1use std::path::Path;
49use std::time::{Duration, SystemTime, UNIX_EPOCH};
50
51use base64::Engine as _;
52use base64::engine::general_purpose::STANDARD as BASE64;
53use serde::Serialize;
54
55use crate::actions::webdev::{SCRIPT_EXEC_ROUTE, SECRET_HEADER};
56use crate::client::GatewayApi;
57use crate::config::Config;
58use crate::error::CoreError;
59
60pub const PRODUCTION_WARNING: &str = "JVM PROFILING ACTIVELY INSTRUMENTS THE GATEWAY: sampling adds CPU overhead \
64 proportional to thread count x frequency; flight recordings buffer in JVM \
65 memory and dump to gateway disk. NEVER use this on a production server \
66 outside an approved change window with a rollback plan — and always use \
67 the shortest duration that answers the question.";
68
69pub const RISK_FLAG: &str = "--i-accept-the-risk";
72
73pub const JFR_RECORDING_NAME: &str = "ign-cli";
76
77pub const SAMPLE_DURATION_DEFAULT_SECS: u64 = 30;
82pub const SAMPLE_DURATION_MIN_SECS: u64 = 5;
84pub const SAMPLE_DURATION_MAX_SECS: u64 = 120;
86pub const SAMPLE_INTERVAL_DEFAULT_MS: u64 = 20;
89pub const SAMPLE_INTERVAL_MIN_MS: u64 = 5;
91pub const SAMPLE_INTERVAL_MAX_MS: u64 = 1000;
93pub const HOT_METHODS_DEFAULT: u64 = 25;
95pub const HOT_METHODS_MAX: u64 = 100;
97pub const TOP_STACKS_KEPT: u64 = 200;
99pub const MAX_STACK_FRAMES: u64 = 64;
101
102pub const JFR_DURATION_DEFAULT_SECS: u64 = 60;
104pub const JFR_DURATION_MIN_SECS: u64 = 10;
106pub const JFR_DURATION_MAX_SECS: u64 = 300;
108pub const JFR_DUMP_MAX_BYTES: u64 = 32 * 1024 * 1024;
111
112const EXEC_TIMEOUT_MARGIN_SECS: u64 = 30;
115
116#[derive(Debug, Clone, Copy)]
121pub struct JvmSampleOptions {
122 pub duration_secs: u64,
124 pub interval_ms: u64,
126 pub top: u64,
128}
129
130impl Default for JvmSampleOptions {
131 fn default() -> Self {
132 Self {
133 duration_secs: SAMPLE_DURATION_DEFAULT_SECS,
134 interval_ms: SAMPLE_INTERVAL_DEFAULT_MS,
135 top: HOT_METHODS_DEFAULT,
136 }
137 }
138}
139
140#[derive(Debug, Clone, Copy)]
142pub struct JvmJfrStartOptions {
143 pub duration_secs: u64,
147 pub settings: JfrSettings,
152}
153
154impl Default for JvmJfrStartOptions {
155 fn default() -> Self {
156 Self {
157 duration_secs: JFR_DURATION_DEFAULT_SECS,
158 settings: JfrSettings::Default,
159 }
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum JfrSettings {
167 Default,
169 Profile,
171}
172
173impl JfrSettings {
174 pub fn as_str(self) -> &'static str {
176 match self {
177 Self::Default => "default",
178 Self::Profile => "profile",
179 }
180 }
181}
182
183#[derive(Debug, Serialize)]
186pub struct HotMethod {
187 pub frame: String,
190 pub count: u64,
192 pub pct: f64,
194}
195
196#[derive(Debug, Serialize)]
198pub struct HotStack {
199 pub stack: String,
201 pub count: u64,
203 pub pct: f64,
205}
206
207#[derive(Debug, Serialize)]
210pub struct JvmSampleResult {
211 pub warning: &'static str,
213 pub duration_secs: u64,
215 pub interval_ms: u64,
218 pub samples: u64,
220 pub thread_peak: u64,
222 pub observations: u64,
226 pub wall_ms: u64,
230 pub hot_methods: Vec<HotMethod>,
232 pub top_stacks: Vec<HotStack>,
234 pub folded: String,
237 #[serde(rename = "elapsedMs")]
239 pub elapsed_ms: u64,
240}
241
242#[derive(Debug, Serialize)]
244pub struct JvmJfrStartResult {
245 pub warning: &'static str,
247 pub name: &'static str,
249 pub duration_secs: u64,
251 pub settings: &'static str,
253 pub detail: String,
255 #[serde(rename = "elapsedMs")]
257 pub elapsed_ms: u64,
258}
259
260#[derive(Debug, Serialize)]
262pub struct JvmJfrStatusResult {
263 pub warning: &'static str,
265 pub active: bool,
267 pub recordings: String,
270 #[serde(rename = "elapsedMs")]
272 pub elapsed_ms: u64,
273}
274
275#[derive(Debug, Serialize)]
277pub struct JvmJfrStopResult {
278 pub warning: &'static str,
280 pub name: &'static str,
282 #[serde(skip_serializing_if = "Option::is_none")]
286 pub file: Option<String>,
287 pub fetched: bool,
290 pub bytes: u64,
292 pub remote_file: String,
294 pub remote_deleted: bool,
297 #[serde(rename = "elapsedMs")]
299 pub elapsed_ms: u64,
300}
301
302pub fn require_risk_acceptance(accepted: bool, operation: &str) -> Result<(), CoreError> {
310 if accepted {
311 Ok(())
312 } else {
313 Err(CoreError::ProfilingRiskNotAccepted {
314 operation: operation.to_string(),
315 })
316 }
317}
318
319pub fn validate_sample_options(opts: &JvmSampleOptions) -> Result<(), CoreError> {
326 if opts.duration_secs > SAMPLE_DURATION_MAX_SECS {
327 return Err(CoreError::InvalidInput {
328 reason: format!(
329 "sample duration {}s exceeds the hard cap of {}s — the cap is not \
330 configurable; profile in short windows and repeat if needed",
331 opts.duration_secs, SAMPLE_DURATION_MAX_SECS
332 ),
333 });
334 }
335 if opts.duration_secs < SAMPLE_DURATION_MIN_SECS {
336 return Err(CoreError::InvalidInput {
337 reason: format!(
338 "sample duration {}s is below the {}s floor — fewer samples is \
339 noise, not a profile",
340 opts.duration_secs, SAMPLE_DURATION_MIN_SECS
341 ),
342 });
343 }
344 if opts.interval_ms > SAMPLE_INTERVAL_MAX_MS || opts.interval_ms < SAMPLE_INTERVAL_MIN_MS {
345 return Err(CoreError::InvalidInput {
346 reason: format!(
347 "sample interval {}ms is outside {}–{}ms (below {}ms the sampler \
348 thread itself skews the JVM)",
349 opts.interval_ms,
350 SAMPLE_INTERVAL_MIN_MS,
351 SAMPLE_INTERVAL_MAX_MS,
352 SAMPLE_INTERVAL_MIN_MS
353 ),
354 });
355 }
356 if opts.top == 0 || opts.top > HOT_METHODS_MAX {
357 return Err(CoreError::InvalidInput {
358 reason: format!("--top must be within 1–{HOT_METHODS_MAX}"),
359 });
360 }
361 Ok(())
362}
363
364pub fn validate_jfr_start_options(opts: &JvmJfrStartOptions) -> Result<(), CoreError> {
367 if opts.duration_secs > JFR_DURATION_MAX_SECS {
368 return Err(CoreError::InvalidInput {
369 reason: format!(
370 "JFR duration {}s exceeds the hard cap of {}s — the cap is not \
371 configurable; record short windows and repeat if needed",
372 opts.duration_secs, JFR_DURATION_MAX_SECS
373 ),
374 });
375 }
376 if opts.duration_secs < JFR_DURATION_MIN_SECS {
377 return Err(CoreError::InvalidInput {
378 reason: format!(
379 "JFR duration {}s is below the {}s floor",
380 opts.duration_secs, JFR_DURATION_MIN_SECS
381 ),
382 });
383 }
384 Ok(())
385}
386
387pub fn build_sample_code(opts: &JvmSampleOptions) -> String {
400 SAMPLE_TEMPLATE
401 .replace("__DURATION_MS__", &(opts.duration_secs * 1000).to_string())
402 .replace("__INTERVAL_MS__", &opts.interval_ms.to_string())
403 .replace("__TOP__", &opts.top.to_string())
404 .replace("__MAX_STACKS__", &TOP_STACKS_KEPT.to_string())
405 .replace("__MAX_FRAMES__", &MAX_STACK_FRAMES.to_string())
406}
407
408const SAMPLE_TEMPLATE: &str = r#"
409import system
410
411def _ign_jvm_sample():
412 # The route surfaces the _result GLOBAL (the 05-01 exec contract)
413 # — an in-function assignment needs the declaration, live-proven:
414 # without it the answer's `result` comes back empty.
415 global _result
416 from java.lang.management import ManagementFactory
417 from java.lang import Thread
418 import time
419 bean = ManagementFactory.getThreadMXBean()
420 me = Thread.currentThread().getName()
421 start = time.time()
422 deadline = start + (__DURATION_MS__ / 1000.0)
423 interval = __INTERVAL_MS__ / 1000.0
424 self_counts = {}
425 stack_counts = {}
426 samples = 0
427 thread_peak = 0
428 observations = 0
429 while True:
430 infos = bean.dumpAllThreads(False, False)
431 live = 0
432 for info in infos:
433 if info is None:
434 continue
435 if info.getThreadName() == me:
436 continue
437 trace = info.getStackTrace()
438 if trace is None or len(trace) == 0:
439 continue
440 live += 1
441 frames = []
442 limit = min(len(trace), __MAX_FRAMES__)
443 for i in range(limit):
444 el = trace[i]
445 frames.append(str(el.getClassName()) + '.' + str(el.getMethodName()))
446 top = frames[0]
447 self_counts[top] = self_counts.get(top, 0) + 1
448 frames.reverse()
449 key = ';'.join(frames)
450 stack_counts[key] = stack_counts.get(key, 0) + 1
451 samples += 1
452 observations += live
453 if live > thread_peak:
454 thread_peak = live
455 if time.time() >= deadline:
456 break
457 time.sleep(interval)
458 wall_ms = int((time.time() - start) * 1000)
459 hot = sorted(self_counts.items(), key=lambda kv: kv[1], reverse=True)[:__TOP__]
460 stacks = sorted(stack_counts.items(), key=lambda kv: kv[1], reverse=True)[:__MAX_STACKS__]
461 folded = '\n'.join([k + ' ' + str(v) for k, v in stacks])
462 _result = system.util.jsonEncode({
463 'samples': samples,
464 'threadPeak': thread_peak,
465 'observations': observations,
466 'wallMs': wall_ms,
467 'hot': [{'frame': k, 'count': v} for k, v in hot],
468 'stacks': [{'stack': k, 'count': v} for k, v in stacks],
469 'folded': folded,
470 })
471
472_ign_jvm_sample()
473"#;
474
475const JFR_PRELUDE: &str = r#"
483import system
484
485def _ign_jfr_diag():
486 from java.lang.management import ManagementFactory
487 from javax.management import ObjectName
488 return ManagementFactory.getPlatformMBeanServer(), \
489 ObjectName('com.sun.management:type=DiagnosticCommand')
490
491def _ign_jfr_invoke(mbean, oname, op, args):
492 from jarray import array
493 from java.lang import String, Object
494 return str(mbean.invoke(oname, op,
495 array([array(args, String)], Object),
496 array(['[Ljava.lang.String;'], String)))
497
498def _ign_jfr_check(mbean, oname):
499 return _ign_jfr_invoke(mbean, oname, 'jfrCheck', [])
500"#;
501
502pub fn build_jfr_start_code(opts: &JvmJfrStartOptions) -> String {
508 JFR_START_TEMPLATE
509 .replace("__NAME__", JFR_RECORDING_NAME)
510 .replace("__DURATION_SECS__", &opts.duration_secs.to_string())
511 .replace("__SETTINGS__", opts.settings.as_str())
512}
513
514const JFR_START_TEMPLATE: &str = r#"
515_mbean, _oname = _ign_jfr_diag()
516_check = _ign_jfr_check(_mbean, _oname)
517if 'name=__NAME__' in _check:
518 raise Exception('a flight recording named __NAME__ already exists — stop it first: ign jvm jfr stop')
519_out = _ign_jfr_invoke(_mbean, _oname, 'jfrStart',
520 ['name=__NAME__', 'duration=__DURATION_SECS__s', 'settings=__SETTINGS__'])
521_result = system.util.jsonEncode({
522 'output': _out,
523 'recordings': _ign_jfr_check(_mbean, _oname),
524})
525"#;
526
527pub fn build_jfr_status_code() -> String {
530 JFR_STATUS_TEMPLATE.replace("__NAME__", JFR_RECORDING_NAME)
531}
532
533const JFR_STATUS_TEMPLATE: &str = r#"
534_mbean, _oname = _ign_jfr_diag()
535_check = _ign_jfr_invoke(_mbean, _oname, 'jfrCheck', [])
536_result = system.util.jsonEncode({
537 'recordings': _check,
538 'active': 'name=__NAME__' in _check,
539})
540"#;
541
542pub fn build_jfr_stop_code() -> String {
546 JFR_STOP_TEMPLATE
547 .replace("__NAME__", JFR_RECORDING_NAME)
548 .replace("__MAX_BYTES__", &JFR_DUMP_MAX_BYTES.to_string())
549}
550
551const JFR_STOP_TEMPLATE: &str = r#"
552import time as _t
553from java.io import File
554from java.lang import System
555_mbean, _oname = _ign_jfr_diag()
556_check = _ign_jfr_check(_mbean, _oname)
557if 'name=__NAME__' not in _check:
558 raise Exception('no flight recording named __NAME__ exists — start one first: ign jvm jfr start')
559_path = System.getProperty('java.io.tmpdir') + '/__NAME__-' + str(int(_t.time())) + '.jfr'
560_ign_jfr_invoke(_mbean, _oname, 'jfrDump', ['name=__NAME__', 'filename=' + _path])
561_ign_jfr_invoke(_mbean, _oname, 'jfrStop', ['name=__NAME__'])
562_f = File(_path)
563_size = _f.length()
564if _size > __MAX_BYTES__:
565 _result = system.util.jsonEncode({
566 'remoteFile': _path, 'bytes': _size,
567 'tooLarge': True, 'data': None, 'deleted': False,
568 })
569else:
570 # The dump holds JVM memory contents: a failed read/encode must
571 # NOT strand it on the gateway (review finding — the old
572 # read-then-delete sequence left the file with its path
573 # unreported). Delete in the failure path and name the path in
574 # the raised error as the last-resort breadcrumb.
575 import base64
576 try:
577 _fh = open(_path, 'rb')
578 try:
579 _raw = _fh.read()
580 finally:
581 _fh.close()
582 _b64 = base64.b64encode(_raw)
583 except Exception as _e:
584 _cleaned = _f.delete()
585 raise Exception('jfr dump fetch failed at ' + _path +
586 ' (gateway-side delete: ' + str(_cleaned) + '): ' + str(_e))
587 _deleted = _f.delete()
588 _result = system.util.jsonEncode({
589 'remoteFile': _path, 'bytes': _size,
590 'tooLarge': False, 'data': _b64,
591 'deleted': bool(_deleted),
592 })
593"#;
594
595fn resolve_secret(config: &Config, profile_name: &str) -> Result<String, CoreError> {
601 config
602 .profiles
603 .get(profile_name)
604 .and_then(|profile| profile.webdev_secret.clone())
605 .ok_or_else(|| CoreError::ScriptExecNotConfigured {
606 profile: profile_name.to_string(),
607 })
608}
609
610async fn exec_with_timeout(
616 api: &dyn GatewayApi,
617 config: &Config,
618 profile_name: &str,
619 project: &str,
620 code: &str,
621 timeout: Duration,
622) -> Result<serde_json::Value, CoreError> {
623 let secret = resolve_secret(config, profile_name)?;
624 api.webdev_route_call(
626 project,
627 SCRIPT_EXEC_ROUTE,
628 &serde_json::json!({"action": "version"}),
629 &[(SECRET_HEADER, secret.as_str())],
630 )
631 .await?;
632 api.webdev_route_call_with_timeout(
633 project,
634 SCRIPT_EXEC_ROUTE,
635 &serde_json::json!({"action": "exec", "code": code}),
636 &[(SECRET_HEADER, secret.as_str())],
637 timeout,
638 )
639 .await
640}
641
642fn parse_payload(data: &serde_json::Value, verb: &str) -> Result<serde_json::Value, CoreError> {
647 let raw = data
648 .get("result")
649 .and_then(serde_json::Value::as_str)
650 .unwrap_or_default();
651 serde_json::from_str(raw).map_err(|err| {
652 CoreError::Internal(format!(
653 "jvm {verb}: the gateway's payload is not the JSON the payload builder \
654 emits (route/exec contract drift): {err}"
655 ))
656 })
657}
658
659fn elapsed_ms(data: &serde_json::Value) -> u64 {
662 data.get("elapsedMs")
663 .and_then(serde_json::Value::as_u64)
664 .unwrap_or_default()
665}
666
667pub async fn jvm_sample(
675 api: &dyn GatewayApi,
676 config: &Config,
677 profile_name: &str,
678 project: &str,
679 opts: &JvmSampleOptions,
680 risk_accepted: bool,
681) -> Result<JvmSampleResult, CoreError> {
682 validate_sample_options(opts)?;
683 require_risk_acceptance(risk_accepted, "jvm sample")?;
684 let code = build_sample_code(opts);
685 let timeout = Duration::from_secs(opts.duration_secs + EXEC_TIMEOUT_MARGIN_SECS);
686 let data = exec_with_timeout(api, config, profile_name, project, &code, timeout).await?;
687 let payload = parse_payload(&data, "sample")?;
688
689 let samples = payload["samples"].as_u64().unwrap_or_default();
690 let observations = payload["observations"].as_u64().unwrap_or_default();
691 let pct = |count: u64| -> f64 {
695 if observations == 0 {
696 0.0
697 } else {
698 ((count as f64 / observations as f64) * 1000.0).round() / 10.0
699 }
700 };
701 let hot_methods = payload["hot"]
702 .as_array()
703 .map(|rows| {
704 rows.iter()
705 .map(|row| HotMethod {
706 frame: row["frame"].as_str().unwrap_or_default().to_string(),
707 count: row["count"].as_u64().unwrap_or_default(),
708 pct: pct(row["count"].as_u64().unwrap_or_default()),
709 })
710 .collect()
711 })
712 .unwrap_or_default();
713 let top_stacks = payload["stacks"]
714 .as_array()
715 .map(|rows| {
716 rows.iter()
717 .map(|row| HotStack {
718 stack: row["stack"].as_str().unwrap_or_default().to_string(),
719 count: row["count"].as_u64().unwrap_or_default(),
720 pct: pct(row["count"].as_u64().unwrap_or_default()),
721 })
722 .collect()
723 })
724 .unwrap_or_default();
725
726 Ok(JvmSampleResult {
727 warning: PRODUCTION_WARNING,
728 duration_secs: opts.duration_secs,
729 interval_ms: opts.interval_ms,
730 samples,
731 thread_peak: payload["threadPeak"].as_u64().unwrap_or_default(),
732 observations,
733 wall_ms: payload["wallMs"].as_u64().unwrap_or_default(),
734 hot_methods,
735 top_stacks,
736 folded: payload["folded"].as_str().unwrap_or_default().to_string(),
737 elapsed_ms: elapsed_ms(&data),
738 })
739}
740
741pub async fn jvm_jfr_start(
743 api: &dyn GatewayApi,
744 config: &Config,
745 profile_name: &str,
746 project: &str,
747 opts: &JvmJfrStartOptions,
748 risk_accepted: bool,
749) -> Result<JvmJfrStartResult, CoreError> {
750 validate_jfr_start_options(opts)?;
751 require_risk_acceptance(risk_accepted, "jvm jfr start")?;
752 let code = format!("{JFR_PRELUDE}\n{}", build_jfr_start_code(opts));
753 let data = exec_with_timeout(
754 api,
755 config,
756 profile_name,
757 project,
758 &code,
759 Duration::from_secs(30),
760 )
761 .await?;
762 let payload = parse_payload(&data, "jfr start")?;
763 Ok(JvmJfrStartResult {
764 warning: PRODUCTION_WARNING,
765 name: JFR_RECORDING_NAME,
766 duration_secs: opts.duration_secs,
767 settings: opts.settings.as_str(),
768 detail: payload["output"].as_str().unwrap_or_default().to_string(),
769 elapsed_ms: elapsed_ms(&data),
770 })
771}
772
773pub async fn jvm_jfr_status(
777 api: &dyn GatewayApi,
778 config: &Config,
779 profile_name: &str,
780 project: &str,
781) -> Result<JvmJfrStatusResult, CoreError> {
782 let code = format!("{JFR_PRELUDE}\n{}", build_jfr_status_code());
783 let data = exec_with_timeout(
784 api,
785 config,
786 profile_name,
787 project,
788 &code,
789 Duration::from_secs(30),
790 )
791 .await?;
792 let payload = parse_payload(&data, "jfr status")?;
793 Ok(JvmJfrStatusResult {
794 warning: PRODUCTION_WARNING,
795 active: payload["active"].as_bool().unwrap_or_default(),
796 recordings: payload["recordings"]
797 .as_str()
798 .unwrap_or_default()
799 .to_string(),
800 elapsed_ms: elapsed_ms(&data),
801 })
802}
803
804pub async fn jvm_jfr_stop(
811 api: &dyn GatewayApi,
812 config: &Config,
813 profile_name: &str,
814 project: &str,
815 output: Option<&Path>,
816) -> Result<JvmJfrStopResult, CoreError> {
817 let code = format!("{JFR_PRELUDE}\n{}", build_jfr_stop_code());
818 let data = exec_with_timeout(
820 api,
821 config,
822 profile_name,
823 project,
824 &code,
825 Duration::from_secs(120),
826 )
827 .await?;
828 let payload = parse_payload(&data, "jfr stop")?;
829
830 let bytes = payload["bytes"].as_u64().unwrap_or_default();
831 let remote_file = payload["remoteFile"]
832 .as_str()
833 .unwrap_or_default()
834 .to_string();
835 let too_large = payload["tooLarge"].as_bool().unwrap_or_default();
836
837 if too_large {
838 return Ok(JvmJfrStopResult {
839 warning: PRODUCTION_WARNING,
840 name: JFR_RECORDING_NAME,
841 file: None,
842 fetched: false,
843 bytes,
844 remote_file,
845 remote_deleted: false,
846 elapsed_ms: elapsed_ms(&data),
847 });
848 }
849
850 let encoded = payload["data"].as_str().unwrap_or_default();
851 let decoded = BASE64.decode(encoded).map_err(|err| {
852 CoreError::Internal(format!(
853 "jvm jfr stop: the gateway's dump is not valid base64 (route/exec \
854 contract drift): {err}"
855 ))
856 })?;
857 let default_name;
858 let path = match output {
859 Some(path) => path.to_path_buf(),
860 None => {
861 let ts = SystemTime::now()
862 .duration_since(UNIX_EPOCH)
863 .map(|d| d.as_secs())
864 .unwrap_or_default();
865 default_name = format!("{JFR_RECORDING_NAME}-{ts}.jfr");
866 std::path::PathBuf::from(&default_name)
867 }
868 };
869 std::fs::write(&path, &decoded).map_err(|err| CoreError::InvalidInput {
870 reason: format!("cannot write {}: {err}", path.display()),
871 })?;
872
873 Ok(JvmJfrStopResult {
874 warning: PRODUCTION_WARNING,
875 name: JFR_RECORDING_NAME,
876 file: Some(path.display().to_string()),
877 fetched: true,
878 bytes,
879 remote_file,
880 remote_deleted: payload["deleted"].as_bool().unwrap_or_default(),
881 elapsed_ms: elapsed_ms(&data),
882 })
883}
884
885#[cfg(test)]
886mod tests {
887 use super::*;
888
889 fn opts() -> JvmSampleOptions {
890 JvmSampleOptions::default()
891 }
892
893 #[test]
900 fn risk_gate_refuses_without_acceptance() {
901 let err = require_risk_acceptance(false, "jvm sample").expect_err("ungated refuses");
902 assert_eq!(err.code(), "profiling_risk_not_accepted");
903 assert_eq!(err.exit_code(), 2);
904 assert!(
905 err.to_string().contains(RISK_FLAG),
906 "the message names the flag: {err}"
907 );
908 let hint = err.hint().expect("hint required");
909 assert!(
910 hint.contains("production") || hint.contains("PRODUCTION"),
911 "the hint names the production caution: {hint}"
912 );
913 assert!(
914 hint.contains("--yes deliberately does NOT"),
915 "the hint states --yes cannot satisfy it: {hint}"
916 );
917 assert!(require_risk_acceptance(true, "jvm sample").is_ok());
918 }
919
920 #[test]
923 fn sample_options_refuse_outside_the_caps() {
924 let mut o = opts();
925 o.duration_secs = SAMPLE_DURATION_MAX_SECS + 1;
926 let err = validate_sample_options(&o).expect_err("above cap refuses");
927 assert_eq!(err.code(), "invalid_input");
928 assert_eq!(err.exit_code(), 2);
929 assert!(err.to_string().contains("120"), "names the cap: {err}");
930
931 let mut o = opts();
932 o.duration_secs = SAMPLE_DURATION_MIN_SECS - 1;
933 assert!(validate_sample_options(&o).is_err(), "below floor refuses");
934
935 let mut o = opts();
936 o.interval_ms = SAMPLE_INTERVAL_MIN_MS - 1;
937 assert!(validate_sample_options(&o).is_err(), "interval floor");
938 let mut o = opts();
939 o.interval_ms = SAMPLE_INTERVAL_MAX_MS + 1;
940 assert!(validate_sample_options(&o).is_err(), "interval cap");
941
942 let mut o = opts();
943 o.top = 0;
944 assert!(validate_sample_options(&o).is_err(), "top floor");
945 let mut o = opts();
946 o.top = HOT_METHODS_MAX + 1;
947 assert!(validate_sample_options(&o).is_err(), "top cap");
948
949 let o = JvmSampleOptions {
951 duration_secs: SAMPLE_DURATION_MAX_SECS,
952 interval_ms: SAMPLE_INTERVAL_MIN_MS,
953 top: HOT_METHODS_MAX,
954 };
955 assert!(validate_sample_options(&o).is_ok(), "boundaries pass");
956 }
957
958 #[test]
959 fn jfr_options_refuse_outside_the_caps() {
960 let o = JvmJfrStartOptions {
961 duration_secs: JFR_DURATION_MAX_SECS + 1,
962 ..JvmJfrStartOptions::default()
963 };
964 let err = validate_jfr_start_options(&o).expect_err("above cap refuses");
965 assert!(err.to_string().contains("300"), "names the cap: {err}");
966 let o = JvmJfrStartOptions {
967 duration_secs: JFR_DURATION_MIN_SECS - 1,
968 ..JvmJfrStartOptions::default()
969 };
970 assert!(validate_jfr_start_options(&o).is_err(), "below floor");
971 let o = JvmJfrStartOptions {
972 duration_secs: JFR_DURATION_MAX_SECS,
973 settings: JfrSettings::Profile,
974 };
975 assert!(validate_jfr_start_options(&o).is_ok(), "boundaries pass");
976 }
977
978 #[test]
984 fn builders_substitute_every_token() {
985 let o = JvmSampleOptions {
986 duration_secs: 45,
987 interval_ms: 50,
988 top: 10,
989 };
990 let code = build_sample_code(&o);
991 for token in [
992 "__DURATION_MS__",
993 "__INTERVAL_MS__",
994 "__TOP__",
995 "__MAX_STACKS__",
996 "__MAX_FRAMES__",
997 ] {
998 assert!(!code.contains(token), "unsubstituted {token}");
999 }
1000 assert!(code.contains("45000"), "duration in ms");
1001 assert!(code.contains("50"), "interval");
1002 assert!(
1003 code.contains("Thread.currentThread().getName()"),
1004 "self-exclusion"
1005 );
1006
1007 let code = build_jfr_start_code(&JvmJfrStartOptions {
1008 duration_secs: 90,
1009 settings: JfrSettings::Profile,
1010 });
1011 for token in ["__NAME__", "__DURATION_SECS__", "__SETTINGS__"] {
1012 assert!(!code.contains(token), "unsubstituted {token}");
1013 }
1014 assert!(code.contains("duration=90s"));
1015 assert!(code.contains("settings=profile"));
1016 assert!(code.contains("name=ign-cli"));
1017
1018 let code = build_jfr_status_code();
1019 assert!(!code.contains("__NAME__"));
1020 assert!(code.contains("jfrCheck"));
1021
1022 let code = build_jfr_stop_code();
1023 for token in ["__NAME__", "__MAX_BYTES__"] {
1024 assert!(!code.contains(token), "unsubstituted {token}");
1025 }
1026 assert!(code.contains(&JFR_DUMP_MAX_BYTES.to_string()));
1027 assert!(code.contains("jfrDump"));
1028 assert!(code.contains("jfrStop"));
1029 assert!(code.contains("base64.b64encode"));
1030 let dump = code.find("jfrDump").expect("dump present");
1032 let stop = code.find("jfrStop").expect("stop present");
1033 assert!(dump < stop, "dump precedes stop");
1034 }
1035
1036 #[test]
1039 fn parse_payload_reads_the_result_string() {
1040 let data = serde_json::json!({
1041 "stdout": "",
1042 "result": "{\"samples\": 3}",
1043 "elapsedMs": 7,
1044 });
1045 let payload = parse_payload(&data, "sample").expect("parses");
1046 assert_eq!(payload["samples"], 3);
1047 assert_eq!(elapsed_ms(&data), 7);
1048 let data = serde_json::json!({"result": 42});
1050 let err = parse_payload(&data, "sample").expect_err("garbage refuses");
1051 assert_eq!(err.code(), "internal");
1052 }
1053}