Skip to main content

ignition_core/actions/
jvm.rs

1//! JVM profiling actions — `ign jvm sample` / `ign jvm jfr
2//! start|status|stop`. **This is a SPECIAL MODE, not a diagnostic:**
3//! every verb here ACTIVELY instruments the gateway JVM (stack
4//! sampling burns CPU proportional to thread count × frequency; JFR
5//! buffers in JVM memory and dumps to gateway disk). The family is
6//! therefore gated HARDER than the destructive verbs: the global
7//! `--yes`/`IGNITION_YES` does NOT satisfy it — only the explicit
8//! `--i-accept-the-risk` flag (CLI) or the typed `ACKNOWLEDGE`
9//! interstitial (TUI) does, and the refusal is the additive
10//! `profiling_risk_not_accepted` (exit 2 — the usage class: it names
11//! a flag the caller must add, the ConfirmationRequired precedent).
12//!
13//! Two mechanisms, both riding the ALREADY-deployed, secret-gated
14//! scriptExec WebDev route (05-01's contract — the structural opt-in
15//! from `ign webdev deploy --with-script-exec`; no new route, no new
16//! server-side surface):
17//!
18//! 1. **Sampling thread profiler** (`sample`) — a Jython loop inside
19//!    the gateway JVM calls `ThreadMXBean.dumpAllThreads(false,
20//!    false)` every `interval_ms` for `duration_secs`, aggregating
21//!    self-counts per top frame and occurrence counts per full stack
22//!    (folded format — flamegraph-ready). Pure JVM READS: no JVM
23//!    flags, no agents, no disk.
24//! 2. **Java Flight Recorder** (`jfr`) — the
25//!    `com.sun.management:type=DiagnosticCommand` MBean's
26//!    `jfrStart`/`jfrCheck`/`jfrDump`/`jfrStop` (the jcmd-over-MBean
27//!    trick). `start` is duration-capped and memory-buffered (no
28//!    `filename=` at start); `stop` dumps to the gateway's temp dir,
29//!    streams the bytes back base64 (the 11-02/11-04 carrier — the
30//!    ONLY transfer transformation), and deletes the remote file.
31//!
32//! HARD CAPS (the special-mode contract — REFUSED, never clamped, so
33//! the caller sees the boundary): sample duration ≤ 120 s, sample
34//! interval ≥ 5 ms, JFR duration ≤ 300 s, JFR dump ≤ 32 MiB (a bigger
35//! recording stays on the gateway with its path reported — fetched
36//! false, remote_deleted false, never silently truncated).
37//!
38//! The long exec calls ride `webdev_route_call_with_timeout` (the
39//! bundle-download precedent): a 120 s sampling run would die to the
40//! 30 s client default otherwise. The timeout is duration + a fixed
41//! margin, never unlimited.
42//!
43//! Every result model leads with `warning` = [`PRODUCTION_WARNING`]
44//! (ALL keys always — the family convention): the production caution
45//! rides every JSON envelope and every human render, so a piped/agent
46//! consumer can never miss it.
47
48use 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
60/// THE production caution — the single source of truth rendered by
61/// the CLI (human banner + every envelope's `warning` field) and the
62/// TUI (the gate interstitial + the screen's persistent banner).
63pub 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
69/// The CLI flag name the refusal prose names (one spelling, pinned by
70/// the contract goldens).
71pub const RISK_FLAG: &str = "--i-accept-the-risk";
72
73/// The one flight-recording name this CLI manages (stop/status key on
74/// it; a collision with a foreign recording is refused route-side).
75pub const JFR_RECORDING_NAME: &str = "ign-cli";
76
77// ── HARD CAPS (refused, never clamped) ─────────────────────────────
78
79/// Sampling duration: default 30 s, floor 5 s (fewer samples is
80/// noise), HARD CAP 120 s (the special-mode contract).
81pub const SAMPLE_DURATION_DEFAULT_SECS: u64 = 30;
82/// Sampling duration floor.
83pub const SAMPLE_DURATION_MIN_SECS: u64 = 5;
84/// Sampling duration HARD CAP.
85pub const SAMPLE_DURATION_MAX_SECS: u64 = 120;
86/// Sampling interval: default 20 ms, floor 5 ms (below that the
87/// sampler thread itself skews the JVM), cap 1000 ms.
88pub const SAMPLE_INTERVAL_DEFAULT_MS: u64 = 20;
89/// Sampling interval floor.
90pub const SAMPLE_INTERVAL_MIN_MS: u64 = 5;
91/// Sampling interval cap.
92pub const SAMPLE_INTERVAL_MAX_MS: u64 = 1000;
93/// Default `--top` hot-method rows.
94pub const HOT_METHODS_DEFAULT: u64 = 25;
95/// `--top` cap (payload bound).
96pub const HOT_METHODS_MAX: u64 = 100;
97/// Distinct stacks kept server-side (folded payload bound).
98pub const TOP_STACKS_KEPT: u64 = 200;
99/// Frames kept per stack (depth bound).
100pub const MAX_STACK_FRAMES: u64 = 64;
101
102/// JFR duration: default 60 s, floor 10 s, HARD CAP 300 s.
103pub const JFR_DURATION_DEFAULT_SECS: u64 = 60;
104/// JFR duration floor.
105pub const JFR_DURATION_MIN_SECS: u64 = 10;
106/// JFR duration HARD CAP.
107pub const JFR_DURATION_MAX_SECS: u64 = 300;
108/// JFR dump size cap — a bigger file is NOT streamed (reported in
109/// place on the gateway instead).
110pub const JFR_DUMP_MAX_BYTES: u64 = 32 * 1024 * 1024;
111
112/// Margin added to a sampling duration for the exec request's
113/// per-request timeout (route overhead + envelope transfer).
114const EXEC_TIMEOUT_MARGIN_SECS: u64 = 30;
115
116// ── Options & result models (ALL keys always) ──────────────────────
117
118/// `ign jvm sample` options (validated by [`validate_sample_options`]
119/// BEFORE the risk gate or any I/O — usage errors lead).
120#[derive(Debug, Clone, Copy)]
121pub struct JvmSampleOptions {
122    /// Seconds to sample (5..=120).
123    pub duration_secs: u64,
124    /// Milliseconds between thread dumps (5..=1000).
125    pub interval_ms: u64,
126    /// Hot-method rows to return (1..=100).
127    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/// `ign jvm jfr start` options.
141#[derive(Debug, Clone, Copy)]
142pub struct JvmJfrStartOptions {
143    /// Recording duration in seconds (10..=300) — the recording
144    /// auto-stops at the deadline even if `stop` never runs (the
145    /// forgetfulness guard).
146    pub duration_secs: u64,
147    /// The JDK's bundled settings name — `default` (lower overhead)
148    /// or `profile` (deeper, heavier). Whitelisted at the CLI; the
149    /// builder accepts only these two so no free text ever reaches
150    /// the MBean invocation.
151    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/// The two bundled JFR settings files (the whitelist — a free-text
164/// `settings=` would be an injection vector into the MBean call).
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum JfrSettings {
167    /// `settings=default` — the lower-overhead bundled config.
168    Default,
169    /// `settings=profile` — the deeper bundled config.
170    Profile,
171}
172
173impl JfrSettings {
174    /// The MBean argument token.
175    pub fn as_str(self) -> &'static str {
176        match self {
177            Self::Default => "default",
178            Self::Profile => "profile",
179        }
180    }
181}
182
183/// One hot-method row (self-time proxy: thread-stack observations
184/// whose TOP frame is this method).
185#[derive(Debug, Serialize)]
186pub struct HotMethod {
187    /// `com.example.Foo.bar` (class.method — line numbers ride the
188    /// stacks, not this rollup).
189    pub frame: String,
190    /// Observed thread stacks with this frame on top.
191    pub count: u64,
192    /// `count / observations * 100`, one decimal.
193    pub pct: f64,
194}
195
196/// One aggregated stack (folded format: root;…;leaf, space, count).
197#[derive(Debug, Serialize)]
198pub struct HotStack {
199    /// Semicolon-joined frames, root first.
200    pub stack: String,
201    /// Observed occurrences of exactly this stack.
202    pub count: u64,
203    /// `count / observations * 100`, one decimal.
204    pub pct: f64,
205}
206
207/// `ign jvm sample` result — `warning` FIRST (declaration order =
208/// golden field order).
209#[derive(Debug, Serialize)]
210pub struct JvmSampleResult {
211    /// [`PRODUCTION_WARNING`] — rides every render path.
212    pub warning: &'static str,
213    /// The requested duration (validated).
214    pub duration_secs: u64,
215    /// The requested interval (validated — the EFFECTIVE cadence is
216    /// longer: each dump costs wall time the sleep adds to).
217    pub interval_ms: u64,
218    /// Thread dumps taken.
219    pub samples: u64,
220    /// Peak live threads seen in one dump.
221    pub thread_peak: u64,
222    /// Total thread stacks observed across all dumps — the
223    /// denominator every `pct` rides (live-proven: per-SAMPLE
224    /// normalization is nonsense for per-THREAD counts).
225    pub observations: u64,
226    /// Measured wall time of the sampling loop in milliseconds
227    /// (gateway-side clock — the honest "what the window actually
228    /// cost", dump overhead included).
229    pub wall_ms: u64,
230    /// Top-N self-time rollup.
231    pub hot_methods: Vec<HotMethod>,
232    /// The heaviest distinct stacks (≤ [`TOP_STACKS_KEPT`]).
233    pub top_stacks: Vec<HotStack>,
234    /// Folded-stack lines (`root;…;leaf <count>` — flamegraph.pl /
235    /// speedscope input), the same rows as `top_stacks`.
236    pub folded: String,
237    /// The route-measured wall time (the scriptExec convention).
238    #[serde(rename = "elapsedMs")]
239    pub elapsed_ms: u64,
240}
241
242/// `ign jvm jfr start` result.
243#[derive(Debug, Serialize)]
244pub struct JvmJfrStartResult {
245    /// [`PRODUCTION_WARNING`].
246    pub warning: &'static str,
247    /// The recording name ([`JFR_RECORDING_NAME`]).
248    pub name: &'static str,
249    /// Auto-stop deadline (validated).
250    pub duration_secs: u64,
251    /// The bundled settings name used.
252    pub settings: &'static str,
253    /// The MBean's jfrStart answer (verbatim — the JDK's own prose).
254    pub detail: String,
255    /// The route-measured wall time.
256    #[serde(rename = "elapsedMs")]
257    pub elapsed_ms: u64,
258}
259
260/// `ign jvm jfr status` result (READ-only — carries no risk gate).
261#[derive(Debug, Serialize)]
262pub struct JvmJfrStatusResult {
263    /// [`PRODUCTION_WARNING`] — even the read names the mode.
264    pub warning: &'static str,
265    /// Whether a recording named [`JFR_RECORDING_NAME`] exists.
266    pub active: bool,
267    /// The MBean's jfrCheck answer (verbatim — every recording the
268    /// JVM knows, not just ours).
269    pub recordings: String,
270    /// The route-measured wall time.
271    #[serde(rename = "elapsedMs")]
272    pub elapsed_ms: u64,
273}
274
275/// `ign jvm jfr stop` result — stop + dump + fetch + remote cleanup.
276#[derive(Debug, Serialize)]
277pub struct JvmJfrStopResult {
278    /// [`PRODUCTION_WARNING`].
279    pub warning: &'static str,
280    /// The recording name stopped.
281    pub name: &'static str,
282    /// The LOCAL file written — `None` when the dump exceeded
283    /// [`JFR_DUMP_MAX_BYTES`] (`fetched` false; the file stays on the
284    /// gateway and `remote_file` names it).
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub file: Option<String>,
287    /// Whether the bytes were streamed back (false = too large, left
288    /// in place — NEVER silently truncated).
289    pub fetched: bool,
290    /// The dump's size on the gateway (always known, fetched or not).
291    pub bytes: u64,
292    /// The gateway-side path the dump was written to.
293    pub remote_file: String,
294    /// Whether the gateway-side file was deleted after the fetch
295    /// (always false when `fetched` is false).
296    pub remote_deleted: bool,
297    /// The route-measured wall time.
298    #[serde(rename = "elapsedMs")]
299    pub elapsed_ms: u64,
300}
301
302// ── The risk gate ──────────────────────────────────────────────────
303
304/// The special-mode gate — STRONGER than `require_confirmation`: only
305/// the explicit per-invocation opt-in satisfies it (the CLI's
306/// `--i-accept-the-risk`; the TUI's typed `ACKNOWLEDGE`, passed down
307/// as `true`). `--yes`/`IGNITION_YES` never reaches this function —
308/// that is the entire point of the separate error class.
309pub 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
319// ── Validation (refuse above the caps — usage errors lead) ─────────
320
321/// Validate the sampling options against the HARD CAPS. Above-cap
322/// values are REFUSED (`invalid_input`, exit 2) with the cap named —
323/// never silently clamped (the caller must SEE the boundary; a
324/// clamped 10-minute run would look like consent).
325pub 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
364/// Validate the JFR start options against the HARD CAPS (same
365/// refuse-don't-clamp contract as sampling).
366pub 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
387// ── The Jython payloads (token-substituted templates) ──────────────
388//
389// Substitution safety: the ONLY substituted values are validated
390// integers and the whitelisted settings token — no free text ever
391// reaches the gateway-side code. The templates use __TOKEN__ markers
392// (never str.format — the Jython braces would collide).
393
394/// The sampling-profiler payload. Aggregates SERVER-SIDE (the wire
395/// carries rollups, never raw dumps): `hot` self-counts per top
396/// frame, `stacks` occurrence counts per full stack, `folded` the
397/// flamegraph-ready rendering of `stacks`. The sampler's own request
398/// thread is excluded (it would top every chart).
399pub 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
475/// The shared DiagnosticCommand-MBean prelude for the JFR family
476/// (jcmd-over-MBean: every operation is `(String[]) -> String` with
477/// the one `[Ljava.lang.String;` signature). INVOKE SHAPE
478/// (live-proven on 8.3.6 / JDK 17): the params Object[] must wrap
479/// the args String[] as its single element — passing the bare
480/// String[] reads as "zero parameters" and the MBean answers
481/// NoSuchMethodException ("mismatched signature … or parameters").
482const 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
502/// `jfrStart` — memory-buffered (NO `filename=` at start: nothing
503/// touches gateway disk until `stop` dumps), duration-capped so the
504/// recording auto-stops even if the caller forgets. A name collision
505/// (a leftover `ign-cli` recording) refuses with the stop command
506/// named.
507pub 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
527/// `jfrCheck` — the verbatim recording list plus whether OUR name is
528/// present (the TUI polls this to keep its banner honest).
529pub 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
542/// `jfrDump` → size check → base64 stream-back → `jfrStop` → remote
543/// delete. Over-cap dumps are NOT read: the recording is stopped, the
544/// file left in place, and its path+size reported (`tooLarge`).
545pub 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
595// ── The scriptExec plumbing (the 07-03 shape + the timeout override) ──
596
597/// Resolve the profile's persisted scriptExec secret (the structural
598/// gate: no secret = the route was never deployed, refuse before ANY
599/// HTTP — script.rs's must-have truth #3).
600fn 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
610/// The version handshake + one exec carrying `code`, with the
611/// per-request timeout override on the exec (a sampling run holds the
612/// connection for its whole duration — the 30 s client default would
613/// kill it). Returns the exec envelope's `data` ({stdout, result,
614/// elapsedMs}) — the secret appears in NONE of it.
615async 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    // Precondition handshake (plain timeout — it answers instantly).
625    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
642/// Pull the payload out of an exec envelope: the Jython sets
643/// `_result` to a jsonEncode STRING (the route walker never sees
644/// raw Java/Jython containers — one serialization layer, parsed
645/// here), so `data.result` is a string we parse as JSON.
646fn 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
659/// The envelope's route-measured wall time (0 when absent — the key
660/// still rides on the models).
661fn 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
667// ── The actions ────────────────────────────────────────────────────
668
669/// `ign jvm sample` — validate (usage errors lead), gate (the risk
670/// acceptance), THEN instrument. Validation precedes the gate so a
671/// mistyped duration refuses with `invalid_input` even when the risk
672/// flag is missing (the put convention: usage errors lead, exit 2,
673/// profile null either way).
674pub 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    // Per-THREAD counts normalize against per-THREAD observations —
692    // never against samples (live-proven: a busy gateway's hot rows
693    // read 7000%+ under per-sample math).
694    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
741/// `ign jvm jfr start` — validate, gate, start the capped recording.
742pub 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
773/// `ign jvm jfr status` — READ-only (jfrCheck changes nothing), so NO
774/// risk gate: checking whether a recording is running must always be
775/// cheap to reach (it is how you confirm cleanup).
776pub 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
804/// `ign jvm jfr stop` — stop + dump + fetch + remote cleanup. NO risk
805/// gate: stopping a recording REDUCES gateway risk and must never be
806/// refused (a forgotten recording is the failure mode this verb
807/// exists to end). The local file write is the only filesystem touch:
808/// `output` or the `ign-cli-<unix_ts>.jfr` default (the
809/// backup/bundle fallback-naming precedent).
810pub 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    // 32 MiB base64 through the envelope: give the transfer room.
819    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    // ── The risk gate ──────────────────────────────────────────────
894
895    /// The gate refuses without the acceptance, with the additive
896    /// slug, exit 2, and a hint that names the production caution —
897    /// and `--yes` structurally CANNOT satisfy it (the function's
898    /// only input is the explicit acceptance).
899    #[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    // ── Validation: refuse, never clamp ────────────────────────────
921
922    #[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        // The boundaries themselves pass.
950        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    // ── The payload builders ───────────────────────────────────────
979
980    /// Every token is substituted (no `__` marker survives) and the
981    /// substituted values are the validated ones — the substitution
982    /// safety contract (validated ints + whitelisted tokens ONLY).
983    #[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        // Dump BEFORE stop (a stop discards an un-dumped memory recording).
1031        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    // ── Payload parsing ────────────────────────────────────────────
1037
1038    #[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        // A non-string/garbage result is an honesty error, not a panic.
1049        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}