nyx/src/dynamic/trace.rs

227 lines
8.6 KiB
Rust
Raw Normal View History

//! Verify-pipeline trace (Phase 30 — Track C observability).
//!
//! [`VerifyTrace`] is a structured, deterministic record of every stage
//! a single [`crate::dynamic::verify::verify_finding`] call walks
//! through. Two uses:
//!
//! 1. **`--verbose` stderr stream** — when
//! [`crate::dynamic::verify::VerifyOptions::trace_verbose`] is set the
//! verifier prints each event to stderr as it fires. Operators see
//! where a run stalled or which payload triggered without re-running
//! under a debugger.
//! 2. **Repro bundle serialisation** — the trace is emitted into the
//! Phase 28 repro bundle as `expected/trace.jsonl` so a replay knows
//! the canonical sequence its run is expected to mirror. Together
//! with the Phase 27 `events.jsonl` log this gives a forensic
//! "what did the verifier do?" picture that does not require
//! re-running the binary.
//!
//! # Determinism contract
//!
//! `TraceEvent` deliberately omits wall-clock timestamps and durations
//! so two runs of the same finding produce a byte-identical sequence.
//! The Phase 30 acceptance test (`tests/determinism_audit.rs`) runs the
//! verifier 10× on a fixed input and asserts every serialised trace is
//! identical. Elapsed-time annotations are still useful for the
//! stderr printer; they are computed inline at print time from
//! `Instant::now()` and never persisted.
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
/// Distinct stages emitted by the verifier. The names match the Phase
/// 30 spec literal so audit logs grep for `oracle_observed` /
/// `verdict` directly.
///
/// Serialised as snake_case strings so the on-disk trace reads cleanly
/// in `jq` without a string-versus-enum decoder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TraceStage {
SpecStarted,
SpecDone,
BuildStarted,
BuildDone,
SandboxStarted,
OracleWait,
OracleObserved,
Verdict,
}
impl TraceStage {
/// Stable label used by the stderr printer. Lowercase, no
/// punctuation, so a CI log scan can grep `^[T] oracle_observed`
/// straightforwardly.
pub fn as_str(&self) -> &'static str {
match self {
Self::SpecStarted => "spec_started",
Self::SpecDone => "spec_done",
Self::BuildStarted => "build_started",
Self::BuildDone => "build_done",
Self::SandboxStarted => "sandbox_started",
Self::OracleWait => "oracle_wait",
Self::OracleObserved => "oracle_observed",
Self::Verdict => "verdict",
}
}
}
/// One row of a [`VerifyTrace`].
///
/// `sequence` is the per-trace ordinal — explicit rather than implicit
/// in `Vec` order because the JSON-lines format on disk lets each line
/// stand alone (operators may sort / filter externally). `detail` is
/// a short, human-friendly free-form note (payload label, build attempt
/// counter, …); kept under 200 chars by callers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceEvent {
pub sequence: u32,
pub stage: TraceStage,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
/// Ordered record of every stage the verifier walks through.
///
/// Append via [`VerifyTrace::record`] (thread-safe; protected by an
/// internal `Mutex` so the sandbox/runner thread and the verifier can
/// share the same handle). Read deterministically via
/// [`VerifyTrace::events`].
#[derive(Debug, Default)]
pub struct VerifyTrace {
inner: Mutex<TraceInner>,
}
#[derive(Debug, Default)]
struct TraceInner {
events: Vec<TraceEvent>,
next_sequence: u32,
}
impl VerifyTrace {
/// Fresh, empty trace. Cheap — no allocation until the first event.
pub fn new() -> Self {
Self::default()
}
/// Append `stage` with optional `detail`. Lock-poisoning is treated
/// as a no-op so a panicking caller does not corrupt downstream
/// traces; the trace is observability, not load-bearing state.
pub fn record(&self, stage: TraceStage, detail: Option<String>) {
let Ok(mut inner) = self.inner.lock() else {
return;
};
let sequence = inner.next_sequence;
inner.next_sequence = sequence.wrapping_add(1);
inner.events.push(TraceEvent {
sequence,
stage,
detail,
});
}
/// Snapshot the recorded events in append order. Clones the vec so
/// the caller can serialise / drain without holding the lock; the
/// allocation is negligible compared to the rest of a verifier run.
pub fn events(&self) -> Vec<TraceEvent> {
match self.inner.lock() {
Ok(g) => g.events.clone(),
Err(_) => Vec::new(),
}
}
/// Serialise the trace as a JSON-lines string. Each line is a
/// single [`TraceEvent`] so the file is greppable and tolerant of
/// truncation (any prefix is still valid JSON-lines).
pub fn to_jsonl(&self) -> String {
let events = self.events();
let mut out = String::with_capacity(events.len() * 80);
for ev in &events {
// `serde_json::to_string` cannot fail for the field types
// here (`u32`, fixed enum, optional `String`).
if let Ok(line) = serde_json::to_string(ev) {
out.push_str(&line);
out.push('\n');
}
}
out
}
/// Best-effort stderr print of every recorded event, prefixed with
/// `[T]` so a tail of a verify log can find trace rows quickly.
/// Called when [`crate::dynamic::verify::VerifyOptions::trace_verbose`]
/// is set. Print failures are silently ignored because trace
/// output is observability, not a verdict input.
pub fn print_to_stderr(&self) {
use std::io::Write;
let events = self.events();
let mut err = std::io::stderr().lock();
for ev in &events {
let detail = ev.detail.as_deref().unwrap_or("");
let _ = writeln!(err, "[T] {} {} {}", ev.sequence, ev.stage.as_str(), detail);
}
let _ = err.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_assigns_monotonic_sequences() {
let t = VerifyTrace::new();
t.record(TraceStage::SpecStarted, None);
t.record(TraceStage::SpecDone, Some("py.cmdi.os_system".to_owned()));
t.record(TraceStage::Verdict, Some("Confirmed".to_owned()));
let events = t.events();
assert_eq!(events.len(), 3);
assert_eq!(events[0].sequence, 0);
assert_eq!(events[1].sequence, 1);
assert_eq!(events[2].sequence, 2);
assert_eq!(events[0].stage, TraceStage::SpecStarted);
assert_eq!(events[2].stage, TraceStage::Verdict);
}
#[test]
fn jsonl_is_deterministic_for_same_sequence() {
let a = VerifyTrace::new();
a.record(TraceStage::SpecStarted, None);
a.record(TraceStage::Verdict, Some("NotConfirmed".to_owned()));
let b = VerifyTrace::new();
b.record(TraceStage::SpecStarted, None);
b.record(TraceStage::Verdict, Some("NotConfirmed".to_owned()));
assert_eq!(a.to_jsonl(), b.to_jsonl());
}
#[test]
fn jsonl_round_trips_through_serde() {
let t = VerifyTrace::new();
t.record(TraceStage::SandboxStarted, Some("payload=sqli-tautology".to_owned()));
t.record(TraceStage::OracleObserved, Some("fired=true".to_owned()));
let jsonl = t.to_jsonl();
let mut parsed = Vec::new();
for line in jsonl.lines() {
let ev: TraceEvent = serde_json::from_str(line).expect("trace line should parse");
parsed.push(ev);
}
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].stage, TraceStage::SandboxStarted);
assert_eq!(parsed[1].stage, TraceStage::OracleObserved);
}
#[test]
fn stage_as_str_matches_spec_names() {
// Phase 30 spec literal: the verifier stage names must serialise
// to these exact tokens so audit grep queries stay stable.
assert_eq!(TraceStage::SpecStarted.as_str(), "spec_started");
assert_eq!(TraceStage::SpecDone.as_str(), "spec_done");
assert_eq!(TraceStage::BuildStarted.as_str(), "build_started");
assert_eq!(TraceStage::BuildDone.as_str(), "build_done");
assert_eq!(TraceStage::SandboxStarted.as_str(), "sandbox_started");
assert_eq!(TraceStage::OracleWait.as_str(), "oracle_wait");
assert_eq!(TraceStage::OracleObserved.as_str(), "oracle_observed");
assert_eq!(TraceStage::Verdict.as_str(), "verdict");
}
}