use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use serde::Serialize;
use crate::actions::webdev::{SCRIPT_EXEC_ROUTE, SECRET_HEADER};
use crate::client::GatewayApi;
use crate::config::Config;
use crate::error::CoreError;
pub const PRODUCTION_WARNING: &str = "JVM PROFILING ACTIVELY INSTRUMENTS THE GATEWAY: sampling adds CPU overhead \
proportional to thread count x frequency; flight recordings buffer in JVM \
memory and dump to gateway disk. NEVER use this on a production server \
outside an approved change window with a rollback plan — and always use \
the shortest duration that answers the question.";
pub const RISK_FLAG: &str = "--i-accept-the-risk";
pub const JFR_RECORDING_NAME: &str = "ign-cli";
pub const SAMPLE_DURATION_DEFAULT_SECS: u64 = 30;
pub const SAMPLE_DURATION_MIN_SECS: u64 = 5;
pub const SAMPLE_DURATION_MAX_SECS: u64 = 120;
pub const SAMPLE_INTERVAL_DEFAULT_MS: u64 = 20;
pub const SAMPLE_INTERVAL_MIN_MS: u64 = 5;
pub const SAMPLE_INTERVAL_MAX_MS: u64 = 1000;
pub const HOT_METHODS_DEFAULT: u64 = 25;
pub const HOT_METHODS_MAX: u64 = 100;
pub const TOP_STACKS_KEPT: u64 = 200;
pub const MAX_STACK_FRAMES: u64 = 64;
pub const JFR_DURATION_DEFAULT_SECS: u64 = 60;
pub const JFR_DURATION_MIN_SECS: u64 = 10;
pub const JFR_DURATION_MAX_SECS: u64 = 300;
pub const JFR_DUMP_MAX_BYTES: u64 = 32 * 1024 * 1024;
const EXEC_TIMEOUT_MARGIN_SECS: u64 = 30;
#[derive(Debug, Clone, Copy)]
pub struct JvmSampleOptions {
pub duration_secs: u64,
pub interval_ms: u64,
pub top: u64,
}
impl Default for JvmSampleOptions {
fn default() -> Self {
Self {
duration_secs: SAMPLE_DURATION_DEFAULT_SECS,
interval_ms: SAMPLE_INTERVAL_DEFAULT_MS,
top: HOT_METHODS_DEFAULT,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct JvmJfrStartOptions {
pub duration_secs: u64,
pub settings: JfrSettings,
}
impl Default for JvmJfrStartOptions {
fn default() -> Self {
Self {
duration_secs: JFR_DURATION_DEFAULT_SECS,
settings: JfrSettings::Default,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JfrSettings {
Default,
Profile,
}
impl JfrSettings {
pub fn as_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::Profile => "profile",
}
}
}
#[derive(Debug, Serialize)]
pub struct HotMethod {
pub frame: String,
pub count: u64,
pub pct: f64,
}
#[derive(Debug, Serialize)]
pub struct HotStack {
pub stack: String,
pub count: u64,
pub pct: f64,
}
#[derive(Debug, Serialize)]
pub struct JvmSampleResult {
pub warning: &'static str,
pub duration_secs: u64,
pub interval_ms: u64,
pub samples: u64,
pub thread_peak: u64,
pub observations: u64,
pub wall_ms: u64,
pub hot_methods: Vec<HotMethod>,
pub top_stacks: Vec<HotStack>,
pub folded: String,
#[serde(rename = "elapsedMs")]
pub elapsed_ms: u64,
}
#[derive(Debug, Serialize)]
pub struct JvmJfrStartResult {
pub warning: &'static str,
pub name: &'static str,
pub duration_secs: u64,
pub settings: &'static str,
pub detail: String,
#[serde(rename = "elapsedMs")]
pub elapsed_ms: u64,
}
#[derive(Debug, Serialize)]
pub struct JvmJfrStatusResult {
pub warning: &'static str,
pub active: bool,
pub recordings: String,
#[serde(rename = "elapsedMs")]
pub elapsed_ms: u64,
}
#[derive(Debug, Serialize)]
pub struct JvmJfrStopResult {
pub warning: &'static str,
pub name: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
pub fetched: bool,
pub bytes: u64,
pub remote_file: String,
pub remote_deleted: bool,
#[serde(rename = "elapsedMs")]
pub elapsed_ms: u64,
}
pub fn require_risk_acceptance(accepted: bool, operation: &str) -> Result<(), CoreError> {
if accepted {
Ok(())
} else {
Err(CoreError::ProfilingRiskNotAccepted {
operation: operation.to_string(),
})
}
}
pub fn validate_sample_options(opts: &JvmSampleOptions) -> Result<(), CoreError> {
if opts.duration_secs > SAMPLE_DURATION_MAX_SECS {
return Err(CoreError::InvalidInput {
reason: format!(
"sample duration {}s exceeds the hard cap of {}s — the cap is not \
configurable; profile in short windows and repeat if needed",
opts.duration_secs, SAMPLE_DURATION_MAX_SECS
),
});
}
if opts.duration_secs < SAMPLE_DURATION_MIN_SECS {
return Err(CoreError::InvalidInput {
reason: format!(
"sample duration {}s is below the {}s floor — fewer samples is \
noise, not a profile",
opts.duration_secs, SAMPLE_DURATION_MIN_SECS
),
});
}
if opts.interval_ms > SAMPLE_INTERVAL_MAX_MS || opts.interval_ms < SAMPLE_INTERVAL_MIN_MS {
return Err(CoreError::InvalidInput {
reason: format!(
"sample interval {}ms is outside {}–{}ms (below {}ms the sampler \
thread itself skews the JVM)",
opts.interval_ms,
SAMPLE_INTERVAL_MIN_MS,
SAMPLE_INTERVAL_MAX_MS,
SAMPLE_INTERVAL_MIN_MS
),
});
}
if opts.top == 0 || opts.top > HOT_METHODS_MAX {
return Err(CoreError::InvalidInput {
reason: format!("--top must be within 1–{HOT_METHODS_MAX}"),
});
}
Ok(())
}
pub fn validate_jfr_start_options(opts: &JvmJfrStartOptions) -> Result<(), CoreError> {
if opts.duration_secs > JFR_DURATION_MAX_SECS {
return Err(CoreError::InvalidInput {
reason: format!(
"JFR duration {}s exceeds the hard cap of {}s — the cap is not \
configurable; record short windows and repeat if needed",
opts.duration_secs, JFR_DURATION_MAX_SECS
),
});
}
if opts.duration_secs < JFR_DURATION_MIN_SECS {
return Err(CoreError::InvalidInput {
reason: format!(
"JFR duration {}s is below the {}s floor",
opts.duration_secs, JFR_DURATION_MIN_SECS
),
});
}
Ok(())
}
pub fn build_sample_code(opts: &JvmSampleOptions) -> String {
SAMPLE_TEMPLATE
.replace("__DURATION_MS__", &(opts.duration_secs * 1000).to_string())
.replace("__INTERVAL_MS__", &opts.interval_ms.to_string())
.replace("__TOP__", &opts.top.to_string())
.replace("__MAX_STACKS__", &TOP_STACKS_KEPT.to_string())
.replace("__MAX_FRAMES__", &MAX_STACK_FRAMES.to_string())
}
const SAMPLE_TEMPLATE: &str = r#"
import system
def _ign_jvm_sample():
# The route surfaces the _result GLOBAL (the 05-01 exec contract)
# — an in-function assignment needs the declaration, live-proven:
# without it the answer's `result` comes back empty.
global _result
from java.lang.management import ManagementFactory
from java.lang import Thread
import time
bean = ManagementFactory.getThreadMXBean()
me = Thread.currentThread().getName()
start = time.time()
deadline = start + (__DURATION_MS__ / 1000.0)
interval = __INTERVAL_MS__ / 1000.0
self_counts = {}
stack_counts = {}
samples = 0
thread_peak = 0
observations = 0
while True:
infos = bean.dumpAllThreads(False, False)
live = 0
for info in infos:
if info is None:
continue
if info.getThreadName() == me:
continue
trace = info.getStackTrace()
if trace is None or len(trace) == 0:
continue
live += 1
frames = []
limit = min(len(trace), __MAX_FRAMES__)
for i in range(limit):
el = trace[i]
frames.append(str(el.getClassName()) + '.' + str(el.getMethodName()))
top = frames[0]
self_counts[top] = self_counts.get(top, 0) + 1
frames.reverse()
key = ';'.join(frames)
stack_counts[key] = stack_counts.get(key, 0) + 1
samples += 1
observations += live
if live > thread_peak:
thread_peak = live
if time.time() >= deadline:
break
time.sleep(interval)
wall_ms = int((time.time() - start) * 1000)
hot = sorted(self_counts.items(), key=lambda kv: kv[1], reverse=True)[:__TOP__]
stacks = sorted(stack_counts.items(), key=lambda kv: kv[1], reverse=True)[:__MAX_STACKS__]
folded = '\n'.join([k + ' ' + str(v) for k, v in stacks])
_result = system.util.jsonEncode({
'samples': samples,
'threadPeak': thread_peak,
'observations': observations,
'wallMs': wall_ms,
'hot': [{'frame': k, 'count': v} for k, v in hot],
'stacks': [{'stack': k, 'count': v} for k, v in stacks],
'folded': folded,
})
_ign_jvm_sample()
"#;
const JFR_PRELUDE: &str = r#"
import system
def _ign_jfr_diag():
from java.lang.management import ManagementFactory
from javax.management import ObjectName
return ManagementFactory.getPlatformMBeanServer(), \
ObjectName('com.sun.management:type=DiagnosticCommand')
def _ign_jfr_invoke(mbean, oname, op, args):
from jarray import array
from java.lang import String, Object
return str(mbean.invoke(oname, op,
array([array(args, String)], Object),
array(['[Ljava.lang.String;'], String)))
def _ign_jfr_check(mbean, oname):
return _ign_jfr_invoke(mbean, oname, 'jfrCheck', [])
"#;
pub fn build_jfr_start_code(opts: &JvmJfrStartOptions) -> String {
JFR_START_TEMPLATE
.replace("__NAME__", JFR_RECORDING_NAME)
.replace("__DURATION_SECS__", &opts.duration_secs.to_string())
.replace("__SETTINGS__", opts.settings.as_str())
}
const JFR_START_TEMPLATE: &str = r#"
_mbean, _oname = _ign_jfr_diag()
_check = _ign_jfr_check(_mbean, _oname)
if 'name=__NAME__' in _check:
raise Exception('a flight recording named __NAME__ already exists — stop it first: ign jvm jfr stop')
_out = _ign_jfr_invoke(_mbean, _oname, 'jfrStart',
['name=__NAME__', 'duration=__DURATION_SECS__s', 'settings=__SETTINGS__'])
_result = system.util.jsonEncode({
'output': _out,
'recordings': _ign_jfr_check(_mbean, _oname),
})
"#;
pub fn build_jfr_status_code() -> String {
JFR_STATUS_TEMPLATE.replace("__NAME__", JFR_RECORDING_NAME)
}
const JFR_STATUS_TEMPLATE: &str = r#"
_mbean, _oname = _ign_jfr_diag()
_check = _ign_jfr_invoke(_mbean, _oname, 'jfrCheck', [])
_result = system.util.jsonEncode({
'recordings': _check,
'active': 'name=__NAME__' in _check,
})
"#;
pub fn build_jfr_stop_code() -> String {
JFR_STOP_TEMPLATE
.replace("__NAME__", JFR_RECORDING_NAME)
.replace("__MAX_BYTES__", &JFR_DUMP_MAX_BYTES.to_string())
}
const JFR_STOP_TEMPLATE: &str = r#"
import time as _t
from java.io import File
from java.lang import System
_mbean, _oname = _ign_jfr_diag()
_check = _ign_jfr_check(_mbean, _oname)
if 'name=__NAME__' not in _check:
raise Exception('no flight recording named __NAME__ exists — start one first: ign jvm jfr start')
_path = System.getProperty('java.io.tmpdir') + '/__NAME__-' + str(int(_t.time())) + '.jfr'
_ign_jfr_invoke(_mbean, _oname, 'jfrDump', ['name=__NAME__', 'filename=' + _path])
_ign_jfr_invoke(_mbean, _oname, 'jfrStop', ['name=__NAME__'])
_f = File(_path)
_size = _f.length()
if _size > __MAX_BYTES__:
_result = system.util.jsonEncode({
'remoteFile': _path, 'bytes': _size,
'tooLarge': True, 'data': None, 'deleted': False,
})
else:
# The dump holds JVM memory contents: a failed read/encode must
# NOT strand it on the gateway (review finding — the old
# read-then-delete sequence left the file with its path
# unreported). Delete in the failure path and name the path in
# the raised error as the last-resort breadcrumb.
import base64
try:
_fh = open(_path, 'rb')
try:
_raw = _fh.read()
finally:
_fh.close()
_b64 = base64.b64encode(_raw)
except Exception as _e:
_cleaned = _f.delete()
raise Exception('jfr dump fetch failed at ' + _path +
' (gateway-side delete: ' + str(_cleaned) + '): ' + str(_e))
_deleted = _f.delete()
_result = system.util.jsonEncode({
'remoteFile': _path, 'bytes': _size,
'tooLarge': False, 'data': _b64,
'deleted': bool(_deleted),
})
"#;
fn resolve_secret(config: &Config, profile_name: &str) -> Result<String, CoreError> {
config
.profiles
.get(profile_name)
.and_then(|profile| profile.webdev_secret.clone())
.ok_or_else(|| CoreError::ScriptExecNotConfigured {
profile: profile_name.to_string(),
})
}
async fn exec_with_timeout(
api: &dyn GatewayApi,
config: &Config,
profile_name: &str,
project: &str,
code: &str,
timeout: Duration,
) -> Result<serde_json::Value, CoreError> {
let secret = resolve_secret(config, profile_name)?;
api.webdev_route_call(
project,
SCRIPT_EXEC_ROUTE,
&serde_json::json!({"action": "version"}),
&[(SECRET_HEADER, secret.as_str())],
)
.await?;
api.webdev_route_call_with_timeout(
project,
SCRIPT_EXEC_ROUTE,
&serde_json::json!({"action": "exec", "code": code}),
&[(SECRET_HEADER, secret.as_str())],
timeout,
)
.await
}
fn parse_payload(data: &serde_json::Value, verb: &str) -> Result<serde_json::Value, CoreError> {
let raw = data
.get("result")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
serde_json::from_str(raw).map_err(|err| {
CoreError::Internal(format!(
"jvm {verb}: the gateway's payload is not the JSON the payload builder \
emits (route/exec contract drift): {err}"
))
})
}
fn elapsed_ms(data: &serde_json::Value) -> u64 {
data.get("elapsedMs")
.and_then(serde_json::Value::as_u64)
.unwrap_or_default()
}
pub async fn jvm_sample(
api: &dyn GatewayApi,
config: &Config,
profile_name: &str,
project: &str,
opts: &JvmSampleOptions,
risk_accepted: bool,
) -> Result<JvmSampleResult, CoreError> {
validate_sample_options(opts)?;
require_risk_acceptance(risk_accepted, "jvm sample")?;
let code = build_sample_code(opts);
let timeout = Duration::from_secs(opts.duration_secs + EXEC_TIMEOUT_MARGIN_SECS);
let data = exec_with_timeout(api, config, profile_name, project, &code, timeout).await?;
let payload = parse_payload(&data, "sample")?;
let samples = payload["samples"].as_u64().unwrap_or_default();
let observations = payload["observations"].as_u64().unwrap_or_default();
let pct = |count: u64| -> f64 {
if observations == 0 {
0.0
} else {
((count as f64 / observations as f64) * 1000.0).round() / 10.0
}
};
let hot_methods = payload["hot"]
.as_array()
.map(|rows| {
rows.iter()
.map(|row| HotMethod {
frame: row["frame"].as_str().unwrap_or_default().to_string(),
count: row["count"].as_u64().unwrap_or_default(),
pct: pct(row["count"].as_u64().unwrap_or_default()),
})
.collect()
})
.unwrap_or_default();
let top_stacks = payload["stacks"]
.as_array()
.map(|rows| {
rows.iter()
.map(|row| HotStack {
stack: row["stack"].as_str().unwrap_or_default().to_string(),
count: row["count"].as_u64().unwrap_or_default(),
pct: pct(row["count"].as_u64().unwrap_or_default()),
})
.collect()
})
.unwrap_or_default();
Ok(JvmSampleResult {
warning: PRODUCTION_WARNING,
duration_secs: opts.duration_secs,
interval_ms: opts.interval_ms,
samples,
thread_peak: payload["threadPeak"].as_u64().unwrap_or_default(),
observations,
wall_ms: payload["wallMs"].as_u64().unwrap_or_default(),
hot_methods,
top_stacks,
folded: payload["folded"].as_str().unwrap_or_default().to_string(),
elapsed_ms: elapsed_ms(&data),
})
}
pub async fn jvm_jfr_start(
api: &dyn GatewayApi,
config: &Config,
profile_name: &str,
project: &str,
opts: &JvmJfrStartOptions,
risk_accepted: bool,
) -> Result<JvmJfrStartResult, CoreError> {
validate_jfr_start_options(opts)?;
require_risk_acceptance(risk_accepted, "jvm jfr start")?;
let code = format!("{JFR_PRELUDE}\n{}", build_jfr_start_code(opts));
let data = exec_with_timeout(
api,
config,
profile_name,
project,
&code,
Duration::from_secs(30),
)
.await?;
let payload = parse_payload(&data, "jfr start")?;
Ok(JvmJfrStartResult {
warning: PRODUCTION_WARNING,
name: JFR_RECORDING_NAME,
duration_secs: opts.duration_secs,
settings: opts.settings.as_str(),
detail: payload["output"].as_str().unwrap_or_default().to_string(),
elapsed_ms: elapsed_ms(&data),
})
}
pub async fn jvm_jfr_status(
api: &dyn GatewayApi,
config: &Config,
profile_name: &str,
project: &str,
) -> Result<JvmJfrStatusResult, CoreError> {
let code = format!("{JFR_PRELUDE}\n{}", build_jfr_status_code());
let data = exec_with_timeout(
api,
config,
profile_name,
project,
&code,
Duration::from_secs(30),
)
.await?;
let payload = parse_payload(&data, "jfr status")?;
Ok(JvmJfrStatusResult {
warning: PRODUCTION_WARNING,
active: payload["active"].as_bool().unwrap_or_default(),
recordings: payload["recordings"]
.as_str()
.unwrap_or_default()
.to_string(),
elapsed_ms: elapsed_ms(&data),
})
}
pub async fn jvm_jfr_stop(
api: &dyn GatewayApi,
config: &Config,
profile_name: &str,
project: &str,
output: Option<&Path>,
) -> Result<JvmJfrStopResult, CoreError> {
let code = format!("{JFR_PRELUDE}\n{}", build_jfr_stop_code());
let data = exec_with_timeout(
api,
config,
profile_name,
project,
&code,
Duration::from_secs(120),
)
.await?;
let payload = parse_payload(&data, "jfr stop")?;
let bytes = payload["bytes"].as_u64().unwrap_or_default();
let remote_file = payload["remoteFile"]
.as_str()
.unwrap_or_default()
.to_string();
let too_large = payload["tooLarge"].as_bool().unwrap_or_default();
if too_large {
return Ok(JvmJfrStopResult {
warning: PRODUCTION_WARNING,
name: JFR_RECORDING_NAME,
file: None,
fetched: false,
bytes,
remote_file,
remote_deleted: false,
elapsed_ms: elapsed_ms(&data),
});
}
let encoded = payload["data"].as_str().unwrap_or_default();
let decoded = BASE64.decode(encoded).map_err(|err| {
CoreError::Internal(format!(
"jvm jfr stop: the gateway's dump is not valid base64 (route/exec \
contract drift): {err}"
))
})?;
let default_name;
let path = match output {
Some(path) => path.to_path_buf(),
None => {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
default_name = format!("{JFR_RECORDING_NAME}-{ts}.jfr");
std::path::PathBuf::from(&default_name)
}
};
std::fs::write(&path, &decoded).map_err(|err| CoreError::InvalidInput {
reason: format!("cannot write {}: {err}", path.display()),
})?;
Ok(JvmJfrStopResult {
warning: PRODUCTION_WARNING,
name: JFR_RECORDING_NAME,
file: Some(path.display().to_string()),
fetched: true,
bytes,
remote_file,
remote_deleted: payload["deleted"].as_bool().unwrap_or_default(),
elapsed_ms: elapsed_ms(&data),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn opts() -> JvmSampleOptions {
JvmSampleOptions::default()
}
#[test]
fn risk_gate_refuses_without_acceptance() {
let err = require_risk_acceptance(false, "jvm sample").expect_err("ungated refuses");
assert_eq!(err.code(), "profiling_risk_not_accepted");
assert_eq!(err.exit_code(), 2);
assert!(
err.to_string().contains(RISK_FLAG),
"the message names the flag: {err}"
);
let hint = err.hint().expect("hint required");
assert!(
hint.contains("production") || hint.contains("PRODUCTION"),
"the hint names the production caution: {hint}"
);
assert!(
hint.contains("--yes deliberately does NOT"),
"the hint states --yes cannot satisfy it: {hint}"
);
assert!(require_risk_acceptance(true, "jvm sample").is_ok());
}
#[test]
fn sample_options_refuse_outside_the_caps() {
let mut o = opts();
o.duration_secs = SAMPLE_DURATION_MAX_SECS + 1;
let err = validate_sample_options(&o).expect_err("above cap refuses");
assert_eq!(err.code(), "invalid_input");
assert_eq!(err.exit_code(), 2);
assert!(err.to_string().contains("120"), "names the cap: {err}");
let mut o = opts();
o.duration_secs = SAMPLE_DURATION_MIN_SECS - 1;
assert!(validate_sample_options(&o).is_err(), "below floor refuses");
let mut o = opts();
o.interval_ms = SAMPLE_INTERVAL_MIN_MS - 1;
assert!(validate_sample_options(&o).is_err(), "interval floor");
let mut o = opts();
o.interval_ms = SAMPLE_INTERVAL_MAX_MS + 1;
assert!(validate_sample_options(&o).is_err(), "interval cap");
let mut o = opts();
o.top = 0;
assert!(validate_sample_options(&o).is_err(), "top floor");
let mut o = opts();
o.top = HOT_METHODS_MAX + 1;
assert!(validate_sample_options(&o).is_err(), "top cap");
let o = JvmSampleOptions {
duration_secs: SAMPLE_DURATION_MAX_SECS,
interval_ms: SAMPLE_INTERVAL_MIN_MS,
top: HOT_METHODS_MAX,
};
assert!(validate_sample_options(&o).is_ok(), "boundaries pass");
}
#[test]
fn jfr_options_refuse_outside_the_caps() {
let o = JvmJfrStartOptions {
duration_secs: JFR_DURATION_MAX_SECS + 1,
..JvmJfrStartOptions::default()
};
let err = validate_jfr_start_options(&o).expect_err("above cap refuses");
assert!(err.to_string().contains("300"), "names the cap: {err}");
let o = JvmJfrStartOptions {
duration_secs: JFR_DURATION_MIN_SECS - 1,
..JvmJfrStartOptions::default()
};
assert!(validate_jfr_start_options(&o).is_err(), "below floor");
let o = JvmJfrStartOptions {
duration_secs: JFR_DURATION_MAX_SECS,
settings: JfrSettings::Profile,
};
assert!(validate_jfr_start_options(&o).is_ok(), "boundaries pass");
}
#[test]
fn builders_substitute_every_token() {
let o = JvmSampleOptions {
duration_secs: 45,
interval_ms: 50,
top: 10,
};
let code = build_sample_code(&o);
for token in [
"__DURATION_MS__",
"__INTERVAL_MS__",
"__TOP__",
"__MAX_STACKS__",
"__MAX_FRAMES__",
] {
assert!(!code.contains(token), "unsubstituted {token}");
}
assert!(code.contains("45000"), "duration in ms");
assert!(code.contains("50"), "interval");
assert!(
code.contains("Thread.currentThread().getName()"),
"self-exclusion"
);
let code = build_jfr_start_code(&JvmJfrStartOptions {
duration_secs: 90,
settings: JfrSettings::Profile,
});
for token in ["__NAME__", "__DURATION_SECS__", "__SETTINGS__"] {
assert!(!code.contains(token), "unsubstituted {token}");
}
assert!(code.contains("duration=90s"));
assert!(code.contains("settings=profile"));
assert!(code.contains("name=ign-cli"));
let code = build_jfr_status_code();
assert!(!code.contains("__NAME__"));
assert!(code.contains("jfrCheck"));
let code = build_jfr_stop_code();
for token in ["__NAME__", "__MAX_BYTES__"] {
assert!(!code.contains(token), "unsubstituted {token}");
}
assert!(code.contains(&JFR_DUMP_MAX_BYTES.to_string()));
assert!(code.contains("jfrDump"));
assert!(code.contains("jfrStop"));
assert!(code.contains("base64.b64encode"));
let dump = code.find("jfrDump").expect("dump present");
let stop = code.find("jfrStop").expect("stop present");
assert!(dump < stop, "dump precedes stop");
}
#[test]
fn parse_payload_reads_the_result_string() {
let data = serde_json::json!({
"stdout": "",
"result": "{\"samples\": 3}",
"elapsedMs": 7,
});
let payload = parse_payload(&data, "sample").expect("parses");
assert_eq!(payload["samples"], 3);
assert_eq!(elapsed_ms(&data), 7);
let data = serde_json::json!({"result": 42});
let err = parse_payload(&data, "sample").expect_err("garbage refuses");
assert_eq!(err.code(), "internal");
}
}