From 7a79e7f791aedeba9ccdab2cb3b4ef5b067a8ed7 Mon Sep 17 00:00:00 2001 From: Alexandre Balmes Date: Wed, 24 Jun 2026 23:07:47 +0200 Subject: [PATCH] feat(debug): add reverse time travel - `.tower/config.toml`: Configure debug record and replay support - `.zpm/kb/architecture/journal.wal`: Record architecture decisions for reverse debugging - `.zpm/kb/feedback/journal.wal`: Record feedback rules for debug implementation - `AGENTS.md`: Document reverse debugging MCP tools - `Makefile`: Add reverse debug workflow targets - `README.md`: Document time-travel debugging capabilities - `crates/core_engine/src/adapters/config/debug.rs`: Add rr record and replay configuration - `crates/core_engine/src/adapters/daemon/engine.rs`: Gate rr debug tools by runtime support - `crates/core_engine/tests/extension_debug_e2e.rs`: Cover rr-backed debug tool discovery - `crates/core_engine/tests/functional_debug_reverse_mcp.rs`: Add reverse debugging MCP tests - `crates/extension_protocol/tests/protocol_tests.rs`: Validate reverse debug protocol schemas - `docs/README.md`: Add reverse debugging documentation links - `docs/extensions.md`: Document debug extension rr support - `docs/getting-started.md`: Add setup guidance for debug tracing - `docs/mcp-tools.md`: Document reverse debug MCP tools - `docs/user-guide/debug-sessions.md`: Explain record and replay debug sessions - `extensions/debug/extension.toml`: Expose rr reverse debug tool metadata - `extensions/debug/src/eval_at.rs`: Integrate eval-at with trace evidence capture - `extensions/debug/src/main.rs`: Route rr record and replay tool handlers - `extensions/debug/src/origin.rs`: Add origin-finding reverse debug workflow - `extensions/debug/src/protocol.rs`: Add reverse debug protocol DTOs - `extensions/debug/src/rr.rs`: Add rr runtime preflight and recorder support - `extensions/debug/src/session.rs`: Support replay sessions and reverse execution - `extensions/debug/src/tools.rs`: Add rr reverse debug tool definitions - `extensions/debug/src/traces.rs`: Add persisted trace metadata store - `extensions/debug/src/types.rs`: Add trace and reverse debug types - `extensions/debug/tests/eval_at_contract.rs`: Cover eval-at trace contract behavior - `extensions/fixtures/debug_adapter/src/main.rs`: Add scripted reverse debug scenarios - `extensions/fixtures/debug_adapter/tests/eval_at_scenario.rs`: Cover fixture eval-at scenarios Closes #12 --- .tower/config.toml | 7 + .zpm/kb/architecture/journal.wal | 24 + .zpm/kb/feedback/journal.wal | 5 + AGENTS.md | 32 +- Makefile | 2 +- README.md | 6 +- .../core_engine/src/adapters/config/debug.rs | 470 ++++- .../core_engine/src/adapters/daemon/engine.rs | 22 + .../core_engine/tests/extension_debug_e2e.rs | 1027 +++++++++- .../tests/functional_debug_reverse_mcp.rs | 301 +++ .../tests/protocol_tests.rs | 24 +- docs/README.md | 4 +- docs/extensions.md | 21 +- docs/getting-started.md | 6 +- docs/mcp-tools.md | 162 +- docs/user-guide/debug-sessions.md | 120 +- extensions/debug/extension.toml | 45 + extensions/debug/src/eval_at.rs | 14 +- extensions/debug/src/main.rs | 535 +++++- extensions/debug/src/origin.rs | 1680 ++++++++++++++++ extensions/debug/src/protocol.rs | 407 +++- extensions/debug/src/rr.rs | 1706 +++++++++++++++++ extensions/debug/src/session.rs | 1170 ++++++++++- extensions/debug/src/tools.rs | 728 ++++++- extensions/debug/src/traces.rs | 1302 +++++++++++++ extensions/debug/src/types.rs | 22 + extensions/debug/tests/eval_at_contract.rs | 49 +- extensions/fixtures/debug_adapter/src/main.rs | 333 +++- .../debug_adapter/tests/eval_at_scenario.rs | 211 +- 29 files changed, 10354 insertions(+), 81 deletions(-) create mode 100644 crates/core_engine/tests/functional_debug_reverse_mcp.rs create mode 100644 extensions/debug/src/origin.rs create mode 100644 extensions/debug/src/rr.rs create mode 100644 extensions/debug/src/traces.rs diff --git a/.tower/config.toml b/.tower/config.toml index a9969e2..8560b14 100644 --- a/.tower/config.toml +++ b/.tower/config.toml @@ -25,6 +25,13 @@ adapter_type = "fixture" default_timeout_secs = 5 idle_ttl_secs = 300 +[debug.record] +backend = "rr" +trace_dir = ".tower/traces" +ttl_secs = 86400 +max_traces = 25 +record_timeout_secs = 30 + [extensions] # Plugin file stems to skip loading, e.g. ["ast"]. # A disabled extension gets no sandbox, no fuel, no tools, no watcher hooks. diff --git a/.zpm/kb/architecture/journal.wal b/.zpm/kb/architecture/journal.wal index c710434..ccbe160 100644 --- a/.zpm/kb/architecture/journal.wal +++ b/.zpm/kb/architecture/journal.wal @@ -72,3 +72,27 @@ {"ts":1782295010,"op":"assert","clause":"arch_convention('conv_debug_inspection_requires_stop', 'extensions/debug', 'stack, variables, and evaluate require a stopped session and return not-stopped while the debuggee is running', 'feature_f004_interactive_debug_session_sidecar')"} {"ts":1782295010,"op":"retractall","clause":"arch_convention('conv_sidecar_hostcall_queue', _, _, _)"} {"ts":1782295010,"op":"assert","clause":"arch_convention('conv_sidecar_hostcall_queue', 'extension sidecars', 'sidecars that await HostCall responses must queue inbound host requests and replay them instead of discarding non-matching frames', 'feature_f004_interactive_debug_session_sidecar')"} +{"ts":1782309987,"op":"retractall","clause":"arch_component(_,_,_,'feature_f005_debug_eval_at_stateless_evidence_prob')"} +{"ts":1782309987,"op":"retractall","clause":"arch_port(_,_,'feature_f005_debug_eval_at_stateless_evidence_prob')"} +{"ts":1782309987,"op":"retractall","clause":"arch_decision(_,_,_,_,'feature_f005_debug_eval_at_stateless_evidence_prob')"} +{"ts":1782309988,"op":"retractall","clause":"arch_convention(_,_,_,'feature_f005_debug_eval_at_stateless_evidence_prob')"} +{"ts":1782309988,"op":"retractall","clause":"arch_adr(_,_,'feature_f005_debug_eval_at_stateless_evidence_prob')"} +{"ts":1782309988,"op":"retractall","clause":"arch_feature('feature_f005_debug_eval_at_stateless_evidence_prob',_,_)"} +{"ts":1782309988,"op":"retractall","clause":"arch_decision('dec_eval_at_sidecar_tool', _, _, _, _, _)"} +{"ts":1782309988,"op":"assert","clause":"arch_decision('dec_eval_at_sidecar_tool', 'debug probe placement', 'implement eval_at as a debug sidecar extension tool exposed through tower_debug_eval_at, not as a native core_engine MCP tool', 'keeps DAP process lifecycle and debug config gating inside the existing fault-isolated debug extension while preserving the host/domain boundary', 'feature_f005_debug_eval_at_stateless_evidence_probe')"} +{"ts":1782309988,"op":"retractall","clause":"arch_decision('dec_eval_at_stateless', _, _, _, _)"} +{"ts":1782309988,"op":"assert","clause":"arch_decision('dec_eval_at_stateless', 'debug probe lifecycle', 'make eval_at a one-shot stateless probe that never exposes session_id in request or response', 'the probe exists to capture runtime evidence without making callers manage interactive session state; the internal session id is only for cleanup', 'feature_f005_debug_eval_at_stateless_evidence_probe')"} +{"ts":1782309989,"op":"retractall","clause":"arch_decision('dec_eval_at_reuse_session_manager', _, _, _, _, _, _, _, _, _)"} +{"ts":1782309989,"op":"assert","clause":"arch_decision('dec_eval_at_reuse_session_manager', 'DAP orchestration', 'build eval_at as an orchestration recipe over SessionManager and DebugAdapterSession instead of a parallel DAP client or new probe runtime', 'reuses existing launch, breakpoint, resume, inspection, timeout, and cleanup semantics while keeping the new behavior fakeable in tests', 'feature_f005_debug_eval_at_stateless_evidence_probe')"} +{"ts":1782309989,"op":"retractall","clause":"arch_decision('dec_eval_at_exited_payload', _, _, _, _, _, _)"} +{"ts":1782309989,"op":"assert","clause":"arch_decision('dec_eval_at_exited_payload', 'no-hit process exit', 'represent normal no-hit process exit as hit:false with finished:exited and optional exit_code', 'a program that exits before the breakpoint is reached is probe evidence, not a transport failure, and callers need the adapter-provided exit code when available', 'feature_f005_debug_eval_at_stateless_evidence_probe')"} +{"ts":1782309989,"op":"retractall","clause":"arch_decision('dec_eval_at_local_expression_errors', _, _, _, _, _, _, _)"} +{"ts":1782309989,"op":"assert","clause":"arch_decision('dec_eval_at_local_expression_errors', 'expression evaluation failures', 'record each failed expression as an error value in that expression result instead of failing the whole probe', 'one bad expression should not discard stack, locals, output, or other expression evidence captured from the same stop', 'feature_f005_debug_eval_at_stateless_evidence_probe')"} +{"ts":1782309989,"op":"retractall","clause":"arch_convention('conv_eval_at_bounded_capture', _, _, _, _, _, _)"} +{"ts":1782309989,"op":"assert","clause":"arch_convention('conv_eval_at_bounded_capture', 'extensions/debug/eval_at', 'eager variable capture must obey max_depth, max_children, and max_hits, and mark truncated variable nodes', 'feature_f005_debug_eval_at_stateless_evidence_probe')"} +{"ts":1782309989,"op":"retractall","clause":"arch_convention('conv_eval_at_cleanup', _, _, _, _, _, _, _)"} +{"ts":1782309989,"op":"assert","clause":"arch_convention('conv_eval_at_cleanup', 'extensions/debug/eval_at', 'eval_at must terminate and reap the internal debug session on hit, no-hit exit, timeout, adapter exit, and runtime error paths', 'feature_f005_debug_eval_at_stateless_evidence_probe')"} +{"ts":1782309990,"op":"retractall","clause":"arch_convention('conv_eval_at_probe_outcomes', _, _, _, _, _, _)"} +{"ts":1782309990,"op":"assert","clause":"arch_convention('conv_eval_at_probe_outcomes', 'extensions/debug/tools', 'expected probe outcomes such as timeout, unsupported breakpoint conditions, expression errors, and no-hit exits should serialize as successful JSON payloads rather than protocol errors', 'feature_f005_debug_eval_at_stateless_evidence_probe')"} +{"ts":1782309990,"op":"retractall","clause":"arch_convention('conv_debug_no_workspace_mutation', _, _, _)"} +{"ts":1782309990,"op":"assert","clause":"arch_convention('conv_debug_no_workspace_mutation', 'extensions/debug', 'debug extension protocol additions must continue to request no workspace mutation capabilities', 'feature_f005_debug_eval_at_stateless_evidence_probe')"} diff --git a/.zpm/kb/feedback/journal.wal b/.zpm/kb/feedback/journal.wal index fea9f50..a3b04c5 100644 --- a/.zpm/kb/feedback/journal.wal +++ b/.zpm/kb/feedback/journal.wal @@ -49,3 +49,8 @@ {"ts":1782302951,"op":"assert","clause":"rule(debug_dap_timeout_tests_use_timeout_error, test, 'Scripted DAP transport tests must model wait deadlines with DapError::Timeout; an exhausted/Ok(None) receive stream represents adapter EOF and should exercise AdapterExited behavior.', medium, 'F005 review remediation 2026-06-24')"} {"ts":1782305755,"op":"assert","clause":"rule(debug_eval_at_reject_unknown_fields, pitfall, 'Debug eval-at request DTOs must deny unknown fields whenever the advertised schema uses additionalProperties:false, so typos fail as InvalidParams instead of silently defaulting.', high, 'F005 review remediation 2026-06-24')"} {"ts":1782309178,"op":"assert","clause":"rule(debug_eval_at_breakpoints_before_configuration_done, pitfall, 'For stateless debug eval-at probes, install requested breakpoints before DAP configurationDone; sending configurationDone first can let real adapters run past the probe location before breakpoints exist.', high, 'F005 remediation 2026-06-24')"} +{"ts":1782316077,"op":"assert","clause":"rule(debug_trace_delete_direct_child, pitfall, 'Debug trace deletion and pruning must verify a trace path canonicalizes to the direct child directory named by TraceId under TracePolicy.trace_root before remove_dir_all; starts_with(root) is insufficient because it accepts the root itself.', high, 'F006 T011 review remediation 2026-06-24')"} +{"ts":1782317926,"op":"assert","clause":"rule(f006_rr_output_order_global_budget, test, 'When testing rr bounded output capture, cover cross-stream stderr/stdout interleaving separately from global byte-budget truncation because a true shared cap may consume the entire budget before a later stream writes.', medium, 'F006 remediation 2026-06-24')"} +{"ts":1782323287,"op":"assert","clause":"rule(f006_t016_cleanup_token_e2e_exact_event_count, test, 'For reverse debug cleanup e2e coverage, assert each required cleanup token has exactly one captured {\"event\":\"cleanup\",\"token\":...} event through the public tool result, not only process absence.', high, 'F006 T016 QA remediation 2026-06-24')"} +{"ts":1782323287,"op":"assert","clause":"trigger(f006_t016_cleanup_token_e2e_exact_event_count, 'crates/core_engine/tests/extension_debug_e2e.rs', file)"} +{"ts":1782325349,"op":"assert","clause":"rule(f006_reverse_debug_cleanup_tokens_per_process, test, 'Use distinct cleanup tokens for distinct reverse-debug replay-backed operations when asserting exact cleanup event counts.', medium, 'F006 functional test implementation 2026-06-24')"} diff --git a/AGENTS.md b/AGENTS.md index f2134be..1a138d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,11 +111,13 @@ Positions are **0-based** `line` + **UTF-16** `character` offset. | `tower_lsp_hover` | Hover info for the symbol at a position. | | `tower_lsp_diagnostics` | Errors/warnings for a file. | -### Debug — interactive sessions (`debug` extension, spec 33) +### Debug — interactive sessions and reverse debugging (`debug` extension, spec 33) Opt-in; tools appear only when `[debug.]` is configured and the `debug` extension is enabled. A discovered `debug` extension takes priority; otherwise the bundled sidecar is used when -`debug_extension` is available next to `tower`. +`debug_extension` is available next to `tower`. rr-backed record/replay tools appear only when +`[debug.record] backend = "rr"` is configured; unsupported rr hosts return structured tool payloads +rather than transport errors. | Tool | Purpose | |------|---------| @@ -128,6 +130,16 @@ enabled. A discovered `debug` extension takes priority; otherwise the bundled si | `tower_debug_stack` | Read stack frames for a thread. | | `tower_debug_variables` | Read variables for a DAP variables reference. | | `tower_debug_evaluate` | Evaluate an expression in a stack frame. | +| `tower_debug_eval_at` | Run a stateless one-shot breakpoint probe and capture bounded stack/variable evidence. | +| `tower_debug_record` | Record one native execution through rr and register a trace. | +| `tower_debug_replay` | Open a replay-backed debug session for a recorded trace. | +| `tower_debug_reverse_continue` | Continue a replay session backward until a stop, trace start, or timeout. | +| `tower_debug_step_back` | Step a replay session backward by line, instruction, or over granularity. | +| `tower_debug_watchpoint` | Set a replay-session data watchpoint by expression or address. | +| `tower_debug_traces` | List recorded debug traces known to the sidecar trace store. | +| `tower_debug_delete_trace` | Delete a recorded trace by id, contained to the configured trace root. | +| `tower_debug_find_origin` | Open replay, seek, set a write watchpoint, reverse-continue, capture origin evidence, and clean up. | +| `tower_debug_record_and_find_origin` | Record a trace and immediately run the origin-finding recipe against it. | | `tower_debug_terminate` | Terminate a debug session and clean up the adapter process. | | `tower_debug_disconnect` | Disconnect from a debug session. | | `tower_debug_sessions` | List active debug sessions and their last known state. | @@ -314,11 +326,13 @@ Positions are **0-based** `line` + **UTF-16** `character` offset. | `tower_lsp_hover` | Hover info for the symbol at a position. | | `tower_lsp_diagnostics` | Errors/warnings for a file. | -### Debug — interactive sessions (`debug` extension) +### Debug — interactive sessions and reverse debugging (`debug` extension) Opt-in; tools appear only when `[debug.]` is configured and the `debug` extension is enabled. A discovered `debug` extension takes priority; otherwise the bundled sidecar is used when -`debug_extension` is available next to `tower`. +`debug_extension` is available next to `tower`. rr-backed record/replay tools appear only when +`[debug.record] backend = "rr"` is configured; unsupported rr hosts return structured tool payloads +rather than transport errors. | Tool | Purpose | |------|---------| @@ -331,6 +345,16 @@ enabled. A discovered `debug` extension takes priority; otherwise the bundled si | `tower_debug_stack` | Read stack frames for a thread. | | `tower_debug_variables` | Read variables for a DAP variables reference. | | `tower_debug_evaluate` | Evaluate an expression in a stack frame. | +| `tower_debug_eval_at` | Run a stateless one-shot breakpoint probe and capture bounded stack/variable evidence. | +| `tower_debug_record` | Record one native execution through rr and register a trace. | +| `tower_debug_replay` | Open a replay-backed debug session for a recorded trace. | +| `tower_debug_reverse_continue` | Continue a replay session backward until a stop, trace start, or timeout. | +| `tower_debug_step_back` | Step a replay session backward by line, instruction, or over granularity. | +| `tower_debug_watchpoint` | Set a replay-session data watchpoint by expression or address. | +| `tower_debug_traces` | List recorded debug traces known to the sidecar trace store. | +| `tower_debug_delete_trace` | Delete a recorded trace by id, contained to the configured trace root. | +| `tower_debug_find_origin` | Open replay, seek, set a write watchpoint, reverse-continue, capture origin evidence, and clean up. | +| `tower_debug_record_and_find_origin` | Record a trace and immediately run the origin-finding recipe against it. | | `tower_debug_terminate` | Terminate a debug session and clean up the adapter process. | | `tower_debug_disconnect` | Disconnect from a debug session. | | `tower_debug_sessions` | List active debug sessions and their last known state. | diff --git a/Makefile b/Makefile index 606df99..4d530fd 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ INSTALL_DIR ?= $(HOME)/.local/bin # For the global XDG scope, pass # EXT_DEST=$(HOME)/.local/share/tower/extensions # EXT_PROFILE — release (default, deployable) or debug (fast, reuses dev build). -EXTENSIONS ?= ast lsp fmt lint debug +EXTENSIONS ?= ast debug fmt lint lsp EXT_DEST ?= .tower/extensions EXT_PROFILE ?= release diff --git a/README.md b/README.md index 290868b..0431735 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ See [`project-brief.md`](project-brief.md) for the full vision. | **AST analysis** | `ast` extension — Tree-sitter outline and symbol search for Rust, Go, PHP | | **Code intelligence** | `lsp` extension — diagnostics, definition, references, hover via a language-server bridge | | **Standalone linting and fixes** | `lint` extension — on-demand diagnostics and structured fixes from configured external linters, using the same diagnostic shape as LSP | -| **Debugging** | `debug` extension — opt-in Debug Adapter Protocol sessions plus one-shot `tower_debug_eval_at` probes for breakpoint evidence capture | +| **Debugging** | `debug` extension — opt-in Debug Adapter Protocol sessions, one-shot `tower_debug_eval_at` probes, and rr-backed record/replay reverse debugging | | **Single static binary** | No WASM, WASI SDK, JVM, Node, or container required at runtime | --- @@ -180,8 +180,8 @@ tests can locate sidecars under `target/debug/`. There is no WASM build step and |---|---| | [`docs/getting-started.md`](docs/getting-started.md) | Prerequisites, build, quality gate, first MCP session | | [`docs/architecture.md`](docs/architecture.md) | Hexagonal boundary, crate layout, ports, data flow, design decisions | -| [`docs/mcp-tools.md`](docs/mcp-tools.md) | Full MCP tool reference — wire protocol, the native tools, extension tools including lint fix, interactive debug sessions, and `tower_debug_eval_at`, error codes | -| [`docs/user-guide/debug-sessions.md`](docs/user-guide/debug-sessions.md) | How to configure and drive interactive Debug Adapter Protocol sessions and one-shot `tower_debug_eval_at` probes | +| [`docs/mcp-tools.md`](docs/mcp-tools.md) | Full MCP tool reference — wire protocol, the native tools, extension tools including lint fix, interactive debug sessions, rr record/replay reverse debugging, and error codes | +| [`docs/user-guide/debug-sessions.md`](docs/user-guide/debug-sessions.md) | How to configure and drive interactive Debug Adapter Protocol sessions, one-shot `tower_debug_eval_at` probes, and rr-backed reverse-debug workflows | | [`docs/user-guide/lint-fixes.md`](docs/user-guide/lint-fixes.md) | How to preview and apply structured linter fixes safely with `tower_lint_fix` | | [`docs/extensions.md`](docs/extensions.md) | Extension authoring guide — native sidecars, the JSON-RPC protocol, capabilities, manifest, fault model | | [`docs/development.md`](docs/development.md) | Contributing, TDD workflow, CI pipeline, test conventions | diff --git a/crates/core_engine/src/adapters/config/debug.rs b/crates/core_engine/src/adapters/config/debug.rs index 87650b5..f04de2a 100644 --- a/crates/core_engine/src/adapters/config/debug.rs +++ b/crates/core_engine/src/adapters/config/debug.rs @@ -1,6 +1,8 @@ #![forbid(unsafe_code)] use std::collections::BTreeMap; +use std::fmt; +use std::path::{Component, Path, PathBuf}; use serde::de::{self, Deserializer}; use serde::{Deserialize, Serialize}; @@ -8,12 +10,13 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, PartialEq, Serialize)] pub struct DebugConfig { pub languages: BTreeMap, + pub record: Option, } impl DebugConfig { #[must_use] pub fn is_empty(&self) -> bool { - self.languages.is_empty() + self.languages.is_empty() && self.record.is_none() } #[must_use] @@ -21,6 +24,7 @@ impl DebugConfig { (!self.is_empty()).then(|| { serde_json::json!({ "languages": self.languages, + "record": self.record, }) }) } @@ -31,22 +35,168 @@ impl<'de> Deserialize<'de> for DebugConfig { where D: Deserializer<'de>, { - let raw = BTreeMap::::deserialize(deserializer)?; + let raw = BTreeMap::::deserialize(deserializer)?; let mut languages = BTreeMap::new(); + let mut record = None; + + for (key, value) in raw { + if key == "record" { + let raw_record: RawDebugRecordConfig = + value.try_into().map_err(de::Error::custom)?; + record = Some(raw_record.validate().map_err(de::Error::custom)?); + continue; + } - for (language, config) in raw { - if language.is_empty() { + if key.is_empty() { return Err(de::Error::custom("debug language key must not be empty")); } - let config = config.validate(Some(&language))?; - languages.insert(language, config); + let config: RawDebugLanguageConfig = value.try_into().map_err(de::Error::custom)?; + let config = config.validate(Some(&key))?; + languages.insert(key, config); } - Ok(Self { languages }) + Ok(Self { languages, record }) } } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DebugRecordConfig { + pub backend: String, + pub trace_dir: Option, + pub ttl_secs: Option, + pub max_traces: Option, + pub record_timeout_secs: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RawDebugRecordConfig { + backend: Option, + #[serde(default)] + trace_dir: Option, + #[serde(default)] + ttl_secs: Option, + #[serde(default)] + max_traces: Option, + #[serde(default)] + record_timeout_secs: Option, +} + +impl RawDebugRecordConfig { + pub fn validate(self) -> Result { + let Self { + backend, + trace_dir, + ttl_secs, + max_traces, + record_timeout_secs, + } = self; + + let backend = backend.ok_or(DebugRecordConfigError::MissingBackend)?; + if backend != "rr" { + return Err(DebugRecordConfigError::UnsupportedBackend { backend }); + } + + if trace_dir + .as_deref() + .is_some_and(|path| !is_valid_relative_trace_dir(path)) + { + return Err(DebugRecordConfigError::InvalidTraceDir { + field: "debug.record.trace_dir", + }); + } + + Ok(DebugRecordConfig { + backend, + trace_dir, + ttl_secs: optional_positive_u64( + ttl_secs.as_ref(), + DebugRecordConfigError::InvalidTtlSecs { + field: "debug.record.ttl_secs", + }, + )?, + max_traces: optional_positive_usize( + max_traces.as_ref(), + DebugRecordConfigError::InvalidMaxTraces { + field: "debug.record.max_traces", + }, + )?, + record_timeout_secs: optional_positive_u64( + record_timeout_secs.as_ref(), + DebugRecordConfigError::InvalidRecordTimeoutSecs { + field: "debug.record.record_timeout_secs", + }, + )?, + }) + } +} + +fn optional_positive_u64( + value: Option<&toml::Value>, + error: DebugRecordConfigError, +) -> Result, DebugRecordConfigError> { + value.map_or(Ok(None), |value| { + value + .as_integer() + .and_then(|integer| u64::try_from(integer).ok()) + .filter(|integer| *integer > 0) + .map(Some) + .ok_or(error) + }) +} + +fn optional_positive_usize( + value: Option<&toml::Value>, + error: DebugRecordConfigError, +) -> Result, DebugRecordConfigError> { + value.map_or(Ok(None), |value| { + value + .as_integer() + .and_then(|integer| usize::try_from(integer).ok()) + .filter(|integer| *integer > 0) + .map(Some) + .ok_or(error) + }) +} + +fn is_valid_relative_trace_dir(path: &Path) -> bool { + !path.as_os_str().is_empty() + && path + .components() + .all(|component| matches!(component, Component::Normal(_) | Component::CurDir)) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DebugRecordConfigError { + MissingBackend, + UnsupportedBackend { backend: String }, + InvalidTtlSecs { field: &'static str }, + InvalidTraceDir { field: &'static str }, + InvalidMaxTraces { field: &'static str }, + InvalidRecordTimeoutSecs { field: &'static str }, +} + +impl fmt::Display for DebugRecordConfigError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingBackend => formatter.write_str("debug.record.backend is required"), + Self::UnsupportedBackend { backend } => { + write!(formatter, "unsupported debug.record.backend {backend:?}") + } + Self::InvalidTtlSecs { field } + | Self::InvalidTraceDir { field } + | Self::InvalidMaxTraces { field } + | Self::InvalidRecordTimeoutSecs { field } => { + write!(formatter, "{field} is invalid") + } + } + } +} + +impl std::error::Error for DebugRecordConfigError {} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawDebugLanguageConfig { @@ -166,7 +316,11 @@ impl<'de> Deserialize<'de> for DebugLanguageConfig { #[cfg(test)] mod tests { - use super::DebugLanguageConfig; + use std::path::PathBuf; + + use super::{ + DebugLanguageConfig, DebugRecordConfig, DebugRecordConfigError, RawDebugRecordConfig, + }; use crate::adapters::config::TowerConfig; use serde_json::json; @@ -261,11 +415,311 @@ idle_ttl_secs = 120 "default_timeout_secs": 15, "idle_ttl_secs": 300 } + }, + "record": null + })) + ); + } + + #[test] + fn debug_config_parses_host_side_debug_record_config_with_exact_public_serde_fields() { + let config = DebugRecordConfig { + backend: "rr".to_owned(), + trace_dir: Some(PathBuf::from(".tower/traces")), + ttl_secs: Some(86_400), + max_traces: Some(25), + record_timeout_secs: Some(30), + }; + + let value = serde_json::to_value(&config).expect("record config serializes"); + + assert_eq!(value["backend"], json!("rr")); + assert_eq!(value["trace_dir"], json!(".tower/traces")); + assert_eq!(value["ttl_secs"], json!(86_400)); + assert_eq!(value["max_traces"], json!(25)); + assert_eq!(value["record_timeout_secs"], json!(30)); + } + + #[test] + fn debug_config_parses_debug_config_includes_record_without_changing_absent_empty_or_present_language_configs() + { + let absent = parse_tower_config("[plugins]\ndisabled = []\n").expect("absent debug parses"); + assert!(absent.debug.languages.is_empty()); + assert_eq!(absent.debug.record, None); + + let empty = parse_tower_config("[debug]\n").expect("empty debug parses"); + assert!(empty.debug.languages.is_empty()); + assert_eq!(empty.debug.record, None); + + let present = parse_tower_config( + r#" +[debug.rust] +extensions = ["rs"] +command = "lldb-dap" +adapter_type = "lldb" +default_timeout_secs = 15 +idle_ttl_secs = 300 +"#, + ) + .expect("present language config parses"); + + assert!(present.debug.languages.contains_key("rust")); + assert_eq!(present.debug.record, None); + } + + #[test] + fn debug_config_record_serializes_through_extension_config_without_dropping_language_settings() + { + let cfg = parse_tower_config( + r#" +[debug.record] +backend = "rr" +trace_dir = ".tower/traces" +ttl_secs = 86400 +max_traces = 25 +record_timeout_secs = 30 + +[debug.rust] +extensions = ["rs"] +command = "lldb-dap" +args = ["--quiet"] +adapter_type = "lldb" +launch = { request = "launch", program = "target/debug/app" } +default_timeout_secs = 15 +idle_ttl_secs = 300 +"#, + ) + .expect("record and language config parse together"); + + assert_eq!( + cfg.debug.for_extension_initialize(), + Some(json!({ + "languages": { + "rust": { + "extensions": ["rs"], + "command": "lldb-dap", + "args": ["--quiet"], + "adapter_type": "lldb", + "launch": { "request": "launch", "program": "target/debug/app" }, + "default_timeout_secs": 15, + "idle_ttl_secs": 300 + } + }, + "record": { + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30, } })) ); } + #[test] + fn debug_config_parses_absent_debug_record_as_record_none() { + let cfg = parse_tower_config( + r#" +[debug.rust] +extensions = ["rs"] +command = "lldb-dap" +adapter_type = "lldb" +default_timeout_secs = 15 +idle_ttl_secs = 300 +"#, + ) + .expect("debug config without record parses"); + + assert_eq!(cfg.debug.record, None); + } + + #[test] + fn debug_config_record_rr_backend_as_debug_record_config_and_preserves_retention_values() { + let cfg = parse_tower_config( + r#" +[debug.record] +backend = "rr" +trace_dir = ".tower/traces" +ttl_secs = 86400 +max_traces = 25 +record_timeout_secs = 30 +"#, + ) + .expect("rr record config parses"); + + assert_eq!( + cfg.debug.record, + Some(DebugRecordConfig { + backend: "rr".to_owned(), + trace_dir: Some(PathBuf::from(".tower/traces")), + ttl_secs: Some(86_400), + max_traces: Some(25), + record_timeout_secs: Some(30), + }) + ); + } + + #[test] + fn debug_config_record_raw_debug_record_config_validates_present_record_table_before_constructing_debug_record_config() + { + let raw = RawDebugRecordConfig { + backend: Some("rr".to_owned()), + trace_dir: Some(PathBuf::from(".tower/traces")), + ttl_secs: Some(toml::Value::Integer(86_400)), + max_traces: Some(toml::Value::Integer(25)), + record_timeout_secs: Some(toml::Value::Integer(30)), + }; + + assert_eq!( + raw.validate().expect("valid raw record config"), + DebugRecordConfig { + backend: "rr".to_owned(), + trace_dir: Some(PathBuf::from(".tower/traces")), + ttl_secs: Some(86_400), + max_traces: Some(25), + record_timeout_secs: Some(30), + } + ); + } + + #[test] + fn debug_config_parses_record_config_validation_uses_named_debug_record_config_error_variants_and_renders_field_names() + { + let cases = [ + ( + DebugRecordConfigError::MissingBackend, + "debug.record.backend", + ), + ( + DebugRecordConfigError::UnsupportedBackend { + backend: "gdb".to_owned(), + }, + "debug.record.backend", + ), + ( + DebugRecordConfigError::InvalidTtlSecs { + field: "debug.record.ttl_secs", + }, + "debug.record.ttl_secs", + ), + ( + DebugRecordConfigError::InvalidTraceDir { + field: "debug.record.trace_dir", + }, + "debug.record.trace_dir", + ), + ( + DebugRecordConfigError::InvalidMaxTraces { + field: "debug.record.max_traces", + }, + "debug.record.max_traces", + ), + ( + DebugRecordConfigError::InvalidRecordTimeoutSecs { + field: "debug.record.record_timeout_secs", + }, + "debug.record.record_timeout_secs", + ), + ]; + + for (error, field) in cases { + let message = error.to_string(); + assert!(message.contains(field), "{message}"); + } + } + + #[test] + fn debug_config_record_unsupported_backend_values_return_debug_record_config_error_unsupported_backend() + { + let raw = RawDebugRecordConfig { + backend: Some("gdb".to_owned()), + trace_dir: None, + ttl_secs: None, + max_traces: None, + record_timeout_secs: None, + }; + + assert_eq!( + raw.validate().expect_err("unsupported backend is rejected"), + DebugRecordConfigError::UnsupportedBackend { + backend: "gdb".to_owned() + } + ); + } + + #[test] + fn debug_config_record_invalid_values_return_exact_debug_record_config_error_field_names() { + for (name, raw, expected) in [ + ( + "invalid ttl_secs", + RawDebugRecordConfig { + backend: Some("rr".to_owned()), + trace_dir: None, + ttl_secs: Some(toml::Value::Integer(0)), + max_traces: None, + record_timeout_secs: None, + }, + DebugRecordConfigError::InvalidTtlSecs { + field: "debug.record.ttl_secs", + }, + ), + ( + "invalid absolute trace_dir", + RawDebugRecordConfig { + backend: Some("rr".to_owned()), + trace_dir: Some(PathBuf::from("/tmp/tower-traces")), + ttl_secs: None, + max_traces: None, + record_timeout_secs: None, + }, + DebugRecordConfigError::InvalidTraceDir { + field: "debug.record.trace_dir", + }, + ), + ( + "invalid traversal trace_dir", + RawDebugRecordConfig { + backend: Some("rr".to_owned()), + trace_dir: Some(PathBuf::from("../traces")), + ttl_secs: None, + max_traces: None, + record_timeout_secs: None, + }, + DebugRecordConfigError::InvalidTraceDir { + field: "debug.record.trace_dir", + }, + ), + ( + "invalid max_traces", + RawDebugRecordConfig { + backend: Some("rr".to_owned()), + trace_dir: None, + ttl_secs: None, + max_traces: Some(toml::Value::Integer(0)), + record_timeout_secs: None, + }, + DebugRecordConfigError::InvalidMaxTraces { + field: "debug.record.max_traces", + }, + ), + ( + "invalid record_timeout_secs", + RawDebugRecordConfig { + backend: Some("rr".to_owned()), + trace_dir: None, + ttl_secs: None, + max_traces: None, + record_timeout_secs: Some(toml::Value::Integer(0)), + }, + DebugRecordConfigError::InvalidRecordTimeoutSecs { + field: "debug.record.record_timeout_secs", + }, + ), + ] { + assert_eq!(raw.validate().expect_err(name), expected, "{name}"); + } + } + #[test] fn debug_language_config_rejects_an_empty_adapter_command_with_a_config_parse_error_that_names_the_language_key() { diff --git a/crates/core_engine/src/adapters/daemon/engine.rs b/crates/core_engine/src/adapters/daemon/engine.rs index d43b881..fc046b2 100644 --- a/crates/core_engine/src/adapters/daemon/engine.rs +++ b/crates/core_engine/src/adapters/daemon/engine.rs @@ -38,6 +38,17 @@ use extension_protocol::ExtensionManifest; type SharedFormatQueue = Arc; type FormatterEchoSet = Arc>>; const BUNDLED_DEBUG_MANIFEST: &str = include_str!("../../../../../extensions/debug/extension.toml"); +const RR_DEBUG_TOOL_NAMES: &[&str] = &[ + "record", + "replay", + "reverse_continue", + "step_back", + "watchpoint", + "traces", + "delete_trace", + "find_origin", + "record_and_find_origin", +]; struct EngineApplyEditsHost { state: Arc>, @@ -396,6 +407,17 @@ fn register_bundled_debug_extension( *argv0 = command; } + if tower_config + .debug + .record + .as_ref() + .is_none_or(|record| record.backend != "rr") + { + manifest + .tools + .retain(|tool| !RR_DEBUG_TOOL_NAMES.contains(&tool.name.as_str())); + } + let instance = Box::new(ExtensionSupervisor::new( manifest, ext_deps, diff --git a/crates/core_engine/tests/extension_debug_e2e.rs b/crates/core_engine/tests/extension_debug_e2e.rs index 7302d8f..cba4ed9 100644 --- a/crates/core_engine/tests/extension_debug_e2e.rs +++ b/crates/core_engine/tests/extension_debug_e2e.rs @@ -8,7 +8,7 @@ use std::fs; use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use core_engine::adapters::cli::GlobalOpts; use core_engine::adapters::config::TowerConfig; @@ -23,7 +23,7 @@ use serde_json::{Value, json}; mod debug_protocol; use debug_protocol::{ - DebugInitError, DebugInitializeConfig, DebugToolError, DebugToolErrorCode, + DebugInitError, DebugInitializeConfig, DebugRecordConfig, DebugToolError, DebugToolErrorCode, debug_not_initialized_result, debug_tool_declarations, debug_tool_unavailable_result, }; @@ -147,6 +147,77 @@ idle_ttl_secs = 300 .expect("native fixture debug config must parse") } +fn rr_fixture_tower_config() -> TowerConfig { + rr_fixture_tower_config_with_args(&[]) +} + +fn rr_fixture_tower_config_with_args(args: &[&str]) -> TowerConfig { + let args = args + .iter() + .map(|arg| serde_json::to_string(arg).expect("fixture arg must serialize")) + .collect::>() + .join(", "); + let command = serde_json::to_string( + fixture_debug_adapter_bin() + .to_str() + .expect("fixture path must be utf8"), + ) + .expect("fixture path must serialize"); + toml::from_str(&format!( + r#" +[extensions] +request_timeout_secs = 5 + +[debug.rust] +extensions = ["rs"] +command = {command} +args = [{args}] +adapter_type = "fixture" +default_timeout_secs = 5 +idle_ttl_secs = 300 + +[debug.record] +backend = "rr" +trace_dir = ".tower/traces" +ttl_secs = 86400 +max_traces = 25 +record_timeout_secs = 30 +"# + )) + .expect("native fixture debug config with rr record backend must parse") +} + +fn real_rr_native_fixture_tower_config() -> TowerConfig { + let command = serde_json::to_string( + fixture_debug_adapter_bin() + .to_str() + .expect("fixture path must be utf8"), + ) + .expect("fixture path must serialize"); + toml::from_str(&format!( + r#" +[extensions] +request_timeout_secs = 10 + +[debug.rust] +extensions = ["rs"] +command = {command} +args = [] +adapter_type = "fixture" +default_timeout_secs = 5 +idle_ttl_secs = 300 + +[debug.record] +backend = "rr" +trace_dir = ".tower/traces" +ttl_secs = 86400 +max_traces = 25 +record_timeout_secs = 30 +"# + )) + .expect("real rr native fixture config must parse") +} + fn expected_debug_tool_names() -> Vec<&'static str> { vec![ "tower_debug_launch", @@ -183,6 +254,20 @@ fn expected_manifest_tool_names() -> Vec<&'static str> { ] } +fn expected_rr_specific_manifest_tool_names() -> Vec<&'static str> { + vec![ + "record", + "replay", + "reverse_continue", + "step_back", + "watchpoint", + "traces", + "delete_trace", + "find_origin", + "record_and_find_origin", + ] +} + fn valid_debug_init_payload() -> serde_json::Value { json!({ "languages": { @@ -201,6 +286,60 @@ fn valid_debug_init_payload() -> serde_json::Value { }) } +fn valid_rr_debug_init_payload() -> serde_json::Value { + valid_rr_debug_init_payload_with_trace_dir(".tower/traces") +} + +fn valid_rr_debug_init_payload_with_trace_dir(trace_dir: &str) -> serde_json::Value { + let mut payload = valid_debug_init_payload(); + payload["record"] = json!({ + "backend": "rr", + "trace_dir": trace_dir, + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30 + }); + payload +} + +fn unique_relative_trace_dir(label: &str) -> String { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + format!(".tower/test-traces-{label}-{}-{unique}", std::process::id()) +} + +fn reverse_debug_fixture_init_payload(token: &str) -> serde_json::Value { + let command = fixture_debug_adapter_bin(); + let command = command + .to_str() + .expect("fixture path must be utf8") + .to_owned(); + json!({ + "languages": { + "rust": { + "extensions": ["rs"], + "command": command, + "args": ["--token", token], + "adapter_type": "fixture", + "launch": { + "request": "launch" + }, + "default_timeout_secs": 5, + "idle_ttl_secs": 300 + } + }, + "record": { + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30, + } + }) +} + fn native_dap_fixture_init_payload(args: &[&str], timeout_secs: u64) -> serde_json::Value { json!({ "languages": { @@ -397,6 +536,95 @@ fn merged_tool_names(config: TowerConfig) -> Vec { .collect::>() } +fn reverse_debug_fixture_registry( + token: &str, + fixture_args: &[&str], +) -> (tempfile::TempDir, ExtensionMergedRegistry) { + let workspace = tempfile::tempdir().expect("create temp workspace"); + std::fs::write(workspace.path().join("main.rs"), "fn main() {}\n").expect("write source"); + + let mut args = vec!["--token".to_owned(), token.to_owned()]; + args.extend( + fixture_args + .iter() + .copied() + .filter(|arg| *arg != "--scenario" && !arg.starts_with("--scenario=")) + .map(str::to_owned), + ); + let arg_refs = args.iter().map(String::as_str).collect::>(); + + let opts = GlobalOpts { + workspace_dir: Some(workspace.path().to_path_buf()), + extensions_dir: None, + }; + let handle = build_engine(&opts, rr_fixture_tower_config_with_args(&arg_refs)) + .expect("engine builds with fixture-backed reverse debug config"); + ( + workspace, + ExtensionMergedRegistry::new(Arc::clone(&handle.state), handle.ext_registry), + ) +} + +fn reverse_debug_record_request(token: &str, scenario: &str) -> serde_json::Value { + json!({ + "language": "rust", + "program": "fixture-program", + "args": [scenario], + "cwd": null, + "env": { + "TOWER_DEBUG_FIXTURE_CLEANUP_TOKEN": token, + "TOWER_DEBUG_FIXTURE_SCENARIO": scenario + }, + "timeout_ms": 5_000 + }) +} + +fn reverse_debug_record_trace( + merged: &mut ExtensionMergedRegistry, + token: &str, + scenario: &str, +) -> String { + let record = merged + .call( + "tower_debug_record", + reverse_debug_record_request(token, scenario), + ) + .expect("tower_debug_record returns a structured fixture result"); + assert_eq!(record["recordable"], true, "record payload: {record}"); + record["trace_id"] + .as_str() + .expect("record result includes trace_id") + .to_owned() +} + +fn reverse_debug_cleanup_record_request(token: &str, scenario: &str) -> serde_json::Value { + json!({ + "language": "rust", + "program": fixture_debug_adapter_bin(), + "args": ["--scenario", scenario], + "cwd": null, + "env": { + "TOWER_DEBUG_FIXTURE_CLEANUP_TOKEN": token + }, + "timeout_ms": 5_000 + }) +} + +fn assert_cleanup_event_emitted_exactly_once(record_result: &Value, token: &str) { + let cleanup_count = record_result["output"] + .as_array() + .expect("record result includes captured output array") + .iter() + .flat_map(|output| output["text"].as_str().unwrap_or_default().lines()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|event| event["event"] == "cleanup" && event["token"] == token) + .count(); + assert_eq!( + cleanup_count, 1, + "cleanup token {token} must emit exactly one cleanup event in record output: {record_result}" + ); +} + fn initialize_debug_child(child: &mut RawDebugChild, id: u64, extension_config: serde_json::Value) { child.write_frame(json!({ "jsonrpc": "2.0", @@ -450,6 +678,68 @@ fn invoke_debug_tool( response["result"]["data"].clone() } +fn invoke_debug_tool_raw( + child: &mut RawDebugChild, + id: u64, + name: &str, + params: serde_json::Value, +) -> serde_json::Value { + child.write_frame(json!({ + "jsonrpc": "2.0", + "id": id, + "method": "invokeTool", + "params": { + "name": name, + "params": params + } + })); + let host_call = child.read_frame(); + assert_eq!( + host_call["method"], "log", + "{name} dispatch must perform the sidecar HostCall before returning; got {host_call}" + ); + child.write_frame(json!({ + "jsonrpc": "2.0", + "id": host_call["id"].clone(), + "result": true + })); + let response = child.read_frame(); + assert_eq!( + response["id"], id, + "{name} response id mismatch: {response}" + ); + response +} + +fn shutdown_debug_child(child: &mut RawDebugChild, id: u64) { + child.write_frame(json!({ + "jsonrpc": "2.0", + "id": id, + "method": "shutdown", + "params": {} + })); + let shutdown = child.read_frame(); + assert_eq!(shutdown["id"], id); + assert_eq!(shutdown["result"]["type"], "Ack"); +} + +fn manifest_tool_names() -> Vec { + let manifest = fs::read_to_string(workspace_root().join("extensions/debug/extension.toml")) + .expect("read debug manifest"); + let value: toml::Value = toml::from_str(&manifest).expect("debug manifest parses as TOML"); + value["tools"] + .as_array() + .expect("debug manifest tools array") + .iter() + .map(|tool| { + tool["name"] + .as_str() + .expect("manifest tool name") + .to_owned() + }) + .collect() +} + fn launch_fixture_session(child: &mut RawDebugChild, id: u64) -> serde_json::Value { invoke_debug_tool( child, @@ -776,6 +1066,23 @@ fn debug_initialize_config_from_init_payload_accepts_absent_valid_non_empty_and_ "present payload without languages must be rejected as DebugInitError::InvalidConfig; got {missing_languages:?}" ); + let record_without_languages = DebugInitializeConfig::from_init_payload(Some(json!({ + "record": { + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30 + } + }))); + assert!( + matches!( + record_without_languages, + Err(DebugInitError::InvalidConfig(_)) + ), + "present payload with record but without languages must be rejected as DebugInitError::InvalidConfig; got {record_without_languages:?}" + ); + let malformed = DebugInitializeConfig::from_init_payload(Some(json!({ "languages": { "rust": { @@ -857,6 +1164,161 @@ fn debug_tool_declarations_returns_empty_for_none_or_empty_config_and_complete_m } } +#[test] +fn debug_tool_declarations_existing_debug_tools_remain_absent_when_debug_language_config_is_absent_or_empty() + { + let record_only_config = DebugInitializeConfig::from_init_payload(Some(json!({ + "languages": {}, + "record": { + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30 + } + }))) + .expect("record-only initialize config parses") + .expect("present record-only config"); + let empty_config = DebugInitializeConfig::from_init_payload(Some(json!({ "languages": {} }))) + .expect("empty map is a valid config") + .expect("empty map still yields config"); + + for config in [None, Some(&empty_config), Some(&record_only_config)] { + let declarations = debug_tool_declarations(config); + for name in expected_manifest_tool_names() { + assert!( + declarations.iter().all(|tool| tool.name != name), + "{name} must be absent when debug language config is absent or empty; got {declarations:?}" + ); + } + } +} + +#[test] +fn debug_tool_declarations_rr_specific_tools_remain_absent_when_debug_config_exists_but_record_backend_rr_is_absent() + { + let mut no_record = DebugInitializeConfig::from_init_payload(Some(valid_debug_init_payload())) + .expect("valid debug config") + .expect("present config"); + no_record.record = None; + let non_rr_record = DebugInitializeConfig { + record: Some(DebugRecordConfig { + backend: "gdb".to_owned(), + trace_dir: None, + ttl_secs: None, + max_traces: None, + record_timeout_secs: None, + }), + ..no_record.clone() + }; + + for config in [&no_record, &non_rr_record] { + let declarations = debug_tool_declarations(Some(config)); + for name in expected_rr_specific_manifest_tool_names() { + assert!( + declarations.iter().all(|tool| tool.name != name), + "{name} must be absent when [debug.record] backend = \"rr\" is absent; got {declarations:?}" + ); + } + } +} + +#[test] +fn debug_tool_declarations_rr_specific_tools_appear_when_record_backend_rr_is_configured() { + let mut payload = valid_debug_init_payload(); + payload["record"] = json!({ + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30 + }); + let config = DebugInitializeConfig::from_init_payload(Some(payload)) + .expect("valid rr record config") + .expect("present config"); + + let declarations = debug_tool_declarations(Some(&config)); + let tool_names = declarations + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + + for name in expected_manifest_tool_names() + .into_iter() + .chain(expected_rr_specific_manifest_tool_names()) + { + assert!( + tool_names.contains(&name), + "{name} must be declared when debug languages and record.backend = \"rr\" are configured; got {tool_names:?}" + ); + } +} + +#[test] +fn debug_initialize_config_rejects_invalid_record_payloads() { + for (record, expected_message) in [ + ( + json!({ + "backend": "gdb", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30 + }), + "debug.record.backend", + ), + ( + json!({ + "backend": "rr", + "trace_dir": "/tmp/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30 + }), + "debug.record.trace_dir", + ), + ( + json!({ + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 0, + "max_traces": 25, + "record_timeout_secs": 30 + }), + "debug.record.ttl_secs", + ), + ( + json!({ + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 0, + "record_timeout_secs": 30 + }), + "debug.record.max_traces", + ), + ( + json!({ + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 0 + }), + "debug.record.record_timeout_secs", + ), + ] { + let mut payload = valid_debug_init_payload(); + payload["record"] = record; + let error = DebugInitializeConfig::from_init_payload(Some(payload)) + .expect_err("invalid record initialize payload must fail closed"); + assert!( + error.jsonrpc_message().contains(expected_message), + "expected {expected_message} in error message; got {error}" + ); + } +} + #[test] fn debug_init_error_maps_malformed_initialize_config_to_json_rpc_minus_32602_with_stable_message_prefix() { @@ -909,6 +1371,139 @@ fn debug_process_spawns_and_declares_configured_tools() { assert_eq!(tool_names, expected_manifest_tool_names()); } +#[test] +fn debug_process_with_rr_record_backend_declares_manifest_parity_for_rr_tool_surface() { + let mut child = RawDebugChild::spawn(); + child.write_frame(json!({ + "jsonrpc": "2.0", + "id": 33, + "method": "initialize", + "params": { + "protocol_version": PROTOCOL_VERSION, + "client_info": "debug-e2e/0.1.0", + "extension_config": valid_rr_debug_init_payload() + } + })); + + let initialized = child.read_frame(); + assert_eq!(initialized["id"], 33); + let runtime_names = initialized["result"]["data"]["tools"] + .as_array() + .expect("tools array") + .iter() + .map(|tool| tool["name"].as_str().expect("tool name").to_owned()) + .collect::>(); + let manifest_names = manifest_tool_names(); + let expected = expected_manifest_tool_names() + .into_iter() + .chain(expected_rr_specific_manifest_tool_names()) + .map(str::to_owned) + .collect::>(); + + assert_eq!( + runtime_names, expected, + "rr runtime declarations must expose the exact debug plus rr tool surface" + ); + for name in &expected { + assert!( + manifest_names.contains(name), + "debug manifest must ship tool {name}; manifest names: {manifest_names:?}" + ); + } +} + +#[test] +fn debug_process_declared_rr_handlers_return_their_public_dtos() { + let mut child = RawDebugChild::spawn(); + initialize_debug_child( + &mut child, + 34, + valid_rr_debug_init_payload_with_trace_dir(&unique_relative_trace_dir("public-dtos")), + ); + + let traces = invoke_debug_tool_raw(&mut child, 35, "traces", json!({})); + assert_eq!(traces["result"]["data"], json!({ "traces": [] })); + + let delete_trace = invoke_debug_tool_raw( + &mut child, + 36, + "delete_trace", + json!({ "trace_id": "missing-trace" }), + ); + assert_eq!(delete_trace["result"]["data"]["deleted"], false); + assert_eq!( + delete_trace["result"]["data"]["error"]["code"], + "trace_not_found" + ); + + let find_origin = invoke_debug_tool_raw( + &mut child, + 37, + "find_origin", + json!({ + "trace_id": "trace-origin", + "language": "rust", + "watch": "answer", + "at": { "kind": "crash" } + }), + ); + assert_eq!(find_origin["result"]["data"]["found"], false); + assert_eq!(find_origin["result"]["data"]["reason"], "trace_not_found"); + + let record_and_find_origin = invoke_debug_tool_raw( + &mut child, + 38, + "record_and_find_origin", + json!({ + "record": { + "language": "rust", + "program": "target/debug/app", + "args": [], + "cwd": null, + "env": {}, + "timeout_ms": 1000 + }, + "origin": { + "language": "rust", + "watch": "answer", + "at": { "kind": "end" }, + "timeout_secs": 1, + "max_depth": 2, + "max_children": 4 + } + }), + ); + assert!(record_and_find_origin["result"]["data"]["record"].is_object()); + assert!( + record_and_find_origin["result"]["data"]["origin"].is_null() + || record_and_find_origin["result"]["data"]["origin"].is_object(), + "origin must be null when recording is unsupported or a structured result when recording succeeds: {record_and_find_origin}" + ); + assert_eq!( + record_and_find_origin["result"]["data"]["error"], + Value::Null + ); +} + +#[test] +fn debug_process_record_rejects_fields_outside_public_record_params_contract() { + let mut child = RawDebugChild::spawn(); + initialize_debug_child(&mut child, 39, valid_rr_debug_init_payload()); + + let response = invoke_debug_tool_raw( + &mut child, + 40, + "record", + json!({ + "language": "rust", + "program": "target/debug/app", + "launch_overrides": {} + }), + ); + + assert_eq!(response["error"]["code"], -32602); +} + #[test] fn debug_sidecar_launch_uses_configured_dap_adapter_and_creates_a_session() { let fixture_dir = tempfile::tempdir().expect("create fixture dir"); @@ -1307,6 +1902,434 @@ fn debug_tools_absent_without_config_and_present_with_valid_debug_config() { "{name} must be present when valid [debug.rust] config exists; got {present_names:?}" ); } + for name in expected_rr_specific_manifest_tool_names() { + let mcp_name = format!("tower_debug_{name}"); + assert!( + present_names.iter().all(|tool| tool != &mcp_name), + "{mcp_name} must be omitted from normal MCP discovery when [debug.record] backend = \"rr\" is absent; got {present_names:?}" + ); + } +} + +#[test] +fn rr_specific_tools_appear_in_merged_registry_only_when_record_backend_rr_is_configured() { + let names = merged_tool_names(rr_fixture_tower_config()); + + for name in expected_rr_specific_manifest_tool_names() { + let mcp_name = format!("tower_debug_{name}"); + assert!( + names.iter().any(|tool| tool == &mcp_name), + "{mcp_name} must be present in normal MCP discovery when [debug.record] backend = \"rr\" is configured; got {names:?}" + ); + } +} + +#[test] +fn reverse_debug_rr_tools_are_absent_without_record_backend_rr_and_present_with_valid_debug_plus_record_config() + { + let names_without_record = merged_tool_names(native_fixture_tower_config(&[], 5)); + for name in expected_rr_specific_manifest_tool_names() { + let mcp_name = format!("tower_debug_{name}"); + assert!( + names_without_record.iter().all(|tool| tool != &mcp_name), + "{mcp_name} must be absent without [debug.record] backend = \"rr\"; got {names_without_record:?}" + ); + } + + let names_with_record = merged_tool_names(rr_fixture_tower_config()); + for name in expected_rr_specific_manifest_tool_names() { + let mcp_name = format!("tower_debug_{name}"); + assert!( + names_with_record.iter().any(|tool| tool == &mcp_name), + "{mcp_name} must be present with valid debug plus rr record config; got {names_with_record:?}" + ); + } +} + +#[test] +fn reverse_debug_fixture_backed_e2e_covers_record_replay_reverse_continue_step_back_watchpoint_traces_and_delete_trace() + { + let token = format!("reverse-debug-tool-surface-{}", std::process::id()); + let (_workspace, mut merged) = + reverse_debug_fixture_registry(&token, &["--scenario=record_ok"]); + + let record = merged + .call( + "tower_debug_record", + reverse_debug_record_request(&token, "record_ok"), + ) + .expect("tower_debug_record returns fixture-backed record result"); + assert_eq!(record["recordable"], true, "record payload: {record}"); + let trace_id = record["trace_id"] + .as_str() + .expect("record returns trace_id") + .to_owned(); + + let traces = merged + .call("tower_debug_traces", json!({})) + .expect("tower_debug_traces returns recorded fixture trace"); + assert!( + traces["traces"] + .as_array() + .expect("traces array") + .iter() + .any(|trace| trace["trace_id"] == trace_id), + "traces should include {trace_id}; got {traces}" + ); + + let replay = merged + .call( + "tower_debug_replay", + json!({ "trace_id": trace_id, "language": "rust", "timeout_secs": 5 }), + ) + .expect("tower_debug_replay opens fixture replay"); + assert_eq!(replay["state"], "stopped", "replay payload: {replay}"); + assert_eq!(replay["supportsStepBack"], true, "replay payload: {replay}"); + let session_id = replay["session_id"] + .as_str() + .expect("replay returns session_id") + .to_owned(); + + let reverse = merged + .call( + "tower_debug_reverse_continue", + json!({ "session_id": session_id, "thread_id": 1, "timeout_secs": 5 }), + ) + .expect("tower_debug_reverse_continue returns a deterministic stop"); + assert_eq!(reverse["state"], "stopped", "reverse payload: {reverse}"); + assert_eq!( + reverse["reason"], "watchpoint", + "reverse payload: {reverse}" + ); + + let stepped = merged + .call( + "tower_debug_step_back", + json!({ "session_id": session_id, "thread_id": 1, "granularity": "line", "timeout_secs": 5 }), + ) + .expect("tower_debug_step_back returns a deterministic line stop"); + assert_eq!(stepped["state"], "stopped", "step_back payload: {stepped}"); + assert_eq!( + stepped["top_frame"]["line"], 11, + "step_back payload: {stepped}" + ); + + let watchpoint = merged + .call( + "tower_debug_watchpoint", + json!({ "session_id": session_id, "expression": "answer", "address": null, "kind": "write", "enabled": true }), + ) + .expect("tower_debug_watchpoint sets a replay watchpoint"); + assert_eq!(watchpoint["ok"], true, "watchpoint payload: {watchpoint}"); + assert_eq!( + watchpoint["watchpoint"]["expression"], "answer", + "watchpoint payload: {watchpoint}" + ); + + let deleted = merged + .call("tower_debug_delete_trace", json!({ "trace_id": trace_id })) + .expect("tower_debug_delete_trace removes the fixture trace"); + assert_eq!(deleted["deleted"], true, "delete payload: {deleted}"); + let terminated = merged + .call("tower_debug_terminate", json!({ "session_id": session_id })) + .expect("tower_debug_terminate cleans up the replay session"); + assert_eq!(terminated["ok"], true, "terminate payload: {terminated}"); + assert_fixture_processes_gone(&token); +} + +#[test] +fn reverse_debug_fixture_backed_e2e_find_origin_returns_found_true_with_frame_and_value_evidence() { + let token = format!("reverse-debug-origin-found-{}", std::process::id()); + let (_workspace, mut merged) = + reverse_debug_fixture_registry(&token, &["--scenario=watchpoint_stop"]); + let trace_id = reverse_debug_record_trace(&mut merged, &token, "watchpoint_stop"); + + let origin = merged + .call( + "tower_debug_find_origin", + json!({ + "trace_id": trace_id, + "language": "rust", + "watch": "answer", + "at": { "kind": "end" }, + "timeout_secs": 5, + "max_depth": 8, + "max_children": 8 + }), + ) + .expect("tower_debug_find_origin returns fixture origin result"); + + assert_eq!(origin["found"], true, "origin payload: {origin}"); + assert_eq!(origin["reason"], Value::Null, "origin payload: {origin}"); + assert_eq!( + origin["write_frame"]["name"], "main", + "origin payload: {origin}" + ); + assert_eq!( + origin["value"]["name"], "answer", + "origin payload: {origin}" + ); + assert_eq!(origin["value"]["value"], "42", "origin payload: {origin}"); + assert!( + origin["stack"] + .as_array() + .is_some_and(|stack| !stack.is_empty()), + "origin should include stack evidence; got {origin}" + ); + assert_fixture_processes_gone(&token); +} + +#[test] +fn reverse_debug_fixture_backed_e2e_find_origin_returns_found_false_with_reason_no_prior_write_reached() + { + let token = format!("reverse-debug-origin-none-{}", std::process::id()); + let (_workspace, mut merged) = + reverse_debug_fixture_registry(&token, &["--scenario=no_prior_write"]); + let trace_id = reverse_debug_record_trace(&mut merged, &token, "no_prior_write"); + + let origin = merged + .call( + "tower_debug_find_origin", + json!({ + "trace_id": trace_id, + "language": "rust", + "watch": "answer", + "at": { "kind": "end" }, + "timeout_secs": 5, + "max_depth": 8, + "max_children": 8 + }), + ) + .expect("tower_debug_find_origin returns fixture no-prior-write result"); + + assert_eq!(origin["found"], false, "origin payload: {origin}"); + assert_eq!( + origin["reason"], "no_prior_write_reached", + "origin payload: {origin}" + ); + assert_eq!(origin["error"], Value::Null, "origin payload: {origin}"); + assert_fixture_processes_gone(&token); +} + +#[test] +fn reverse_debug_fixture_backed_e2e_record_and_find_origin_success_and_recording_success_origin_failure_cleanup() + { + for (scenario, expected_found, expected_reason, expected_error_code) in [ + ("watchpoint_stop", true, Value::Null, Value::Null), + ( + "no_prior_write", + false, + json!("no_prior_write_reached"), + Value::Null, + ), + ( + "adapter_exited", + false, + Value::Null, + json!("adapter_exited"), + ), + ] { + let token = format!("reverse-debug-combined-{scenario}-{}", std::process::id()); + let (_workspace, mut merged) = + reverse_debug_fixture_registry(&token, &[&format!("--scenario={scenario}")]); + let result = merged + .call( + "tower_debug_record_and_find_origin", + json!({ + "record": reverse_debug_record_request(&token, scenario), + "origin": { + "language": "rust", + "watch": "answer", + "at": { "kind": "end" }, + "timeout_secs": 5, + "max_depth": 8, + "max_children": 8 + } + }), + ) + .expect("tower_debug_record_and_find_origin returns fixture result"); + + assert_eq!( + result["record"]["recordable"], true, + "combined payload: {result}" + ); + assert_eq!( + result["origin"]["found"], expected_found, + "combined payload: {result}" + ); + assert_eq!( + result["origin"]["reason"], expected_reason, + "combined payload: {result}" + ); + assert_eq!( + result["origin"]["error"]["code"], expected_error_code, + "combined payload: {result}" + ); + assert_fixture_processes_gone(&token); + } +} + +#[test] +fn reverse_debug_cleanup_token_assertions_prove_record_and_replay_process_trees_are_reaped_after_success_timeout_no_prior_write_adapter_exit_and_recipe_failure() + { + for (case, scenario) in [ + ("record", "record_ok"), + ("replay", "replay_open"), + ("timeout", "timeout"), + ("no_prior_write", "no_prior_write"), + ("adapter_exit", "adapter_exited"), + ("recipe_failure", "adapter_exited"), + ] { + let replay_token = format!("reverse-debug-cleanup-replay-{case}-{}", std::process::id()); + let record_token = format!("reverse-debug-cleanup-record-{case}-{}", std::process::id()); + let (_workspace, mut merged) = + reverse_debug_fixture_registry(&replay_token, &[&format!("--scenario={scenario}")]); + let result = merged.call( + "tower_debug_record_and_find_origin", + json!({ + "record": reverse_debug_cleanup_record_request(&record_token, scenario), + "origin": { + "language": "rust", + "watch": "answer", + "at": { "kind": "end" }, + "timeout_secs": 1, + "max_depth": 4, + "max_children": 4 + } + }), + ); + assert!( + result.is_ok(), + "scenario {scenario} must return a structured result before cleanup assertion: {result:?}" + ); + let result = result.expect("structured cleanup result"); + assert_cleanup_event_emitted_exactly_once(&result["record"], &record_token); + assert_cleanup_event_emitted_exactly_once(&result["origin"], &replay_token); + assert_fixture_processes_gone(&record_token); + assert_fixture_processes_gone(&replay_token); + } +} + +#[test] +fn reverse_debug_gated_real_rr_native_fixture_records_and_replays_tiny_program_when_preflight_passes_or_skips_cleanly() + { + let preflight = Command::new("rr").arg("--version").output(); + let Ok(preflight) = preflight else { + println!("SKIP reverse_debug real rr native fixture: rr binary is missing"); + return; + }; + if !preflight.status.success() { + println!( + "SKIP reverse_debug real rr native fixture: rr preflight failed: {}", + String::from_utf8_lossy(&preflight.stderr) + ); + return; + } + + let workspace = tempfile::tempdir().expect("create temp workspace"); + std::fs::write(workspace.path().join("main.rs"), "fn main() {}\n").expect("write source"); + + let opts = GlobalOpts { + workspace_dir: Some(workspace.path().to_path_buf()), + extensions_dir: None, + }; + let handle = build_engine(&opts, real_rr_native_fixture_tower_config()) + .expect("engine builds with real rr native fixture config"); + let mut merged = ExtensionMergedRegistry::new(Arc::clone(&handle.state), handle.ext_registry); + let fixture_path = fixture_debug_adapter_bin() + .to_str() + .expect("fixture path must be utf8") + .to_owned(); + let record = merged + .call( + "tower_debug_record", + json!({ + "language": "rust", + "program": fixture_path, + "args": [], + "cwd": null, + "env": {}, + "timeout_ms": 30_000 + }), + ) + .expect("real rr native fixture record returns structured result"); + assert_eq!(record["recordable"], true, "record payload: {record}"); + assert_eq!( + record["trace"]["program"], fixture_path, + "real rr record must trace the native fixture binary, not the scripted scenario fixture: {record}" + ); + let trace_id = record["trace_id"] + .as_str() + .expect("real rr record returns trace_id") + .to_owned(); + let replay = merged + .call( + "tower_debug_replay", + json!({ "trace_id": trace_id, "language": "rust", "timeout_secs": 5 }), + ) + .expect("real-rr gated fixture replay opens"); + assert_eq!(replay["state"], "stopped", "replay payload: {replay}"); +} + +#[test] +fn reverse_debug_twenty_parallel_initialize_record_or_replay_cleanup_shutdown_stress_test_completes_without_deadlock() + { + let handles = (0..20) + .map(|index| { + std::thread::spawn(move || { + let scenario = if index % 2 == 0 { + "record_ok" + } else { + "replay_open" + }; + let token = format!("reverse-debug-stress-{index}-{}", std::process::id()); + let mut child = RawDebugChild::spawn(); + initialize_debug_child(&mut child, 1, reverse_debug_fixture_init_payload(&token)); + let record = invoke_debug_tool( + &mut child, + 2, + "record", + reverse_debug_record_request(&token, scenario), + ); + assert_eq!( + record["recordable"], true, + "stress record payload: {record}" + ); + assert_cleanup_event_emitted_exactly_once(&record, &token); + if index % 2 == 1 { + let trace_id = record["trace_id"] + .as_str() + .expect("stress record returns trace id") + .to_owned(); + let replay = invoke_debug_tool( + &mut child, + 3, + "replay", + json!({ "trace_id": trace_id, "language": "rust", "timeout_secs": 5 }), + ); + let session_id = replay["session_id"] + .as_str() + .expect("stress replay returns session") + .to_owned(); + let terminated = invoke_debug_tool( + &mut child, + 4, + "terminate", + json!({ "session_id": session_id }), + ); + assert_eq!(terminated["ok"], true); + } + shutdown_debug_child(&mut child, 5); + assert_fixture_processes_gone(&token); + }) + }) + .collect::>(); + + for handle in handles { + handle + .join() + .expect("reverse debug stress worker should not panic"); + } } #[test] diff --git a/crates/core_engine/tests/functional_debug_reverse_mcp.rs b/crates/core_engine/tests/functional_debug_reverse_mcp.rs new file mode 100644 index 0000000..c2a5f40 --- /dev/null +++ b/crates/core_engine/tests/functional_debug_reverse_mcp.rs @@ -0,0 +1,301 @@ +// Feature: F006 + +#![forbid(unsafe_code)] +#![allow(clippy::pedantic)] + +use std::process::Command; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use core_engine::adapters::cli::GlobalOpts; +use core_engine::adapters::config::TowerConfig; +use core_engine::adapters::daemon::engine::build_engine; +use core_engine::adapters::mcp::extension_merged_registry::ExtensionMergedRegistry; +use core_engine::adapters::mcp::registry::ToolRegistry; +use serde_json::{Value, json}; + +fn workspace_root() -> std::path::PathBuf { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + std::path::Path::new(manifest_dir) + .parent() + .expect("crates directory") + .parent() + .expect("workspace root") + .to_path_buf() +} + +fn fixture_debug_adapter_bin() -> std::path::PathBuf { + workspace_root() + .join("target") + .join("debug") + .join("fixture_debug_adapter") +} + +fn rr_fixture_tower_config(token: &str) -> TowerConfig { + let command = serde_json::to_string( + fixture_debug_adapter_bin() + .to_str() + .expect("fixture path must be utf8"), + ) + .expect("fixture path must serialize"); + let token = serde_json::to_string(token).expect("token must serialize"); + + toml::from_str(&format!( + r#" +[extensions] +request_timeout_secs = 5 + +[debug.rust] +extensions = ["rs"] +command = {command} +args = ["--token", {token}] +adapter_type = "fixture" +default_timeout_secs = 5 +idle_ttl_secs = 300 + +[debug.record] +backend = "rr" +trace_dir = ".tower/traces" +ttl_secs = 86400 +max_traces = 25 +record_timeout_secs = 30 +"# + )) + .expect("fixture-backed rr config must parse") +} + +fn reverse_debug_registry(token: &str) -> (tempfile::TempDir, ExtensionMergedRegistry) { + let workspace = tempfile::tempdir().expect("create temp workspace"); + std::fs::write(workspace.path().join("main.rs"), "fn main() {}\n").expect("write source"); + + let opts = GlobalOpts { + workspace_dir: Some(workspace.path().to_path_buf()), + extensions_dir: None, + }; + let handle = build_engine(&opts, rr_fixture_tower_config(token)) + .expect("engine builds with debug sidecar"); + + ( + workspace, + ExtensionMergedRegistry::new(Arc::clone(&handle.state), handle.ext_registry), + ) +} + +fn record_request(token: &str, scenario: &str) -> Value { + json!({ + "language": "rust", + "program": "fixture-program", + "args": [scenario], + "cwd": null, + "env": { + "TOWER_DEBUG_FIXTURE_CLEANUP_TOKEN": token, + "TOWER_DEBUG_FIXTURE_SCENARIO": scenario + }, + "timeout_ms": 5_000 + }) +} + +fn cleanup_side_channel_path(token: &str) -> std::path::PathBuf { + let sanitized = token + .chars() + .map(|ch| match ch { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' => ch, + _ => '_', + }) + .collect::(); + std::env::temp_dir().join(format!("tower-debug-fixture-cleanup-{sanitized}.jsonl")) +} + +fn remove_cleanup_side_channel(token: &str) { + let _ = std::fs::remove_file(cleanup_side_channel_path(token)); +} + +fn assert_record_cleanup_once(record_result: &Value, token: &str) { + let cleanup_count = record_result["output"] + .as_array() + .expect("record result includes captured output array") + .iter() + .flat_map(|output| output["text"].as_str().unwrap_or_default().lines()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|event| event["event"] == "cleanup" && event["token"] == token) + .count(); + + assert_eq!( + cleanup_count, 1, + "record cleanup token {token} must appear exactly once in {record_result}" + ); +} + +fn assert_replay_cleanup_once(token: &str) { + let path = cleanup_side_channel_path(token); + let content = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read fixture cleanup side channel {path:?}: {error}")); + let cleanup_count = content + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|event| event["event"] == "cleanup" && event["token"] == token) + .count(); + + assert_eq!( + cleanup_count, 1, + "replay cleanup token {token} must appear exactly once in {path:?}: {content}" + ); +} + +fn fixture_process_count(token: &str) -> usize { + let output = Command::new("ps") + .args(["-eo", "pid=,args="]) + .output() + .expect("ps command"); + + String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| line.contains("fixture_debug_adapter") && line.contains(token)) + .count() +} + +fn assert_fixture_processes_gone(token: &str) { + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + if fixture_process_count(token) == 0 { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + + assert_eq!( + fixture_process_count(token), + 0, + "fixture adapter process with token {token} should be cleaned up" + ); +} + +#[test] +fn reverse_debug_records_replays_and_finds_origin_through_public_tools() { + let replay_token = format!("functional-reverse-debug-replay-{}", std::process::id()); + remove_cleanup_side_channel(&replay_token); + let (_workspace, mut registry) = reverse_debug_registry(&replay_token); + + let record = registry + .call( + "tower_debug_record", + record_request(&replay_token, "watchpoint_stop"), + ) + .expect("tower_debug_record returns a fixture-backed trace"); + assert_eq!(record["recordable"], true, "record payload: {record}"); + assert_record_cleanup_once(&record, &replay_token); + let trace_id = record["trace_id"] + .as_str() + .expect("record returns trace_id") + .to_owned(); + + let replay = registry + .call( + "tower_debug_replay", + json!({ "trace_id": trace_id, "language": "rust", "timeout_secs": 5 }), + ) + .expect("tower_debug_replay opens the recorded trace"); + assert_eq!(replay["state"], "stopped", "replay payload: {replay}"); + assert_eq!(replay["supportsStepBack"], true, "replay payload: {replay}"); + let session_id = replay["session_id"] + .as_str() + .expect("replay returns session_id") + .to_owned(); + + let reverse_stop = registry + .call( + "tower_debug_reverse_continue", + json!({ "session_id": session_id, "thread_id": 1, "timeout_secs": 5 }), + ) + .expect("tower_debug_reverse_continue returns a stop"); + assert_eq!( + reverse_stop["reason"], "watchpoint", + "reverse payload: {reverse_stop}" + ); + + let terminated = registry + .call("tower_debug_terminate", json!({ "session_id": session_id })) + .expect("tower_debug_terminate cleans up replay session"); + assert_eq!(terminated["ok"], true, "terminate payload: {terminated}"); + assert_replay_cleanup_once(&replay_token); + assert_fixture_processes_gone(&replay_token); + + let origin_token = format!("functional-reverse-debug-origin-{}", std::process::id()); + remove_cleanup_side_channel(&origin_token); + let (_workspace, mut registry) = reverse_debug_registry(&origin_token); + + let record = registry + .call( + "tower_debug_record", + record_request(&origin_token, "watchpoint_stop"), + ) + .expect("tower_debug_record returns a fixture-backed trace"); + assert_eq!(record["recordable"], true, "record payload: {record}"); + assert_record_cleanup_once(&record, &origin_token); + let trace_id = record["trace_id"] + .as_str() + .expect("record returns trace_id") + .to_owned(); + + let origin = registry + .call( + "tower_debug_find_origin", + json!({ + "trace_id": trace_id, + "language": "rust", + "watch": "answer", + "at": { "kind": "end" }, + "timeout_secs": 5, + "max_depth": 8, + "max_children": 8 + }), + ) + .expect("tower_debug_find_origin returns origin evidence"); + assert_eq!(origin["found"], true, "origin payload: {origin}"); + assert_eq!( + origin["write_frame"]["name"], "main", + "origin payload: {origin}" + ); + assert_eq!( + origin["value"]["name"], "answer", + "origin payload: {origin}" + ); + assert_eq!(origin["value"]["value"], "42", "origin payload: {origin}"); + assert_replay_cleanup_once(&origin_token); + assert_fixture_processes_gone(&origin_token); +} + +#[test] +fn reverse_debug_reports_no_prior_write_as_structured_tool_result() { + let token = format!("functional-reverse-debug-no-prior-{}", std::process::id()); + remove_cleanup_side_channel(&token); + let (_workspace, mut registry) = reverse_debug_registry(&token); + + let result = registry + .call( + "tower_debug_record_and_find_origin", + json!({ + "record": record_request(&token, "no_prior_write"), + "origin": { + "language": "rust", + "watch": "answer", + "at": { "kind": "end" }, + "timeout_secs": 5, + "max_depth": 8, + "max_children": 8 + } + }), + ) + .expect("expected no-prior-write returns a structured successful payload"); + + assert_eq!(result["record"]["recordable"], true, "payload: {result}"); + assert_record_cleanup_once(&result["record"], &token); + assert_eq!(result["origin"]["found"], false, "payload: {result}"); + assert_eq!( + result["origin"]["reason"], "no_prior_write_reached", + "payload: {result}" + ); + assert_eq!(result["origin"]["error"], Value::Null, "payload: {result}"); + assert_replay_cleanup_once(&token); + assert_fixture_processes_gone(&token); +} diff --git a/crates/extension_protocol/tests/protocol_tests.rs b/crates/extension_protocol/tests/protocol_tests.rs index c801a03..3a7b87b 100644 --- a/crates/extension_protocol/tests/protocol_tests.rs +++ b/crates/extension_protocol/tests/protocol_tests.rs @@ -657,6 +657,15 @@ fn debug_manifest_declares_exactly_the_required_local_tool_names() { "variables", "evaluate", "eval_at", + "record", + "replay", + "reverse_continue", + "step_back", + "watchpoint", + "traces", + "delete_trace", + "find_origin", + "record_and_find_origin", "terminate", "disconnect", "sessions", @@ -716,10 +725,12 @@ fn extension_manifest_parses_debug_tools_and_preserves_all_debug_tool_names() { assert_eq!(manifest.name, "debug"); assert_eq!(manifest.command, vec!["debug_extension"]); - assert_eq!(tool_names.len(), 13); + assert_eq!(tool_names.len(), 22); assert!(tool_names.contains(&"launch")); assert!(tool_names.contains(&"set_breakpoints")); assert!(tool_names.contains(&"eval_at")); + assert!(tool_names.contains(&"record")); + assert!(tool_names.contains(&"record_and_find_origin")); assert!(tool_names.contains(&"sessions")); } @@ -745,11 +756,20 @@ fn debug_protocol_stability_includes_eval_at_without_workspace_mutation_capabili "variables", "evaluate", "eval_at", + "record", + "replay", + "reverse_continue", + "step_back", + "watchpoint", + "traces", + "delete_trace", + "find_origin", + "record_and_find_origin", "terminate", "disconnect", "sessions", ], - "debug protocol stability must include eval_at in the shipped tool order" + "debug protocol stability must include rr tools in the shipped tool order" ); assert!( manifest.capabilities.required.is_empty(), diff --git a/docs/README.md b/docs/README.md index 682ec12..313972b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,10 +3,10 @@ | Document | Purpose | |---|---| | [getting-started.md](getting-started.md) | Prerequisites, build commands, quality gate, first MCP session with copy-paste examples | -| [user-guide/debug-sessions.md](user-guide/debug-sessions.md) | How to configure and drive interactive Debug Adapter Protocol sessions and one-shot `tower_debug_eval_at` probes | +| [user-guide/debug-sessions.md](user-guide/debug-sessions.md) | How to configure and drive interactive Debug Adapter Protocol sessions, one-shot `tower_debug_eval_at` probes, and rr-backed reverse-debug workflows | | [user-guide/lint-fixes.md](user-guide/lint-fixes.md) | How to preview and apply structured linter fixes safely with `tower_lint_fix` | | [architecture.md](architecture.md) | Hexagonal boundary, crate layout, port signatures, data flow (startup → serve loop → watcher), extension host runtime, invariants table, design decisions | -| [mcp-tools.md](mcp-tools.md) | Wire protocol, session lifecycle, the native `tower_*` tools, extension tools (`tower_ast_get_outline`, `tower_lint_check`, `tower_lint_fix`, `tower_debug_launch`, `tower_debug_eval_at`, ...), JSON-RPC error codes | +| [mcp-tools.md](mcp-tools.md) | Wire protocol, session lifecycle, the native `tower_*` tools, extension tools (`tower_ast_get_outline`, `tower_lint_check`, `tower_lint_fix`, `tower_debug_launch`, `tower_debug_eval_at`, `tower_debug_record`, ...), JSON-RPC error codes | | [towerignore.md](towerignore.md) | `.towerignore` index ignore source (git-independent), syntax, `tower init` scaffold, default template, BREAKING change for `.gitignore`-reliant workspaces | | [extensions.md](extensions.md) | Extension authoring guide: out-of-process native sidecars, the JSON-RPC 2.0 protocol & lifecycle, capability callbacks, the `extension.toml` manifest, discovery/activation, supervision/fault model, worked examples | | [development.md](development.md) | Spec-driven workflow, CI pipeline, testing conventions, hexagonal boundary rules, invariants | diff --git a/docs/extensions.md b/docs/extensions.md index 323f3fd..933e67a 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -273,6 +273,13 @@ adapter_type = "lldb" launch = { request = "launch", program = "target/debug/app" } default_timeout_secs = 15 idle_ttl_secs = 300 + +[debug.record] +backend = "rr" +trace_dir = ".tower/traces" +ttl_secs = 86400 +max_traces = 20 +record_timeout_secs = 60 ``` | Field | Meaning | @@ -285,11 +292,23 @@ idle_ttl_secs = 300 | `default_timeout_secs` | Positive per-operation timeout used by launch, resume, inspect, and cleanup calls unless a tool overrides it. | | `idle_ttl_secs` | Positive idle lifetime for an inactive debug session before the sidecar terminates and reaps it. | +Add `[debug.record] backend = "rr"` only when rr record/replay tools should be exposed. The host +validates this section at startup and forwards it through the sidecar initialize payload; the sidecar +does not reread `.tower/config.toml`. + +| Field | Meaning | +|-------|---------| +| `backend` | Required record backend. The only supported value is `"rr"`. | +| `trace_dir` | Optional workspace-relative trace root. Defaults to `.tower/traces`; absolute paths and `..` traversal are rejected. | +| `ttl_secs` | Optional positive trace TTL in seconds. Omit for no TTL expiry. | +| `max_traces` | Optional positive retained trace limit. Defaults to `20`; pruning still applies without TTL. | +| `record_timeout_secs` | Optional positive default record timeout. Defaults to `60`; tool calls may pass `timeout_ms`. | + The debug sidecar declares no workspace mutation capabilities. A discovered extension named `debug` takes priority over the bundled fallback. It owns adapter and debuggee process lifecycle inside the extension process and removes ephemeral sessions on terminate, disconnect, shutdown, quarantine, or idle expiry. See [debug sessions](user-guide/debug-sessions.md) for the operator workflow and -[MCP tool reference](mcp-tools.md#interactive-debug-tools) for the tool contract. +[MCP tool reference](mcp-tools.md#debug-tools) for the tool contract. --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 116f985..bb40a43 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -49,7 +49,7 @@ cargo build # or: make build ``` The host binary produced is `target/debug/tower`; the reference extension binaries -(`ast_extension`, `hello_extension`, `lsp_extension`, `lint_extension`) are also under +(`ast_extension`, `debug_extension`, `hello_extension`, `lsp_extension`, `lint_extension`) are also under `target/debug/`. --- @@ -129,8 +129,8 @@ database there. This directory is created automatically; no manual setup is requ ### Initial scan -On the first run against a workspace, `tower` walks the directory tree (respecting `.gitignore`), -indexes every text file, and reports progress to `stderr`: +On the first run against a workspace, `tower` walks the directory tree, respects `.towerignore` +when present, indexes every non-hidden text file, and reports progress to `stderr`: ``` tower: initial scan complete — 312 files indexed diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 827fb7b..04feaaf 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -999,8 +999,9 @@ Stable fix error codes are `lint_fix_unavailable`, `lint_fix_apply_failed`, and ## Debug tools The `debug` extension (manifest `name = "debug"`) bridges configured Debug Adapter Protocol -adapters and also provides a stateless one-shot eval-at probe. Its tools are lazy extension tools and -appear only when all of the following are true: +adapters, provides a stateless one-shot eval-at probe, and can expose rr-backed record/replay +reverse debugging. Its live-session tools are lazy extension tools and appear only when all of the +following are true: - the bundled debug sidecar binary is available next to `tower`, or a `debug` extension is discovered from an extension scope; @@ -1020,6 +1021,25 @@ default_timeout_secs = 15 idle_ttl_secs = 300 ``` +Enable rr-backed record/replay tools with an explicit record section: + +```toml +[debug.record] +backend = "rr" +trace_dir = ".tower/traces" +ttl_secs = 86400 +max_traces = 20 +record_timeout_secs = 60 +``` + +`trace_dir` is workspace-relative and defaults to `.tower/traces`. `ttl_secs`, +`max_traces`, and `record_timeout_secs` must be positive when present; the defaults are no TTL, +`20` retained traces, and `60` seconds. + +Record/replay tools are omitted from `tools/list` unless `[debug.record] backend = "rr"` is present. +rr itself is preflighted at record time, so unsupported hosts return a structured payload instead of +crashing the extension. + The debug extension declares no workspace mutation capabilities. Sessions are ephemeral and owned by the sidecar; terminate, disconnect, shutdown, quarantine, and idle TTL expiry clean up the adapter and debuggee process tree. @@ -1040,8 +1060,12 @@ errors: } ``` -Stable debug error codes are `session-not-found`, `not-stopped`, `debug-timeout`, `adapter-exited`, -and `launch-failed`. Malformed tool parameters still return protocol-level invalid-params errors. +Stable debug error codes include `session-not-found`, `not-stopped`, `debug-timeout`, +`adapter-exited`, `launch-failed`, and `reverse_unsupported`. rr record failures use stable payload +codes such as `rr_unsupported`, `record_timeout`, and `record_failed`. Origin-finding failures use +stable reasons such as `no_prior_write_reached`, `trace_not_found`, `watch_evaluation_failed`, +`replay_open_failed`, `origin_timeout`, `record_failed`, and `capture_failed`. Malformed tool parameters still return +protocol-level invalid-params errors. ### tower_debug_launch @@ -1237,6 +1261,136 @@ evidence, and always tears down the internal session before returning. It does n `finished:"timeout"` after cleanup. If a requested breakpoint condition cannot be honored, the result sets `condition_unsupported:true` instead of silently ignoring the condition. +### tower_debug_record + +Record one native execution through rr and register the resulting trace. This tool is present only +when `[debug.record] backend = "rr"` is configured. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `language` | string | yes | Configured `[debug.]` key | +| `program` | string | yes | Program path to run under `rr record`; the program must already be built | +| `args` | string array | no | Program arguments; defaults to `[]` | +| `cwd` | string or null | no | Working directory for the recorded process | +| `env` | string map | no | Environment variables for the recorded process; defaults to `{}` | +| `timeout_ms` | integer or null | no | Record timeout in milliseconds; omit to use `debug.record.record_timeout_secs` | + +**Returns** `{"recordable": bool, "reason": string|null, "trace_id": string|null, "trace": object|null, "exit_code": integer|null, "output": array, "output_truncated": bool, "error": object|null}`. + +Unsupported rr environments return `recordable:false`, `reason:"rr_unsupported"`, and +`error.data.unsupported_reason` such as `rr_missing`, `non_linux_host`, +`unsupported_cpu`, `unsupported_perf_counters`, or `rr_unsupported`. Record output is bounded across +stdout and stderr; `output_truncated:true` signals that the cap was reached. + +### tower_debug_replay + +Open a replay-backed debug session for a recorded trace. Replay sessions reuse the normal session +tools (`tower_debug_continue`, `tower_debug_step`, `tower_debug_threads`, `tower_debug_stack`, +`tower_debug_variables`, `tower_debug_evaluate`, `tower_debug_terminate`, and +`tower_debug_disconnect`) and advertise reverse execution support. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `trace_id` | string | yes | Trace returned by `tower_debug_record` or `tower_debug_traces` | +| `language` | string | yes | Configured `[debug.]` key | +| `timeout_secs` | integer or null | no | Override the configured debug timeout for replay startup | + +**Returns** `{"session_id": string, "trace_id": string, "state": "stopped", "stop": object|null, "supportsStepBack": bool}`. + +### tower_debug_reverse_continue + +Resume a replay session backward until the previous matching stop, breakpoint, watchpoint, start of +trace, or timeout. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `session_id` | string | yes | Replay session returned by `tower_debug_replay` | +| `thread_id` | integer or null | no | Adapter thread to reverse-continue | +| `timeout_secs` | integer or null | no | Override the configured timeout for this call | + +**Returns** the same stop object as `tower_debug_continue`. Live non-replay sessions return a +structured `reverse_unsupported` payload. + +### tower_debug_step_back + +Step a replay session backward. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `session_id` | string | yes | Replay session returned by `tower_debug_replay` | +| `thread_id` | integer or null | no | Adapter thread to step backward | +| `granularity` | `"line"`, `"instruction"`, or `"over"` | no | Backward step granularity; defaults to `"line"` | +| `timeout_secs` | integer or null | no | Override the configured timeout for this call | + +**Returns** the same stop object as `tower_debug_continue`. Live non-replay sessions return a +structured `reverse_unsupported` payload. + +### tower_debug_watchpoint + +Set a replay-session watchpoint by expression or address. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `session_id` | string | yes | Replay session returned by `tower_debug_replay` | +| `expression` | string or null | no | Expression to watch, for example a variable name | +| `address` | string or null | no | Address to watch when expression-based watchpoints are not desired | +| `kind` | `"write"`, `"read"`, or `"access"` | yes | Watchpoint access kind | +| `enabled` | boolean or null | no | Defaults to `true` | +| `timeout_secs` | integer or null | no | Reserved timeout override for adapters that need it | + +**Returns** `{"ok": bool, "watchpoint": {"watchpoint_id": string, "expression": string|null, "address": string|null, "kind": string, "enabled": bool, "verified": bool}|null, "error": object|null}`. + +### tower_debug_traces + +List recorded traces known to the debug sidecar. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| _(none)_ | — | — | This tool takes no arguments | + +**Returns** `{"traces": [{"trace_id": string, "path": string, "created_unix_secs": uint, "program": string, "args_summary": array, "exit_code": integer|null, "output_summary": array, "output_truncated": bool, "expires_unix_secs": uint|null, "ttl_secs": uint|null, "prune_generation": uint}]}`. + +### tower_debug_delete_trace + +Delete one recorded trace by id. Deletion is contained to the configured trace root. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `trace_id` | string | yes | Trace id returned by `tower_debug_record` or `tower_debug_traces` | + +**Returns** `{"deleted": bool, "trace_id": string, "error": object|null}`. + +### tower_debug_find_origin + +Open replay, seek to a target point, set a write watchpoint for a watched value, reverse-continue to +the last write, capture bounded evidence, and clean up the replay session before returning. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `trace_id` | string | yes | Trace to inspect | +| `language` | string | yes | Configured `[debug.]` key | +| `at` | object | yes | Target point: `{"kind":"crash"}`, `{"kind":"end"}`, or `{"kind":"source","path":"src/main.rs","line":42,"column":1}` | +| `watch` | string | yes | Watched expression or value location | +| `timeout_secs` | integer or null | no | Timeout for replay seek and reverse operations | +| `max_depth` | integer or null | no | Recursive variable expansion depth | +| `max_children` | integer or null | no | Maximum expanded children per node or scope | + +**Returns** `{"found": bool, "reason": string|null, "trace_id": string|null, "write_frame": object|null, "stack": array, "value": object|null, "locals": array, "args": array, "output": array, "truncated": bool, "error": object|null}`. + +When reverse execution reaches the start of the trace without finding a prior write, the result has +`found:false` and `reason:"no_prior_write_reached"` rather than a transport error. + +### tower_debug_record_and_find_origin + +Record a program and immediately run the origin-finding recipe against the new trace. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `record` | object | yes | Same shape as `tower_debug_record` arguments | +| `origin` | object | yes | Origin request without `trace_id`: `language`, `at`, `watch`, `timeout_secs`, `max_depth`, and `max_children` | + +**Returns** `{"record": , "origin": |null}`. If recording is unsupported, `origin` is `null`; if recording succeeds but origin search fails, both the trace record result and origin failure are returned. + ### tower_debug_terminate Terminate the debuggee and adapter process tree, then remove the session. diff --git a/docs/user-guide/debug-sessions.md b/docs/user-guide/debug-sessions.md index c0a0469..5f0c618 100644 --- a/docs/user-guide/debug-sessions.md +++ b/docs/user-guide/debug-sessions.md @@ -1,7 +1,8 @@ # Debug Sessions And Probes This guide shows how to configure the `debug` extension, drive a live Debug Adapter Protocol -session, and run a stateless one-shot probe through `tower_debug_*` MCP tools. +session, run a stateless one-shot probe, and use rr-backed replay workflows through +`tower_debug_*` MCP tools. Debugging is opt-in. The tools are absent from `tools/list` unless the workspace has a valid `[debug.]` config entry and the `debug` extension is enabled. A discovered `debug` @@ -39,6 +40,26 @@ Expected tool names include `tower_debug_launch`, `tower_debug_set_breakpoints`, `tower_debug_continue`, `tower_debug_stack`, `tower_debug_variables`, `tower_debug_evaluate`, `tower_debug_eval_at`, and `tower_debug_terminate`. +## Enable rr Record/Replay + +Add `[debug.record] backend = "rr"` when you want time-travel debugging tools. Without this section, +the rr-specific tools are absent from `tools/list` even if ordinary debug tools are enabled. + +```toml +[debug.record] +backend = "rr" +trace_dir = ".tower/traces" +ttl_secs = 86400 +max_traces = 20 +record_timeout_secs = 60 +``` + +`trace_dir` must stay inside the workspace. `ttl_secs`, `max_traces`, and +`record_timeout_secs` are optional positive values; omit `ttl_secs` for no TTL expiry. rr host +support is checked when recording. Missing rr, non-Linux hosts, unsupported CPUs, or unsupported +perf-counter settings return `recordable:false` with `reason:"rr_unsupported"` instead of crashing +the extension. + ## Launch and Stop Start a session for a configured language. `launch_overrides` is merged into the configured `launch` @@ -139,24 +160,110 @@ If the breakpoint is not reached before normal process exit, the payload has `hi `finished:"timeout"` after teardown. Individual expression failures appear under that expression as `{"error":"..."}` without failing the whole probe. +## Record and Replay + +Record a built native program: + +```json +{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"tower_debug_record","arguments":{"language":"rust","program":"target/debug/app","args":[],"cwd":".","env":{},"timeout_ms":60000}}} +``` + +A successful result includes `recordable:true`, `trace_id`, trace metadata, the recorded program +`exit_code`, bounded `output`, and `output_truncated`. An unsupported host returns +`recordable:false` and an `error.data.unsupported_reason` such as `rr_missing` or +`non_linux_host`. + +Open a replay session from the trace: + +```json +{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"tower_debug_replay","arguments":{"trace_id":"trace-1","language":"rust","timeout_secs":15}}} +``` + +Replay returns a normal `session_id` plus `supportsStepBack:true`. Use the same inspection tools as +live sessions, then call `tower_debug_terminate` or `tower_debug_disconnect` when done. + +## Navigate Backward + +Step backward by line, instruction, or "over": + +```json +{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"tower_debug_step_back","arguments":{"session_id":"debug-1","thread_id":1,"granularity":"over","timeout_secs":15}}} +``` + +Set a write watchpoint and reverse-continue to the previous write or stop: + +```json +{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"tower_debug_watchpoint","arguments":{"session_id":"debug-1","expression":"x","address":null,"kind":"write","enabled":true,"timeout_secs":15}}} +``` + +```json +{"jsonrpc":"2.0","id":14,"method":"tools/call","params":{"name":"tower_debug_reverse_continue","arguments":{"session_id":"debug-1","thread_id":1,"timeout_secs":15}}} +``` + +Reverse operations on live non-replay sessions return a structured payload error with code +`reverse_unsupported`. + +## Find an Origin + +Use `tower_debug_find_origin` when you already have a trace and need the last write to a watched +value. The tool opens replay, seeks to the requested target, sets a write watchpoint, runs backward, +captures bounded stack/value evidence, and cleans up the replay session before returning. + +```json +{"jsonrpc":"2.0","id":15,"method":"tools/call","params":{"name":"tower_debug_find_origin","arguments":{"trace_id":"trace-1","language":"rust","at":{"kind":"crash"},"watch":"x","timeout_secs":15,"max_depth":2,"max_children":50}}} +``` + +Targets are `{"kind":"crash"}`, `{"kind":"end"}`, or +`{"kind":"source","path":"src/main.rs","line":42,"column":1}`. A found result includes +`found:true`, `write_frame`, `stack`, `value`, `locals`, `args`, `output`, and `truncated`. If replay +reaches the beginning of the trace without a prior write, the result is `found:false` with +`reason:"no_prior_write_reached"`. + +Use `tower_debug_record_and_find_origin` to combine recording and origin search: + +```json +{"jsonrpc":"2.0","id":16,"method":"tools/call","params":{"name":"tower_debug_record_and_find_origin","arguments":{"record":{"language":"rust","program":"target/debug/app","args":[],"cwd":".","env":{},"timeout_ms":60000},"origin":{"language":"rust","at":{"kind":"crash"},"watch":"x","timeout_secs":15,"max_depth":2,"max_children":50}}}} +``` + +The response always includes the `record` result. `origin` is `null` when recording is unsupported; +otherwise it contains the same shape as `tower_debug_find_origin`. + +## Manage Traces + +List traces: + +```json +{"jsonrpc":"2.0","id":17,"method":"tools/call","params":{"name":"tower_debug_traces","arguments":{}}} +``` + +Delete a trace: + +```json +{"jsonrpc":"2.0","id":18,"method":"tools/call","params":{"name":"tower_debug_delete_trace","arguments":{"trace_id":"trace-1"}}} +``` + +New recordings prune expired traces and oldest traces above `max_traces`. Trace ids are restricted +to ASCII letters, digits, `.`, `_`, and `-`; invalid, missing, deleted, or expired trace ids return a +structured payload error instead of escaping the configured trace root. + ## Cleanup Terminate the debuggee and adapter process tree: ```json -{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"tower_debug_terminate","arguments":{"session_id":"debug-1"}}} +{"jsonrpc":"2.0","id":19,"method":"tools/call","params":{"name":"tower_debug_terminate","arguments":{"session_id":"debug-1"}}} ``` Use `tower_debug_disconnect` when the adapter should disconnect instead of terminate: ```json -{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"tower_debug_disconnect","arguments":{"session_id":"debug-1"}}} +{"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"tower_debug_disconnect","arguments":{"session_id":"debug-1"}}} ``` List live sessions at any time: ```json -{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"tower_debug_sessions","arguments":{}}} +{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"tower_debug_sessions","arguments":{}}} ``` Sessions are ephemeral and are not restored after extension restart. Unknown, ended, expired, or lost @@ -178,5 +285,6 @@ codes: } ``` -Stable codes are `session-not-found`, `not-stopped`, `debug-timeout`, `adapter-exited`, and -`launch-failed`. Malformed tool arguments still use protocol-level invalid-params errors. +Stable codes include `session-not-found`, `not-stopped`, `debug-timeout`, `adapter-exited`, +`launch-failed`, `reverse_unsupported`, `rr_unsupported`, `record_timeout`, and `record_failed`. +Malformed tool arguments still use protocol-level invalid-params errors. diff --git a/extensions/debug/extension.toml b/extensions/debug/extension.toml index 34b0dca..bd24ec0 100644 --- a/extensions/debug/extension.toml +++ b/extensions/debug/extension.toml @@ -56,6 +56,51 @@ name = "eval_at" description = "Run an eval-at debug probe." schema_json = '{"type":"object","properties":{"lang":{"type":"string"},"program":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":["string","null"]},"env":{"type":"object","additionalProperties":{"type":"string"}},"breakpoint":{"type":["object","null"],"properties":{"path":{"type":"string"},"line":{"type":"integer","minimum":0},"condition":{"type":["string","null"]}},"required":["path","line"],"additionalProperties":false},"expressions":{"type":"array","items":{"type":"string"}},"capture":{"type":"object","properties":{"stack":{"type":"boolean"},"locals":{"type":"boolean"},"args":{"type":"boolean"}},"additionalProperties":false},"on_hit":{"type":"string","enum":["first","all"]},"max_hits":{"type":"integer","minimum":1},"max_depth":{"type":"integer","minimum":0},"max_children":{"type":"integer","minimum":0},"timeout_ms":{"type":["integer","null"],"minimum":0}},"required":["lang","program"],"additionalProperties":false}' +[[tools]] +name = "record" +description = "Record a debug trace." +schema_json = '{"type":"object","properties":{"language":{"type":"string"},"program":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":["string","null"]},"env":{"type":"object","additionalProperties":{"type":"string"}},"timeout_ms":{"type":["integer","null"],"minimum":1}},"required":["language","program"],"additionalProperties":false}' + +[[tools]] +name = "replay" +description = "Replay a debug trace." +schema_json = '{"type":"object","properties":{"trace_id":{"type":"string"},"language":{"type":"string"},"timeout_secs":{"type":["integer","null"],"minimum":0}},"required":["trace_id","language"],"additionalProperties":false}' + +[[tools]] +name = "reverse_continue" +description = "Continue a replay session backwards." +schema_json = '{"type":"object","properties":{"session_id":{"type":"string"},"thread_id":{"type":["integer","null"],"minimum":0},"timeout_secs":{"type":["integer","null"],"minimum":0}},"required":["session_id"],"additionalProperties":false}' + +[[tools]] +name = "step_back" +description = "Step a replay session backwards." +schema_json = '{"type":"object","properties":{"session_id":{"type":"string"},"thread_id":{"type":["integer","null"],"minimum":0},"granularity":{"type":["string","null"],"enum":["line","instruction","over",null]},"timeout_secs":{"type":["integer","null"],"minimum":0}},"required":["session_id"],"additionalProperties":false}' + +[[tools]] +name = "watchpoint" +description = "Set a replay watchpoint." +schema_json = '{"type":"object","properties":{"session_id":{"type":"string"},"expression":{"type":["string","null"]},"address":{"type":["string","null"]},"kind":{"type":"string","enum":["write","read","access"]},"enabled":{"type":["boolean","null"]},"timeout_secs":{"type":["integer","null"],"minimum":0}},"required":["session_id","kind"],"additionalProperties":false}' + +[[tools]] +name = "traces" +description = "List recorded debug traces." +schema_json = '{"type":"object","properties":{},"additionalProperties":false}' + +[[tools]] +name = "delete_trace" +description = "Delete a debug trace." +schema_json = '{"type":"object","properties":{"trace_id":{"type":"string"}},"required":["trace_id"],"additionalProperties":false}' + +[[tools]] +name = "find_origin" +description = "Find the origin of a value in a replay session." +schema_json = '{"type":"object","properties":{"trace_id":{"type":"string"},"language":{"type":"string"},"at":{"oneOf":[{"type":"object","properties":{"kind":{"const":"crash"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"const":"end"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"const":"source"},"path":{"type":"string"},"line":{"type":"integer","minimum":0},"column":{"type":["integer","null"],"minimum":0}},"required":["kind","path","line"],"additionalProperties":false}]},"watch":{"type":"string"},"timeout_secs":{"type":["integer","null"],"minimum":0},"max_depth":{"type":["integer","null"],"minimum":0},"max_children":{"type":["integer","null"],"minimum":0}},"required":["trace_id","language","at","watch"],"additionalProperties":false}' + +[[tools]] +name = "record_and_find_origin" +description = "Record a trace and find a value origin." +schema_json = '{"type":"object","properties":{"record":{"type":"object","properties":{"language":{"type":"string"},"program":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":["string","null"]},"env":{"type":"object","additionalProperties":{"type":"string"}},"timeout_ms":{"type":["integer","null"],"minimum":1}},"required":["language","program"],"additionalProperties":false},"origin":{"type":"object","properties":{"language":{"type":"string"},"at":{"oneOf":[{"type":"object","properties":{"kind":{"const":"crash"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"const":"end"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"const":"source"},"path":{"type":"string"},"line":{"type":"integer","minimum":0},"column":{"type":["integer","null"],"minimum":0}},"required":["kind","path","line"],"additionalProperties":false}]},"watch":{"type":"string"},"timeout_secs":{"type":["integer","null"],"minimum":0},"max_depth":{"type":["integer","null"],"minimum":0},"max_children":{"type":["integer","null"],"minimum":0}},"required":["language","at","watch"],"additionalProperties":false}},"required":["record","origin"],"additionalProperties":false}' + [[tools]] name = "terminate" description = "Terminate a debug session." diff --git a/extensions/debug/src/eval_at.rs b/extensions/debug/src/eval_at.rs index e8dddea..48008a0 100644 --- a/extensions/debug/src/eval_at.rs +++ b/extensions/debug/src/eval_at.rs @@ -289,7 +289,7 @@ fn run_eval_at_loop( } } -enum CaptureError { +pub(crate) enum CaptureError { AdapterGone, Runtime(DebugRuntimeError), } @@ -366,7 +366,7 @@ fn capture_scopes( Ok((locals, args)) } -fn capture_variables( +pub(crate) fn capture_variables( sessions: &SessionManager, session_id: &DebugSessionId, variables_reference: u64, @@ -514,9 +514,9 @@ fn runtime_tool_error(error: DebugRuntimeError) -> DebugToolError { DebugRuntimeError::SessionNotFound(_) => DebugToolErrorCode::SessionNotFound, DebugRuntimeError::NotStopped(_) => DebugToolErrorCode::NotStopped, DebugRuntimeError::DebugTimeout(_) => DebugToolErrorCode::DebugTimeout, - DebugRuntimeError::AdapterExited(_) | DebugRuntimeError::LaunchFailed(_) => { - DebugToolErrorCode::InvalidParams - } + DebugRuntimeError::AdapterExited(_) + | DebugRuntimeError::LaunchFailed(_) + | DebugRuntimeError::ReverseUnsupported(_) => DebugToolErrorCode::InvalidParams, }, message: runtime_message(error), } @@ -528,7 +528,8 @@ fn runtime_message(error: DebugRuntimeError) -> String { | DebugRuntimeError::NotStopped(message) | DebugRuntimeError::DebugTimeout(message) | DebugRuntimeError::AdapterExited(message) - | DebugRuntimeError::LaunchFailed(message) => message, + | DebugRuntimeError::LaunchFailed(message) + | DebugRuntimeError::ReverseUnsupported(message) => message, } } @@ -1478,6 +1479,7 @@ mod tests { idle_ttl_secs: 60, }, )]), + record: None, } } diff --git a/extensions/debug/src/main.rs b/extensions/debug/src/main.rs index 250e7f4..ddc7c61 100644 --- a/extensions/debug/src/main.rs +++ b/extensions/debug/src/main.rs @@ -2,10 +2,13 @@ pub mod dap; pub mod eval_at; +pub mod origin; pub mod process; mod protocol; +pub mod rr; pub mod session; pub mod tools; +pub mod traces; pub mod types; use std::collections::{BTreeMap, VecDeque}; @@ -27,8 +30,10 @@ use protocol::{ DebugAdapterConfig, DebugInitializeConfig, debug_not_initialized_result, debug_tool_declarations, debug_tool_unavailable_result, }; +use rr::RrRuntime; use serde_json::{Map, Value, json}; use session::{DebugAdapterFactory, DebugAdapterSession, LaunchRequest, SessionManager}; +use traces::{TracePolicy, TraceStore}; use types::{ DebugBreakpoint, DebugRuntimeError, DebugScope, DebugSessionState, DebugStackFrame, DebugStop, DebugThread, DebugVariable, @@ -38,6 +43,13 @@ fn main() { serve_debug(); } +fn uses_fixture_adapter(config: &DebugInitializeConfig) -> bool { + config + .languages + .values() + .any(|adapter| adapter.adapter_type == "fixture") +} + fn serve_debug() { let out = Arc::new(Mutex::new(io::stdout())); let mut lines = BufReader::new(io::stdin()).lines(); @@ -46,6 +58,7 @@ fn serve_debug() { let mut config: Option = None; let mut initialized = false; let mut sessions: Option = None; + let mut rr_runtime: Option = None; while let Some(frame) = next_frame(&mut lines, &mut queued) { match frame { @@ -58,6 +71,7 @@ fn serve_debug() { params, &mut config, &mut sessions, + &mut rr_runtime, &mut initialized, ); } @@ -74,6 +88,7 @@ fn serve_debug() { params, config.as_ref(), sessions.as_ref(), + rr_runtime.as_mut(), initialized, ); } @@ -138,6 +153,7 @@ fn handle_initialize( params: Value, config: &mut Option, sessions: &mut Option, + rr_runtime: &mut Option, initialized: &mut bool, ) { let init_params: InitParams = match serde_json::from_value(params) { @@ -163,9 +179,35 @@ fn handle_initialize( match DebugInitializeConfig::from_init_payload(init_params.extension_config) { Ok(parsed_config) => { + let parsed_traces = match parsed_config + .as_ref() + .and_then(|config| config.record.as_ref()) + .map(TracePolicy::from_record_config) + .transpose() + { + Ok(Some(policy)) => match TraceStore::open(policy) { + Ok(store) => Some(store), + Err(error) => { + let _ = send_error(out, id, -32602, &error.to_string()); + return; + } + }, + Ok(None) => None, + Err(error) => { + let _ = send_error(out, id, -32602, &error.to_string()); + return; + } + }; *sessions = parsed_config.clone().map(|config| { SessionManager::new(config.clone(), Arc::new(RealDebugAdapterFactory { config })) }); + *rr_runtime = parsed_traces.map(|store| { + if parsed_config.as_ref().is_some_and(uses_fixture_adapter) { + RrRuntime::new_fixture(store) + } else { + RrRuntime::new(store) + } + }); *config = parsed_config; *initialized = true; let result = InitResult { @@ -187,6 +229,7 @@ fn handle_invoke_tool( params: Value, config: Option<&DebugInitializeConfig>, sessions: Option<&SessionManager>, + rr_runtime: Option<&mut RrRuntime>, initialized: bool, ) { let tool_name = params.get("name").and_then(Value::as_str).unwrap_or(""); @@ -227,9 +270,15 @@ fn handle_invoke_tool( io.queued, ); - let result = sessions - .map(|sessions| dispatch_debug_tool(tool_name, tool_params, sessions)) - .unwrap_or_else(|| Ok(debug_tool_unavailable_result(tool_name))); + let result = if let Some(rr_runtime) = rr_runtime + && is_rr_tool(tool_name) + { + dispatch_rr_tool(tool_name, tool_params, sessions, rr_runtime) + } else { + sessions + .map(|sessions| dispatch_debug_tool(tool_name, tool_params, sessions)) + .unwrap_or_else(|| Ok(debug_tool_unavailable_result(tool_name))) + }; match result { Ok(result) => { @@ -246,6 +295,36 @@ fn handle_invoke_tool( } } +fn is_rr_tool(tool_name: &str) -> bool { + matches!( + tool_name, + "record" | "replay" | "traces" | "delete_trace" | "find_origin" | "record_and_find_origin" + ) +} + +fn dispatch_rr_tool( + tool_name: &str, + params: Value, + sessions: Option<&SessionManager>, + rr_runtime: &mut RrRuntime, +) -> Result { + match tool_name { + "record" => tools::tower_debug_record(params, rr_runtime), + "replay" => sessions + .map(|sessions| tools::tower_debug_replay(params, sessions, rr_runtime)) + .unwrap_or_else(|| Ok(debug_tool_unavailable_result(tool_name))), + "traces" => tools::tower_debug_traces(params, rr_runtime), + "delete_trace" => tools::tower_debug_delete_trace(params, rr_runtime), + "find_origin" => sessions + .map(|sessions| tools::tower_debug_find_origin(params, sessions, rr_runtime)) + .unwrap_or_else(|| Ok(debug_tool_unavailable_result(tool_name))), + "record_and_find_origin" => sessions + .map(|sessions| tools::tower_debug_record_and_find_origin(params, sessions, rr_runtime)) + .unwrap_or_else(|| Ok(debug_tool_unavailable_result(tool_name))), + _ => Ok(debug_tool_unavailable_result(tool_name)), + } +} + fn dispatch_debug_tool( tool_name: &str, params: Value, @@ -257,6 +336,9 @@ fn dispatch_debug_tool( "continue" => tools::tower_debug_continue(params, sessions), "step" => tools::tower_debug_step(params, sessions), "pause" => tools::tower_debug_pause(params, sessions), + "reverse_continue" => tools::tower_debug_reverse_continue(params, sessions), + "step_back" => tools::tower_debug_step_back(params, sessions), + "watchpoint" => tools::tower_debug_watchpoint(params, sessions), "threads" => tools::tower_debug_threads(params, sessions), "stack" => tools::tower_debug_stack(params, sessions), "variables" => tools::tower_debug_variables(params, sessions), @@ -646,6 +728,88 @@ impl DebugAdapterSession for ProcessDebugAdapterSession { }) } + fn reverse_continue( + &mut self, + thread_id: Option, + timeout: Duration, + ) -> Result { + self.resume( + "reverseContinue", + json!({ "threadId": thread_id.unwrap_or(1) }), + timeout, + ) + } + + fn step_back( + &mut self, + thread_id: Option, + granularity: session::StepBackGranularity, + timeout: Duration, + ) -> Result { + self.resume( + "stepBack", + json!({ + "threadId": thread_id.unwrap_or(1), + "granularity": granularity + }), + timeout, + ) + } + + fn set_watchpoint( + &mut self, + watchpoint: session::WatchpointSpec, + timeout: Duration, + ) -> Result { + let response = self.client.request( + "setDataBreakpoints", + json!({ + "breakpoints": [{ + "dataId": watchpoint.expression.as_deref().or(watchpoint.address.as_deref()).unwrap_or("watchpoint"), + "accessType": watchpoint.kind, + "enabled": watchpoint.enabled + }] + }), + timeout, + )?; + ensure_success(response.clone())?; + let breakpoint = response + .body + .get("breakpoints") + .and_then(Value::as_array) + .and_then(|breakpoints| breakpoints.first()) + .cloned() + .unwrap_or(Value::Null); + Ok(session::WatchpointResult { + watchpoint_id: breakpoint + .get("id") + .and_then(Value::as_str) + .unwrap_or("watch-1") + .to_owned(), + expression: watchpoint.expression, + address: watchpoint.address, + kind: watchpoint.kind, + enabled: watchpoint.enabled, + verified: breakpoint + .get("verified") + .and_then(Value::as_bool) + .unwrap_or(true), + }) + } + + fn seek_replay( + &mut self, + target: session::ReplaySeekTarget, + timeout: Duration, + ) -> Result { + let target = serde_json::to_value(target).map_err(|error| { + DebugRuntimeError::LaunchFailed(format!( + "failed to serialize replay seek target: {error}" + )) + })?; + self.resume("seekReplay", target, timeout) + } + fn terminate(&mut self, timeout: Duration) -> Result<(), DebugRuntimeError> { let response = self.client.request("terminate", json!({}), timeout)?; ensure_success(response) @@ -672,6 +836,16 @@ impl ProcessDebugAdapterSession { { Ok(Some(event)) => Ok(self.stop_from_event(event, timeout)), Ok(None) | Err(DapError::Timeout { .. }) => Ok(self.timeout_stop()), + Err(DapError::AdapterExited) => Ok(DebugStop { + state: DebugSessionState::Terminated, + reason: Some("adapter_exited".to_owned()), + thread_id: None, + top_frame: None, + hit_breakpoint_ids: Vec::new(), + timed_out: false, + exit_code: None, + output_since: self.drain_output_events(), + }), Err(error) => Err(error.into()), } } @@ -1131,6 +1305,7 @@ fn tower_debug_eval_at_maps_params_and_never_exposes_session_id() { idle_ttl_secs: 60, }, )]), + record: None, }, Arc::new(ExactEvalAtFactory), ); @@ -1167,10 +1342,16 @@ mod tests { use super::{ DapClient, DapError, DapTransport, DebugAdapterSession, DebugBreakpoint, DebugRuntimeError, - DebugSessionState, ProcessDebugAdapterSession, dispatch_debug_tool, + DebugScope, DebugSessionState, DebugStackFrame, DebugStop, DebugThread, DebugVariable, + ProcessDebugAdapterSession, dispatch_debug_tool, dispatch_rr_tool, }; use crate::protocol::{DebugAdapterConfig, DebugInitializeConfig, DebugToolErrorCode}; + use crate::rr::{ + FakeRrPreflight, FakeRrRecorder, RR_UNSUPPORTED, RrPreflightStatus, RrRecordResult, + RrRuntime, RrUnsupportedReason, + }; use crate::session::{DebugAdapterFactory, LaunchRequest, SessionManager}; + use crate::traces::{TracePolicy, TraceStore}; #[test] fn main_dispatches_incoming_eval_at_requests_through_tower_debug_eval_at_and_preserves_spec_33_handlers() @@ -1202,6 +1383,7 @@ mod tests { idle_ttl_secs: 60, }, )]), + record: None, }, Arc::new(DispatchOnlyFactory), ); @@ -1244,6 +1426,333 @@ mod tests { } } + #[test] + fn replay_specific_tool_dispatch_surfaces_reverse_unsupported_as_runtime_failure_for_live_sessions() + { + struct LiveAdapterFactory; + struct LiveAdapter; + + impl DebugAdapterFactory for LiveAdapterFactory { + fn start( + &self, + _request: &LaunchRequest, + ) -> Result, DebugRuntimeError> { + Ok(Box::new(LiveAdapter)) + } + } + + impl DebugAdapterSession for LiveAdapter { + fn initialize(&mut self, _timeout: Duration) -> Result<(), DebugRuntimeError> { + Ok(()) + } + + fn launch( + &mut self, + _request: &LaunchRequest, + _timeout: Duration, + ) -> Result<(), DebugRuntimeError> { + Ok(()) + } + + fn set_breakpoints( + &mut self, + breakpoints: &[DebugBreakpoint], + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + Ok(breakpoints.to_vec()) + } + + fn continue_session( + &mut self, + _timeout: Duration, + ) -> Result { + Ok(stopped()) + } + + fn step( + &mut self, + _thread_id: Option, + _timeout: Duration, + ) -> Result { + Ok(stopped()) + } + + fn pause( + &mut self, + _thread_id: Option, + _timeout: Duration, + ) -> Result { + Ok(stopped()) + } + + fn threads( + &mut self, + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + Ok(Vec::new()) + } + + fn stack( + &mut self, + _thread_id: u64, + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + Ok(Vec::new()) + } + + fn scopes( + &mut self, + _frame_id: u64, + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + Ok(Vec::new()) + } + + fn variables( + &mut self, + _variables_reference: u64, + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + Ok(Vec::new()) + } + + fn evaluate( + &mut self, + _frame_id: u64, + expression: &str, + _timeout: Duration, + ) -> Result { + Ok(DebugVariable { + name: expression.to_owned(), + value: "42".to_owned(), + r#type: None, + variables_reference: 0, + }) + } + + fn terminate(&mut self, _timeout: Duration) -> Result<(), DebugRuntimeError> { + Ok(()) + } + + fn disconnect(&mut self, _timeout: Duration) -> Result<(), DebugRuntimeError> { + Ok(()) + } + } + + fn stopped() -> DebugStop { + DebugStop { + state: DebugSessionState::Stopped, + reason: Some("breakpoint".to_owned()), + thread_id: Some(1), + top_frame: None, + hit_breakpoint_ids: Vec::new(), + timed_out: false, + exit_code: None, + output_since: Vec::new(), + } + } + + let sessions = SessionManager::new( + DebugInitializeConfig { + languages: std::collections::BTreeMap::from([( + "rust".to_owned(), + DebugAdapterConfig { + extensions: vec!["rs".to_owned()], + command: "fake-debug-adapter".to_owned(), + args: Vec::new(), + adapter_type: "fake".to_owned(), + launch: serde_json::Map::new(), + default_timeout_secs: 1, + idle_ttl_secs: 60, + }, + )]), + record: None, + }, + Arc::new(LiveAdapterFactory), + ); + let live = sessions + .launch(LaunchRequest { + language: "rust".to_owned(), + program: "target/debug/app".to_owned(), + cwd: None, + args: Vec::new(), + env: std::collections::BTreeMap::new(), + launch_overrides: serde_json::Map::new(), + }) + .expect("live session launch should establish a session for dispatch rejection"); + + let result = dispatch_debug_tool( + "reverse_continue", + json!({ "session_id": live.session_id }), + &sessions, + ) + .expect("reverse_continue handler should return a structured tool payload"); + + assert_eq!(result["ok"], false); + assert_eq!(result["error"]["code"], "reverse_unsupported"); + assert_eq!(result["error"]["message"], "reverse_unsupported"); + } + + #[test] + fn main_dispatches_record_requests_through_rr_runtime_and_returns_record_payload() { + let mut rr_runtime = RrRuntime::with_parts( + Box::new(FakeRrPreflight::new(RrPreflightStatus::Unsupported { + reason: RrUnsupportedReason::UnsupportedCpu, + message: "CPU lacks required rr support".to_owned(), + })), + Box::new(FakeRrRecorder::new(RrRecordResult { + recordable: true, + reason: None, + trace_id: None, + trace: None, + exit_code: Some(0), + output: Vec::new(), + output_truncated: false, + error: None, + })), + TraceStore::new(TracePolicy { + trace_root: std::env::temp_dir().join("tower-record-dispatch-test"), + ttl_secs: Some(60), + max_traces: 20, + record_timeout_secs: 60, + }), + ); + + let result = dispatch_rr_tool( + "record", + json!({ + "language": "rust", + "program": "target/debug/app", + "args": ["--case", "smoke"], + "cwd": null, + "env": {}, + "timeout_ms": 250 + }), + None, + &mut rr_runtime, + ) + .expect("record dispatch returns a structured result"); + + assert_eq!(result["recordable"], false); + assert_eq!(result["reason"], RR_UNSUPPORTED); + assert_eq!(result["error"]["code"], RR_UNSUPPORTED); + assert_eq!( + result["error"]["data"], + json!({ "unsupported_reason": "unsupported_cpu" }) + ); + + let error = dispatch_rr_tool( + "record", + json!({ + "language": "rust", + "program": "target/debug/app", + "launch_overrides": {} + }), + None, + &mut rr_runtime, + ) + .expect_err("record dispatch must reject fields outside the public RecordParams contract"); + assert_eq!(error.code, DebugToolErrorCode::InvalidParams); + } + + #[test] + fn main_dispatches_rr_trace_and_origin_tools_through_public_handlers() { + let mut rr_runtime = RrRuntime::with_parts( + Box::new(FakeRrPreflight::new(RrPreflightStatus::Unsupported { + reason: RrUnsupportedReason::UnsupportedCpu, + message: "CPU lacks required rr support".to_owned(), + })), + Box::new(FakeRrRecorder::new(RrRecordResult { + recordable: true, + reason: None, + trace_id: None, + trace: None, + exit_code: Some(0), + output: Vec::new(), + output_truncated: false, + error: None, + })), + TraceStore::new(TracePolicy { + trace_root: std::env::temp_dir().join("tower-rr-tool-dispatch-test"), + ttl_secs: Some(60), + max_traces: 20, + record_timeout_secs: 60, + }), + ); + let sessions = SessionManager::new( + DebugInitializeConfig { + languages: std::collections::BTreeMap::new(), + record: None, + }, + Arc::new(DispatchNeverStartsFactory), + ); + + let traces = dispatch_rr_tool("traces", json!({}), Some(&sessions), &mut rr_runtime) + .expect("traces dispatch must route to TraceStore-backed handler"); + let deleted = dispatch_rr_tool( + "delete_trace", + json!({ "trace_id": "missing-trace" }), + Some(&sessions), + &mut rr_runtime, + ) + .expect("delete_trace dispatch must route to TraceStore-backed handler"); + let origin = dispatch_rr_tool( + "find_origin", + json!({ + "trace_id": "trace-origin", + "language": "rust", + "watch": "answer", + "at": { "kind": "crash" } + }), + Some(&sessions), + &mut rr_runtime, + ) + .expect("find_origin dispatch must route to stable origin DTO handler"); + let record_and_origin = dispatch_rr_tool( + "record_and_find_origin", + json!({ + "record": { + "language": "rust", + "program": "target/debug/app", + "args": [], + "cwd": null, + "env": {}, + "timeout_ms": 250 + }, + "origin": { + "language": "rust", + "watch": "answer", + "at": { "kind": "end" }, + "timeout_secs": 1, + "max_depth": 2, + "max_children": 4 + } + }), + Some(&sessions), + &mut rr_runtime, + ) + .expect("record_and_find_origin dispatch must route to public combined handler"); + + assert_eq!(traces, json!({ "traces": [] })); + assert_eq!(deleted["deleted"], false); + assert_eq!(deleted["error"]["code"], "trace_not_found"); + assert_eq!(origin["found"], false); + assert_eq!(origin["reason"], "trace_not_found"); + assert_eq!(record_and_origin["record"]["recordable"], false); + assert_eq!(record_and_origin["origin"], Value::Null); + } + + struct DispatchNeverStartsFactory; + + impl DebugAdapterFactory for DispatchNeverStartsFactory { + fn start( + &self, + _request: &LaunchRequest, + ) -> Result, DebugRuntimeError> { + panic!("rr trace/origin dispatch tests must not start a debug adapter") + } + } + struct ScriptedTransport { frames: VecDeque, DapError>>, sent: Arc>>, @@ -1469,8 +1978,15 @@ mod tests { } #[test] - fn process_debug_adapter_session_resume_propagates_adapter_exited_after_continue_response() { + fn process_debug_adapter_session_resume_preserves_buffered_output_when_adapter_exits_after_continue_response() + { let transport = ScriptedTransport::new(vec![ + Ok(Some(json!({ + "seq": 9, + "type": "event", + "event": "output", + "body": { "category": "stdout", "output": "cleanup\n" } + }))), Ok(Some(json!({ "seq": 10, "type": "response", @@ -1483,11 +1999,14 @@ mod tests { ]); let mut session = process_session(transport); - let error = session + let stop = session .continue_session(Duration::from_millis(50)) - .expect_err("adapter EOF must remain an adapter-exited error"); + .expect("adapter EOF after response is a terminated stop"); - assert!(matches!(error, DebugRuntimeError::AdapterExited(_))); + assert_eq!(stop.state, DebugSessionState::Terminated); + assert_eq!(stop.reason.as_deref(), Some("adapter_exited")); + assert_eq!(stop.output_since.len(), 1); + assert_eq!(stop.output_since[0].text, "cleanup\n"); } fn process_session(transport: ScriptedTransport) -> ProcessDebugAdapterSession { diff --git a/extensions/debug/src/origin.rs b/extensions/debug/src/origin.rs new file mode 100644 index 0000000..376b76f --- /dev/null +++ b/extensions/debug/src/origin.rs @@ -0,0 +1,1680 @@ +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::eval_at::{CaptureError, CapturedVariable, capture_variables}; +use crate::rr::{RrRecordRequest, RrRecordResult, RrRuntime}; +use crate::session::{ + ReplayOpenRequest, ReplaySeekTarget, SessionManager, WatchpointKind, WatchpointSpec, +}; +use crate::tools::RecordParams; +use crate::traces::{TraceId, TraceStore}; +use crate::types::DebugOutput; +use crate::types::{ + DebugRuntimeError, DebugScope, DebugSessionId, DebugSessionState, DebugStackFrame, + DebugVariable, RuntimeFailure, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum OriginTarget { + Crash, + End, + Source { + path: String, + line: u64, + column: Option, + }, +} + +impl From for ReplaySeekTarget { + fn from(target: OriginTarget) -> Self { + match target { + OriginTarget::Crash => Self::Crash, + OriginTarget::End => Self::End, + OriginTarget::Source { path, line, column } => Self::Source { path, line, column }, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OriginFailureCode { + NoPriorWriteReached, + WatchEvaluationFailed, + TraceNotFound, + ReplayOpenFailed, + ReverseUnsupported, + OriginTimeout, + RecordFailed, + CaptureFailed, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FindOriginRequest { + pub trace_id: TraceId, + pub language: String, + pub at: OriginTarget, + pub watch: String, + pub timeout_secs: Option, + pub max_depth: Option, + pub max_children: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FindOriginResult { + pub found: bool, + pub reason: Option, + pub trace_id: Option, + pub write_frame: Option, + pub stack: Vec, + pub value: Option, + pub locals: Vec, + pub args: Vec, + pub output: Vec, + pub truncated: bool, + pub error: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecordOriginParams { + pub language: String, + pub at: OriginTarget, + pub watch: String, + pub timeout_secs: Option, + pub max_depth: Option, + pub max_children: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecordAndFindOriginRequest { + pub record: RecordParams, + pub origin: RecordOriginParams, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RecordAndFindOriginResult { + pub record: RrRecordResult, + pub origin: Option, +} + +pub fn find_origin(request: FindOriginRequest, sessions: &SessionManager) -> FindOriginResult { + find_origin_with_trace_path(request, sessions, None) +} + +pub fn find_origin_with_trace_store( + request: FindOriginRequest, + sessions: &SessionManager, + trace_store: &TraceStore, +) -> FindOriginResult { + let trace = match trace_store.trace(&request.trace_id) { + Ok(trace) => trace, + Err(error) => { + return failure( + request.trace_id, + OriginFailureCode::TraceNotFound, + error.to_string(), + ); + } + }; + find_origin_with_trace_path(request, sessions, Some(trace.path)) +} + +fn find_origin_with_trace_path( + request: FindOriginRequest, + sessions: &SessionManager, + trace_path: Option, +) -> FindOriginResult { + let timeout = request.timeout_secs.map(std::time::Duration::from_secs); + let replay = match sessions.open_replay(ReplayOpenRequest { + trace_id: request.trace_id.clone(), + trace_path, + language: request.language.clone(), + timeout_secs: request.timeout_secs, + adapter: None, + adapter_args: Vec::new(), + }) { + Ok(replay) => replay, + Err(error) => { + return failure( + request.trace_id, + OriginFailureCode::ReplayOpenFailed, + runtime_message(error), + ); + } + }; + let session_id = replay.session_id; + let trace_id = replay.trace_id; + + let mut result = match run_find_origin(&request, sessions, &session_id, &trace_id, timeout) { + Ok(result) => result, + Err((code, message)) => failure(trace_id, code, message), + }; + + if let Err(cleanup_error) = sessions.terminate(&session_id) { + attach_cleanup_error(&mut result, runtime_message(cleanup_error)); + } + + result +} + +pub fn record_and_find_origin( + request: RecordAndFindOriginRequest, + sessions: &SessionManager, + rr_runtime: &mut RrRuntime, +) -> RecordAndFindOriginResult { + let record = rr_runtime.record(RrRecordRequest { + language: request.record.language, + program: request.record.program, + args: request.record.args, + cwd: request.record.cwd, + env: request.record.env, + timeout_ms: request.record.timeout_ms, + trace_policy: rr_runtime.store.policy().clone(), + }); + + let Some(trace_id) = record.trace_id.clone() else { + let origin = if record.reason.as_deref() == Some("rr_unsupported") { + None + } else { + Some(failure( + TraceId::new("record-failed").expect("static trace id is valid"), + OriginFailureCode::RecordFailed, + record + .error + .as_ref() + .map(|error| error.message.clone()) + .unwrap_or_else(|| "record failed".to_owned()), + )) + }; + return RecordAndFindOriginResult { record, origin }; + }; + let trace_path = record.trace.as_ref().map(|trace| trace.path.clone()); + + let origin = find_origin_with_trace_path( + FindOriginRequest { + trace_id, + language: request.origin.language, + at: request.origin.at, + watch: request.origin.watch, + timeout_secs: request.origin.timeout_secs, + max_depth: request.origin.max_depth, + max_children: request.origin.max_children, + }, + sessions, + trace_path, + ); + + RecordAndFindOriginResult { + record, + origin: Some(origin), + } +} + +fn run_find_origin( + request: &FindOriginRequest, + sessions: &SessionManager, + session_id: &DebugSessionId, + trace_id: &TraceId, + timeout: Option, +) -> Result { + let seek = match sessions.seek_replay( + session_id, + ReplaySeekTarget::from(request.at.clone()), + timeout, + ) { + Ok(seek) => seek, + Err(DebugRuntimeError::AdapterExited(_) | DebugRuntimeError::SessionNotFound(_)) => { + return Ok(adapter_exited(trace_id.clone(), Vec::new())); + } + Err(error) => { + return Err(map_runtime_error( + error, + OriginFailureCode::ReverseUnsupported, + )); + } + }; + if seek.timed_out { + return Err(( + OriginFailureCode::OriginTimeout, + "seek replay timed out".to_owned(), + )); + } + + sessions + .set_watchpoint( + session_id, + WatchpointSpec { + expression: Some(request.watch.clone()), + address: None, + kind: WatchpointKind::Write, + enabled: true, + }, + ) + .map_err(|error| map_runtime_error(error, OriginFailureCode::WatchEvaluationFailed))?; + + let stop = match sessions.reverse_continue(session_id, None, timeout) { + Ok(stop) => stop, + Err(DebugRuntimeError::AdapterExited(_) | DebugRuntimeError::SessionNotFound(_)) => { + return Ok(adapter_exited(trace_id.clone(), Vec::new())); + } + Err(error) => { + return Err(map_runtime_error( + error, + OriginFailureCode::ReverseUnsupported, + )); + } + }; + if stop.timed_out { + return Err(( + OriginFailureCode::OriginTimeout, + "origin search timed out".to_owned(), + )); + } + if stop.reason.as_deref() == Some("adapter_exited") { + return Ok(adapter_exited(trace_id.clone(), stop.output_since)); + } + if stop.state != DebugSessionState::Stopped { + return Ok(no_prior_write(trace_id.clone(), stop.output_since)); + } + + capture_origin(request, sessions, session_id, trace_id, &stop) +} + +fn capture_origin( + request: &FindOriginRequest, + sessions: &SessionManager, + session_id: &DebugSessionId, + trace_id: &TraceId, + stop: &crate::types::DebugStop, +) -> Result { + let thread_id = stop.thread_id.ok_or_else(|| { + ( + OriginFailureCode::NoPriorWriteReached, + "reverse execution reached trace start".to_owned(), + ) + })?; + let stack = sessions + .stack(session_id, thread_id) + .map_err(|error| map_runtime_error(error, OriginFailureCode::CaptureFailed))?; + let frame = stop + .top_frame + .clone() + .or_else(|| stack.first().cloned()) + .ok_or_else(|| { + ( + OriginFailureCode::NoPriorWriteReached, + "reverse execution reached trace start".to_owned(), + ) + })?; + let max_depth = request.max_depth.unwrap_or(2); + let max_children = request.max_children.unwrap_or(50); + let locals = capture_local_variables(sessions, session_id, frame.id, max_depth, max_children) + .map_err(map_capture_error)?; + let evaluated = sessions + .evaluate(session_id, frame.id, request.watch.clone()) + .map_err(|error| map_runtime_error(error, OriginFailureCode::WatchEvaluationFailed))?; + let value = locals + .iter() + .find(|variable| variable.name == evaluated.name || variable.name == request.watch) + .cloned() + .or_else(|| Some(from_debug_variable(evaluated))); + let truncated = locals.iter().any(is_truncated) || value.as_ref().is_some_and(is_truncated); + + Ok(FindOriginResult { + found: true, + reason: None, + trace_id: Some(trace_id.clone()), + write_frame: Some(frame), + stack, + value, + locals, + args: Vec::new(), + output: stop.output_since.clone(), + truncated, + error: None, + }) +} + +fn capture_local_variables( + sessions: &SessionManager, + session_id: &DebugSessionId, + frame_id: u64, + max_depth: usize, + max_children: usize, +) -> Result, CaptureError> { + let scopes = sessions + .scopes(session_id, frame_id) + .map_err(CaptureError::Runtime)?; + let mut locals = Vec::new(); + for scope in scopes { + if is_local_scope(&scope) { + locals.extend(capture_variables( + sessions, + session_id, + scope.variables_reference, + max_depth, + max_children, + )?); + } + } + Ok(locals) +} + +fn is_local_scope(scope: &DebugScope) -> bool { + scope.name.eq_ignore_ascii_case("locals") || scope.name.eq_ignore_ascii_case("local") +} + +fn from_debug_variable(variable: DebugVariable) -> CapturedVariable { + CapturedVariable { + name: variable.name, + value: variable.value, + r#type: variable.r#type, + children: Vec::new(), + truncated: variable.variables_reference != 0, + } +} + +fn is_truncated(variable: &CapturedVariable) -> bool { + variable.truncated || variable.children.iter().any(is_truncated) +} + +fn no_prior_write(trace_id: TraceId, output: Vec) -> FindOriginResult { + FindOriginResult { + found: false, + reason: Some(OriginFailureCode::NoPriorWriteReached), + trace_id: Some(trace_id), + write_frame: None, + stack: Vec::new(), + value: None, + locals: Vec::new(), + args: Vec::new(), + output, + truncated: false, + error: None, + } +} + +fn adapter_exited(trace_id: TraceId, output: Vec) -> FindOriginResult { + FindOriginResult { + found: false, + reason: None, + trace_id: Some(trace_id), + write_frame: None, + stack: Vec::new(), + value: None, + locals: Vec::new(), + args: Vec::new(), + output, + truncated: false, + error: Some(RuntimeFailure { + code: "adapter_exited".to_owned(), + message: "debug adapter exited during origin search".to_owned(), + data: None, + }), + } +} + +fn failure(trace_id: TraceId, code: OriginFailureCode, message: String) -> FindOriginResult { + let error = (!matches!(code, OriginFailureCode::NoPriorWriteReached)).then(|| RuntimeFailure { + code: failure_code_string(&code), + message, + data: None, + }); + FindOriginResult { + found: false, + reason: Some(code), + trace_id: Some(trace_id), + write_frame: None, + stack: Vec::new(), + value: None, + locals: Vec::new(), + args: Vec::new(), + output: Vec::new(), + truncated: false, + error, + } +} + +fn map_capture_error(error: CaptureError) -> (OriginFailureCode, String) { + match error { + CaptureError::AdapterGone => ( + OriginFailureCode::CaptureFailed, + "debug adapter exited during capture".to_owned(), + ), + CaptureError::Runtime(error) => (OriginFailureCode::CaptureFailed, runtime_message(error)), + } +} + +fn map_runtime_error( + error: DebugRuntimeError, + default_code: OriginFailureCode, +) -> (OriginFailureCode, String) { + let code = match error { + DebugRuntimeError::DebugTimeout(_) => OriginFailureCode::OriginTimeout, + DebugRuntimeError::ReverseUnsupported(_) => OriginFailureCode::ReverseUnsupported, + DebugRuntimeError::SessionNotFound(_) + | DebugRuntimeError::NotStopped(_) + | DebugRuntimeError::AdapterExited(_) + | DebugRuntimeError::LaunchFailed(_) => default_code, + }; + (code, runtime_message(error)) +} + +fn runtime_message(error: DebugRuntimeError) -> String { + match error { + DebugRuntimeError::SessionNotFound(message) + | DebugRuntimeError::NotStopped(message) + | DebugRuntimeError::DebugTimeout(message) + | DebugRuntimeError::AdapterExited(message) + | DebugRuntimeError::LaunchFailed(message) + | DebugRuntimeError::ReverseUnsupported(message) => message, + } +} + +fn attach_cleanup_error(result: &mut FindOriginResult, cleanup_error: String) { + if let Some(error) = &mut result.error { + let mut data = error + .data + .take() + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + data.insert("cleanup_error".to_owned(), Value::String(cleanup_error)); + error.data = Some(Value::Object(data)); + } +} + +fn failure_code_string(code: &OriginFailureCode) -> String { + serde_json::to_value(code) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "origin_failure".to_owned()) +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, VecDeque}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use serde_json::{Map, json}; + + use super::{ + FindOriginRequest, FindOriginResult, OriginFailureCode, OriginTarget, + RecordAndFindOriginRequest, RecordOriginParams, find_origin, record_and_find_origin, + }; + use crate::eval_at::CapturedVariable; + use crate::protocol::{DebugAdapterConfig, DebugInitializeConfig, DebugRecordConfig}; + use crate::rr::{ + FakeRrPreflight, FakeRrRecorder, RrPreflightStatus, RrRecordResult, RrRuntime, + }; + use crate::session::{ + DebugAdapterFactory, DebugAdapterSession, LaunchRequest, ReplaySeekTarget, SessionManager, + WatchpointKind, WatchpointResult, WatchpointSpec, + }; + use crate::tools::RecordParams; + use crate::traces::{TraceId, TracePolicy, TraceStore}; + use crate::types::{ + DebugBreakpoint, DebugOutput, DebugRuntimeError, DebugScope, DebugSessionState, + DebugStackFrame, DebugStop, DebugThread, DebugVariable, RuntimeFailure, + }; + + #[test] + fn origin_target_exists_with_exact_serde_shapes_crash_end_and_source_path_line_column() { + assert_eq!( + serde_json::to_value(OriginTarget::Crash).unwrap(), + json!({ "kind": "crash" }) + ); + assert_eq!( + serde_json::to_value(OriginTarget::End).unwrap(), + json!({ "kind": "end" }) + ); + assert_eq!( + serde_json::to_value(OriginTarget::Source { + path: "src/main.rs".to_owned(), + line: 42, + column: Some(7), + }) + .unwrap(), + json!({ "kind": "source", "path": "src/main.rs", "line": 42, "column": 7 }) + ); + } + + #[test] + fn find_origin_request_exists_with_public_fields_trace_id_language_at_watch_timeout_secs_max_depth_and_max_children() + { + let request: FindOriginRequest = serde_json::from_value(json!({ + "trace_id": "trace-origin-1", + "language": "rust", + "at": { "kind": "source", "path": "src/main.rs", "line": 42, "column": null }, + "watch": "answer", + "timeout_secs": 3, + "max_depth": 2, + "max_children": 4 + })) + .unwrap(); + + assert_eq!(request.trace_id, trace_id()); + assert_eq!(request.language, "rust"); + assert_eq!( + request.at, + OriginTarget::Source { + path: "src/main.rs".to_owned(), + line: 42, + column: None, + } + ); + assert_eq!(request.watch, "answer"); + assert_eq!(request.timeout_secs, Some(3)); + assert_eq!(request.max_depth, Some(2)); + assert_eq!(request.max_children, Some(4)); + } + + #[test] + fn find_origin_result_exists_with_public_fields_found_reason_trace_id_write_frame_stack_value_locals_args_output_truncated_and_error() + { + let frame = write_frame(); + let variable = captured_variable("answer", "42", false); + let result = FindOriginResult { + found: true, + reason: None, + trace_id: Some(trace_id()), + write_frame: Some(frame.clone()), + stack: vec![frame], + value: Some(variable.clone()), + locals: vec![variable.clone()], + args: vec![captured_variable("argv", "[]", false)], + output: vec![debug_output(1, "stdout", "hit\n")], + truncated: true, + error: None, + }; + + assert_eq!( + serde_json::to_value(result).unwrap(), + json!({ + "found": true, + "reason": null, + "trace_id": "trace-origin-1", + "write_frame": { + "id": 10, + "name": "write_answer", + "path": "src/main.rs", + "line": 42, + "column": 7 + }, + "stack": [{ + "id": 10, + "name": "write_answer", + "path": "src/main.rs", + "line": 42, + "column": 7 + }], + "value": { + "name": "answer", + "value": "42", + "type": "i32", + "children": [], + "truncated": false + }, + "locals": [{ + "name": "answer", + "value": "42", + "type": "i32", + "children": [], + "truncated": false + }], + "args": [{ + "name": "argv", + "value": "[]", + "type": "Vec", + "children": [], + "truncated": false + }], + "output": [{ "sequence": 1, "category": "stdout", "text": "hit\n" }], + "truncated": true, + "error": null + }) + ); + } + + #[test] + fn origin_failure_code_exists_with_exact_serde_string_values() { + let cases = [ + ( + OriginFailureCode::NoPriorWriteReached, + "no_prior_write_reached", + ), + ( + OriginFailureCode::WatchEvaluationFailed, + "watch_evaluation_failed", + ), + (OriginFailureCode::TraceNotFound, "trace_not_found"), + (OriginFailureCode::ReplayOpenFailed, "replay_open_failed"), + (OriginFailureCode::ReverseUnsupported, "reverse_unsupported"), + (OriginFailureCode::OriginTimeout, "origin_timeout"), + (OriginFailureCode::RecordFailed, "record_failed"), + (OriginFailureCode::CaptureFailed, "capture_failed"), + ]; + + for (code, expected) in cases { + assert_eq!(serde_json::to_value(&code).unwrap(), json!(expected)); + assert_eq!( + serde_json::from_value::(json!(expected)).unwrap(), + code + ); + } + } + + #[test] + fn find_origin_result_returns_found_true_reason_none_error_none_populated_write_frame_stack_watched_value_output_and_truncation_markers_when_last_write_is_found() + { + let factory = Arc::new(FakeOriginFactory::new( + FakeOriginScenario::last_write_found(), + )); + let sessions = SessionManager::new(debug_config(), factory.clone()); + + let result = find_origin(base_find_request(), &sessions); + + assert!(result.found); + assert_eq!(result.reason, None); + assert_eq!(result.error, None); + assert_eq!(result.trace_id, Some(trace_id())); + assert_eq!(result.write_frame, Some(write_frame())); + assert_eq!(result.stack, vec![write_frame(), caller_frame()]); + assert_eq!(result.value, Some(captured_variable("answer", "42", true))); + assert_eq!( + result.output, + vec![debug_output(1, "stdout", "watch hit\n")] + ); + assert!(result.truncated); + assert_eq!( + factory.calls(), + vec![ + FakeOriginCall::Initialize, + FakeOriginCall::Launch, + FakeOriginCall::Stack(1), + FakeOriginCall::SeekReplay(ReplaySeekTarget::Crash), + FakeOriginCall::SetWatchpoint(WatchpointSpec { + expression: Some("answer".to_owned()), + address: None, + kind: WatchpointKind::Write, + enabled: true, + }), + FakeOriginCall::ReverseContinue(None), + FakeOriginCall::Stack(1), + FakeOriginCall::Scopes(10), + FakeOriginCall::Variables(100), + FakeOriginCall::Variables(200), + FakeOriginCall::Evaluate(10, "answer".to_owned()), + FakeOriginCall::Terminate, + ] + ); + assert!(sessions.sessions().is_empty()); + } + + #[test] + fn find_origin_result_returns_found_false_no_prior_write_reached_serialized_when_reverse_execution_reaches_trace_start_without_a_write() + { + let factory = Arc::new(FakeOriginFactory::new(FakeOriginScenario::no_prior_write())); + let sessions = SessionManager::new(debug_config(), factory.clone()); + + let result = find_origin(base_find_request(), &sessions); + + assert!(!result.found); + assert_eq!(result.reason, Some(OriginFailureCode::NoPriorWriteReached)); + assert_eq!(result.error, None); + assert_eq!( + serde_json::to_value(&result).unwrap()["reason"], + "no_prior_write_reached" + ); + assert!(factory.calls().contains(&FakeOriginCall::Terminate)); + assert!(sessions.sessions().is_empty()); + } + + #[test] + fn watch_expression_evaluation_failure_returns_find_origin_result_found_false_watch_evaluation_failed_runtime_failure_not_panic_or_transport_error() + { + let factory = Arc::new(FakeOriginFactory::new( + FakeOriginScenario::watch_evaluation_failed(), + )); + let sessions = SessionManager::new(debug_config(), factory.clone()); + + let result = find_origin(base_find_request(), &sessions); + + assert!(!result.found); + assert_eq!( + result.reason, + Some(OriginFailureCode::WatchEvaluationFailed) + ); + assert_eq!( + result.error.as_ref().map(|error| error.code.as_str()), + Some("watch_evaluation_failed") + ); + assert!(factory.calls().contains(&FakeOriginCall::Terminate)); + } + + #[test] + fn replay_open_failure_returns_replay_open_failed_instead_of_trace_not_found() { + let sessions = SessionManager::new( + debug_config(), + Arc::new(FakeOriginFactory::new( + FakeOriginScenario::last_write_found(), + )), + ); + let result = find_origin( + FindOriginRequest { + language: "missing-language".to_owned(), + ..base_find_request() + }, + &sessions, + ); + + assert!(!result.found); + assert_eq!(result.reason, Some(OriginFailureCode::ReplayOpenFailed)); + assert_eq!( + result.error.as_ref().map(|error| error.code.as_str()), + Some("replay_open_failed") + ); + } + + #[test] + fn origin_target_maps_to_replay_seek_target_exactly_crash_end_and_source_path_line_column() { + assert_eq!( + ReplaySeekTarget::from(OriginTarget::Crash), + ReplaySeekTarget::Crash + ); + assert_eq!( + ReplaySeekTarget::from(OriginTarget::End), + ReplaySeekTarget::End + ); + assert_eq!( + ReplaySeekTarget::from(OriginTarget::Source { + path: "src/main.rs".to_owned(), + line: 42, + column: Some(7), + }), + ReplaySeekTarget::Source { + path: "src/main.rs".to_owned(), + line: 42, + column: Some(7), + } + ); + } + + #[test] + fn session_manager_seek_replay_is_called_before_setting_the_watchpoint_and_seek_timeout_maps_to_origin_timeout_runtime_failure_code() + { + let factory = Arc::new(FakeOriginFactory::new(FakeOriginScenario::seek_timeout())); + let sessions = SessionManager::new(debug_config(), factory.clone()); + + let result = find_origin(base_find_request(), &sessions); + + assert!(!result.found); + assert_eq!(result.reason, Some(OriginFailureCode::OriginTimeout)); + assert_eq!( + result.error.as_ref().map(|error| error.code.as_str()), + Some("origin_timeout") + ); + assert_eq!( + factory.calls(), + vec![ + FakeOriginCall::Initialize, + FakeOriginCall::Launch, + FakeOriginCall::Stack(1), + FakeOriginCall::SeekReplay(ReplaySeekTarget::Crash), + FakeOriginCall::Terminate, + ] + ); + } + + #[test] + fn trace_ids_containing_fixture_words_still_drive_replay_instead_of_fabricating_origin_results() + { + let factory = Arc::new(FakeOriginFactory::new( + FakeOriginScenario::last_write_found(), + )); + let sessions = SessionManager::new(debug_config(), factory.clone()); + let mut request = base_find_request(); + request.trace_id = TraceId::new("real-nopriorwrite-adapterexited-trace").unwrap(); + + let result = find_origin(request, &sessions); + + assert!(result.found); + assert_eq!( + factory.calls(), + vec![ + FakeOriginCall::Initialize, + FakeOriginCall::Launch, + FakeOriginCall::Stack(1), + FakeOriginCall::SeekReplay(ReplaySeekTarget::Crash), + FakeOriginCall::SetWatchpoint(WatchpointSpec { + expression: Some("answer".to_owned()), + address: None, + kind: WatchpointKind::Write, + enabled: true, + }), + FakeOriginCall::ReverseContinue(None), + FakeOriginCall::Stack(1), + FakeOriginCall::Scopes(10), + FakeOriginCall::Variables(100), + FakeOriginCall::Variables(200), + FakeOriginCall::Evaluate(10, "answer".to_owned()), + FakeOriginCall::Terminate, + ] + ); + } + + #[test] + fn origin_failure_code_mapping_is_exact_for_reverse_unsupported_no_prior_write_timeout_watch_evaluation_capture_and_record_failed() + { + let reverse = find_origin( + base_find_request(), + &SessionManager::new( + debug_config(), + Arc::new(FakeOriginFactory::new( + FakeOriginScenario::reverse_unsupported(), + )), + ), + ); + assert_eq!(reverse.reason, Some(OriginFailureCode::ReverseUnsupported)); + assert_eq!( + reverse.error.as_ref().map(|error| error.code.as_str()), + Some("reverse_unsupported") + ); + + let capture = find_origin( + base_find_request(), + &SessionManager::new( + debug_config(), + Arc::new(FakeOriginFactory::new(FakeOriginScenario::capture_failed())), + ), + ); + assert_eq!(capture.reason, Some(OriginFailureCode::CaptureFailed)); + assert_eq!( + capture.error.as_ref().map(|error| error.code.as_str()), + Some("capture_failed") + ); + + let mut runtime = rr_runtime(record_failed_result()); + let combined = + record_and_find_origin(base_record_and_find_request(), &manager(), &mut runtime); + assert_eq!( + combined + .origin + .as_ref() + .and_then(|origin| origin.error.as_ref()) + .map(|error| error.code.as_str()), + Some("record_failed") + ); + } + + #[test] + fn for_every_mapped_failure_find_origin_result_error_code_equals_serialized_origin_failure_code_except_no_prior_write_reached_has_no_error() + { + let cases = [ + ( + FakeOriginScenario::seek_timeout(), + OriginFailureCode::OriginTimeout, + Some("origin_timeout"), + ), + ( + FakeOriginScenario::reverse_unsupported(), + OriginFailureCode::ReverseUnsupported, + Some("reverse_unsupported"), + ), + ( + FakeOriginScenario::watch_evaluation_failed(), + OriginFailureCode::WatchEvaluationFailed, + Some("watch_evaluation_failed"), + ), + ( + FakeOriginScenario::capture_failed(), + OriginFailureCode::CaptureFailed, + Some("capture_failed"), + ), + ( + FakeOriginScenario::no_prior_write(), + OriginFailureCode::NoPriorWriteReached, + None, + ), + ]; + + for (scenario, expected_reason, expected_error_code) in cases { + let result = find_origin( + base_find_request(), + &SessionManager::new(debug_config(), Arc::new(FakeOriginFactory::new(scenario))), + ); + assert_eq!(result.reason, Some(expected_reason)); + assert_eq!( + result.error.as_ref().map(|error| error.code.as_str()), + expected_error_code + ); + } + } + + #[test] + fn cleanup_failures_do_not_replace_the_primary_origin_failure_code_and_are_included_in_runtime_failure_data_cleanup_error() + { + let factory = Arc::new(FakeOriginFactory::new( + FakeOriginScenario::watch_evaluation_failed_with_cleanup_error(), + )); + let sessions = SessionManager::new(debug_config(), factory.clone()); + + let result = find_origin(base_find_request(), &sessions); + + assert_eq!( + result.reason, + Some(OriginFailureCode::WatchEvaluationFailed) + ); + let error = result + .error + .expect("primary failure should include runtime error"); + assert_eq!(error.code, "watch_evaluation_failed"); + assert_eq!( + error + .data + .as_ref() + .and_then(|data| data.get("cleanup_error")), + Some(&json!("fake terminate failed")) + ); + assert!(factory.calls().contains(&FakeOriginCall::Terminate)); + } + + #[test] + fn find_origin_tears_down_replay_sessions_on_every_success_and_failure_branch() { + for scenario in [ + FakeOriginScenario::last_write_found(), + FakeOriginScenario::no_prior_write(), + FakeOriginScenario::seek_timeout(), + FakeOriginScenario::watch_evaluation_failed(), + FakeOriginScenario::capture_failed(), + ] { + let factory = Arc::new(FakeOriginFactory::new(scenario)); + let sessions = SessionManager::new(debug_config(), factory.clone()); + + let _ = find_origin(base_find_request(), &sessions); + + assert!(factory.calls().contains(&FakeOriginCall::Terminate)); + assert!(sessions.sessions().is_empty()); + } + } + + #[test] + fn record_and_find_origin_request_exists_with_public_record_and_origin_fields_and_record_origin_params_include_language_at_watch_timeout_secs_max_depth_max_children_but_no_trace_id() + { + let request: RecordAndFindOriginRequest = serde_json::from_value(json!({ + "record": { + "language": "rust", + "program": "target/debug/app", + "args": ["--case", "crash"], + "cwd": "/workspace", + "env": { "RUST_BACKTRACE": "1" }, + "timeout_ms": 1000 + }, + "origin": { + "language": "rust", + "at": { "kind": "end" }, + "watch": "answer", + "timeout_secs": 3, + "max_depth": 2, + "max_children": 4 + } + })) + .unwrap(); + + assert_eq!(request.record.language, "rust"); + assert_eq!(request.record.program, "target/debug/app"); + assert_eq!(request.origin.language, "rust"); + assert_eq!(request.origin.at, OriginTarget::End); + assert_eq!(request.origin.watch, "answer"); + assert_eq!(request.origin.timeout_secs, Some(3)); + assert_eq!(request.origin.max_depth, Some(2)); + assert_eq!(request.origin.max_children, Some(4)); + assert!( + serde_json::from_value::(json!({ + "language": "rust", + "trace_id": "trace-origin-1", + "at": { "kind": "end" }, + "watch": "answer", + "timeout_secs": 3, + "max_depth": 2, + "max_children": 4 + })) + .is_err() + ); + } + + #[test] + fn record_and_find_origin_result_exists_with_public_fields_record_and_origin() { + let result = super::RecordAndFindOriginResult { + record: successful_record_result(), + origin: Some(successful_origin_result()), + }; + + let value = serde_json::to_value(result).unwrap(); + + assert_eq!(value["record"]["recordable"], true); + assert_eq!(value["record"]["trace_id"], "trace-origin-1"); + assert_eq!(value["origin"]["found"], true); + } + + #[test] + fn record_and_find_origin_returns_record_and_origin_some_found_true_when_both_steps_succeed() { + let factory = Arc::new(FakeOriginFactory::new( + FakeOriginScenario::last_write_found(), + )); + let sessions = SessionManager::new(debug_config(), factory); + let mut runtime = rr_runtime(successful_record_result()); + + let result = + record_and_find_origin(base_record_and_find_request(), &sessions, &mut runtime); + + assert!(result.record.recordable); + assert_eq!(result.record.trace_id, Some(trace_id())); + let origin = result + .origin + .expect("origin should run after successful record"); + assert!(origin.found); + assert_eq!(origin.reason, None); + } + + #[test] + fn record_and_find_origin_returns_record_and_origin_some_found_false_when_recording_succeeds_but_origin_finding_fails() + { + let factory = Arc::new(FakeOriginFactory::new(FakeOriginScenario::no_prior_write())); + let sessions = SessionManager::new(debug_config(), factory); + let mut runtime = rr_runtime(successful_record_result()); + + let result = + record_and_find_origin(base_record_and_find_request(), &sessions, &mut runtime); + + assert!(result.record.recordable); + let origin = result + .origin + .expect("origin failure should still be returned"); + assert!(!origin.found); + assert_eq!(origin.reason, Some(OriginFailureCode::NoPriorWriteReached)); + } + + #[test] + fn record_and_find_origin_returns_record_unchanged_and_origin_none_when_rr_preflight_or_recording_is_unsupported() + { + let record = unsupported_record_result(); + let mut runtime = rr_runtime(record.clone()); + + let result = + record_and_find_origin(base_record_and_find_request(), &manager(), &mut runtime); + + assert_eq!(result.record, record); + assert_eq!(result.origin, None); + } + + fn manager() -> SessionManager { + SessionManager::new( + debug_config(), + Arc::new(FakeOriginFactory::new( + FakeOriginScenario::last_write_found(), + )), + ) + } + + fn base_find_request() -> FindOriginRequest { + FindOriginRequest { + trace_id: trace_id(), + language: "rust".to_owned(), + at: OriginTarget::Crash, + watch: "answer".to_owned(), + timeout_secs: Some(3), + max_depth: Some(2), + max_children: Some(1), + } + } + + fn base_record_and_find_request() -> RecordAndFindOriginRequest { + RecordAndFindOriginRequest { + record: RecordParams { + language: "rust".to_owned(), + program: "target/debug/app".to_owned(), + args: Vec::new(), + cwd: None, + env: BTreeMap::new(), + timeout_ms: Some(1000), + }, + origin: RecordOriginParams { + language: "rust".to_owned(), + at: OriginTarget::Crash, + watch: "answer".to_owned(), + timeout_secs: Some(3), + max_depth: Some(2), + max_children: Some(1), + }, + } + } + + fn debug_config() -> DebugInitializeConfig { + DebugInitializeConfig { + languages: BTreeMap::from([( + "rust".to_owned(), + DebugAdapterConfig { + extensions: vec!["rs".to_owned()], + command: "fake-debug-adapter".to_owned(), + args: Vec::new(), + adapter_type: "fake".to_owned(), + launch: Map::new(), + default_timeout_secs: 1, + idle_ttl_secs: 60, + }, + )]), + record: Some(DebugRecordConfig { + backend: "rr".to_owned(), + trace_dir: Some("target/origin-test-traces".to_owned()), + ttl_secs: Some(60), + max_traces: Some(20), + record_timeout_secs: Some(60), + }), + } + } + + fn rr_runtime(record_result: RrRecordResult) -> RrRuntime { + RrRuntime::with_parts( + Box::new(FakeRrPreflight::new(RrPreflightStatus::Supported)), + Box::new(FakeRrRecorder::new(record_result)), + TraceStore::new(TracePolicy { + trace_root: std::env::temp_dir().join("tower-origin-test-traces"), + ttl_secs: Some(60), + max_traces: 20, + record_timeout_secs: 60, + }), + ) + } + + fn successful_origin_result() -> FindOriginResult { + FindOriginResult { + found: true, + reason: None, + trace_id: Some(trace_id()), + write_frame: Some(write_frame()), + stack: vec![write_frame()], + value: Some(captured_variable("answer", "42", false)), + locals: vec![captured_variable("answer", "42", false)], + args: Vec::new(), + output: vec![debug_output(1, "stdout", "watch hit\n")], + truncated: false, + error: None, + } + } + + fn successful_record_result() -> RrRecordResult { + RrRecordResult { + recordable: true, + reason: None, + trace_id: Some(trace_id()), + trace: None, + exit_code: Some(0), + output: vec![debug_output(1, "stdout", "recorded\n")], + output_truncated: false, + error: None, + } + } + + fn unsupported_record_result() -> RrRecordResult { + RrRecordResult { + recordable: false, + reason: Some("rr_unsupported".to_owned()), + trace_id: None, + trace: None, + exit_code: None, + output: Vec::new(), + output_truncated: false, + error: Some(RuntimeFailure { + code: "rr_unsupported".to_owned(), + message: "rr unsupported".to_owned(), + data: None, + }), + } + } + + fn record_failed_result() -> RrRecordResult { + RrRecordResult { + recordable: false, + reason: Some("record_failed".to_owned()), + trace_id: None, + trace: None, + exit_code: None, + output: Vec::new(), + output_truncated: false, + error: Some(RuntimeFailure { + code: "record_failed".to_owned(), + message: "record failed".to_owned(), + data: None, + }), + } + } + + fn trace_id() -> TraceId { + TraceId::new("trace-origin-1").unwrap() + } + + fn write_frame() -> DebugStackFrame { + DebugStackFrame { + id: 10, + name: "write_answer".to_owned(), + path: Some("src/main.rs".to_owned()), + line: 42, + column: 7, + } + } + + fn caller_frame() -> DebugStackFrame { + DebugStackFrame { + id: 11, + name: "caller".to_owned(), + path: Some("src/lib.rs".to_owned()), + line: 30, + column: 9, + } + } + + fn captured_variable(name: &str, value: &str, truncated: bool) -> CapturedVariable { + CapturedVariable { + name: name.to_owned(), + value: value.to_owned(), + r#type: Some(if name == "argv" { "Vec" } else { "i32" }.to_owned()), + children: if truncated { + vec![CapturedVariable { + name: "child".to_owned(), + value: "1".to_owned(), + r#type: Some("i32".to_owned()), + children: Vec::new(), + truncated: false, + }] + } else { + Vec::new() + }, + truncated, + } + } + + fn debug_output(sequence: u64, category: &str, text: &str) -> DebugOutput { + DebugOutput { + sequence, + category: Some(category.to_owned()), + text: text.to_owned(), + } + } + + #[derive(Clone)] + struct FakeOriginFactory { + scenario: FakeOriginScenario, + calls: Arc>>, + } + + impl FakeOriginFactory { + fn new(scenario: FakeOriginScenario) -> Self { + Self { + scenario, + calls: Arc::new(Mutex::new(Vec::new())), + } + } + + fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + } + + impl DebugAdapterFactory for FakeOriginFactory { + fn start( + &self, + _request: &LaunchRequest, + ) -> Result, DebugRuntimeError> { + Ok(Box::new(FakeOriginSession { + scenario: self.scenario.clone(), + calls: self.calls.clone(), + })) + } + } + + #[derive(Clone)] + struct FakeOriginScenario { + reverse_stops: Arc>>>, + seek_error: Option, + watch_error: Option, + evaluate_error: Option, + variables_error: Option, + terminate_error: Option, + } + + impl FakeOriginScenario { + fn last_write_found() -> Self { + Self::with_reverse_stop(Ok(DebugStop { + reason: Some("watchpoint".to_owned()), + output_since: vec![debug_output(1, "stdout", "watch hit\n")], + ..stopped() + })) + } + + fn no_prior_write() -> Self { + Self::with_reverse_stop(Ok(DebugStop { + state: DebugSessionState::Terminated, + reason: Some("trace_start".to_owned()), + thread_id: None, + top_frame: None, + hit_breakpoint_ids: Vec::new(), + timed_out: false, + exit_code: None, + output_since: Vec::new(), + })) + } + + fn seek_timeout() -> Self { + Self { + seek_error: Some(DebugRuntimeError::DebugTimeout( + "seek replay timed out".to_owned(), + )), + ..Self::last_write_found() + } + } + + fn reverse_unsupported() -> Self { + Self { + reverse_stops: Arc::new(Mutex::new(VecDeque::from([Err( + DebugRuntimeError::ReverseUnsupported("reverse unsupported".to_owned()), + )]))), + ..Self::last_write_found() + } + } + + fn watch_evaluation_failed() -> Self { + Self { + evaluate_error: Some(DebugRuntimeError::LaunchFailed( + "watch expression failed".to_owned(), + )), + ..Self::last_write_found() + } + } + + fn watch_evaluation_failed_with_cleanup_error() -> Self { + Self { + terminate_error: Some(DebugRuntimeError::AdapterExited( + "fake terminate failed".to_owned(), + )), + ..Self::watch_evaluation_failed() + } + } + + fn capture_failed() -> Self { + Self { + variables_error: Some(DebugRuntimeError::DebugTimeout( + "variables timed out".to_owned(), + )), + ..Self::last_write_found() + } + } + + fn with_reverse_stop(stop: Result) -> Self { + Self { + reverse_stops: Arc::new(Mutex::new(VecDeque::from([stop]))), + seek_error: None, + watch_error: None, + evaluate_error: None, + variables_error: None, + terminate_error: None, + } + } + } + + #[derive(Clone, Debug, PartialEq, Eq)] + enum FakeOriginCall { + Initialize, + Launch, + SeekReplay(ReplaySeekTarget), + SetWatchpoint(WatchpointSpec), + ReverseContinue(Option), + Stack(u64), + Scopes(u64), + Variables(u64), + Evaluate(u64, String), + Terminate, + } + + struct FakeOriginSession { + scenario: FakeOriginScenario, + calls: Arc>>, + } + + impl DebugAdapterSession for FakeOriginSession { + fn initialize(&mut self, _timeout: Duration) -> Result<(), DebugRuntimeError> { + self.calls.lock().unwrap().push(FakeOriginCall::Initialize); + Ok(()) + } + + fn launch( + &mut self, + _request: &LaunchRequest, + _timeout: Duration, + ) -> Result<(), DebugRuntimeError> { + self.calls.lock().unwrap().push(FakeOriginCall::Launch); + Ok(()) + } + + fn set_breakpoints( + &mut self, + breakpoints: &[DebugBreakpoint], + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + Ok(breakpoints.to_vec()) + } + + fn continue_session(&mut self, _timeout: Duration) -> Result { + Ok(stopped()) + } + + fn step( + &mut self, + _thread_id: Option, + _timeout: Duration, + ) -> Result { + Ok(stopped()) + } + + fn pause( + &mut self, + thread_id: Option, + _timeout: Duration, + ) -> Result { + Ok(DebugStop { + thread_id, + ..stopped() + }) + } + + fn threads(&mut self, _timeout: Duration) -> Result, DebugRuntimeError> { + Ok(vec![DebugThread { + id: 1, + name: "main".to_owned(), + }]) + } + + fn stack( + &mut self, + thread_id: u64, + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + self.calls + .lock() + .unwrap() + .push(FakeOriginCall::Stack(thread_id)); + Ok(vec![write_frame(), caller_frame()]) + } + + fn scopes( + &mut self, + frame_id: u64, + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + self.calls + .lock() + .unwrap() + .push(FakeOriginCall::Scopes(frame_id)); + Ok(vec![ + DebugScope { + name: "Locals".to_owned(), + variables_reference: 100, + expensive: false, + }, + DebugScope { + name: "Arguments".to_owned(), + variables_reference: 300, + expensive: false, + }, + ]) + } + + fn variables( + &mut self, + variables_reference: u64, + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + self.calls + .lock() + .unwrap() + .push(FakeOriginCall::Variables(variables_reference)); + if let Some(error) = &self.scenario.variables_error { + return Err(error.clone()); + } + Ok(match variables_reference { + 100 => vec![DebugVariable { + name: "answer".to_owned(), + value: "42".to_owned(), + r#type: Some("i32".to_owned()), + variables_reference: 200, + }], + 200 => vec![ + DebugVariable { + name: "child".to_owned(), + value: "1".to_owned(), + r#type: Some("i32".to_owned()), + variables_reference: 0, + }, + DebugVariable { + name: "extra".to_owned(), + value: "2".to_owned(), + r#type: Some("i32".to_owned()), + variables_reference: 0, + }, + ], + 300 => vec![DebugVariable { + name: "argv".to_owned(), + value: "[]".to_owned(), + r#type: Some("Vec".to_owned()), + variables_reference: 0, + }], + _ => Vec::new(), + }) + } + + fn evaluate( + &mut self, + frame_id: u64, + expression: &str, + _timeout: Duration, + ) -> Result { + self.calls + .lock() + .unwrap() + .push(FakeOriginCall::Evaluate(frame_id, expression.to_owned())); + if let Some(error) = &self.scenario.evaluate_error { + return Err(error.clone()); + } + Ok(DebugVariable { + name: expression.to_owned(), + value: "42".to_owned(), + r#type: Some("i32".to_owned()), + variables_reference: 200, + }) + } + + fn reverse_continue( + &mut self, + thread_id: Option, + _timeout: Duration, + ) -> Result { + self.calls + .lock() + .unwrap() + .push(FakeOriginCall::ReverseContinue(thread_id)); + self.scenario + .reverse_stops + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| Ok(stopped())) + } + + fn set_watchpoint( + &mut self, + watchpoint: WatchpointSpec, + _timeout: Duration, + ) -> Result { + self.calls + .lock() + .unwrap() + .push(FakeOriginCall::SetWatchpoint(watchpoint.clone())); + if let Some(error) = &self.scenario.watch_error { + return Err(error.clone()); + } + Ok(WatchpointResult { + watchpoint_id: "watch-1".to_owned(), + expression: watchpoint.expression, + address: watchpoint.address, + kind: watchpoint.kind, + enabled: watchpoint.enabled, + verified: true, + }) + } + + fn seek_replay( + &mut self, + target: ReplaySeekTarget, + _timeout: Duration, + ) -> Result { + self.calls + .lock() + .unwrap() + .push(FakeOriginCall::SeekReplay(target)); + if let Some(error) = &self.scenario.seek_error { + return Err(error.clone()); + } + Ok(stopped()) + } + + fn terminate(&mut self, _timeout: Duration) -> Result<(), DebugRuntimeError> { + self.calls.lock().unwrap().push(FakeOriginCall::Terminate); + if let Some(error) = &self.scenario.terminate_error { + return Err(error.clone()); + } + Ok(()) + } + + fn disconnect(&mut self, _timeout: Duration) -> Result<(), DebugRuntimeError> { + Ok(()) + } + } + + fn stopped() -> DebugStop { + DebugStop { + state: DebugSessionState::Stopped, + reason: Some("stopped".to_owned()), + thread_id: Some(1), + top_frame: Some(write_frame()), + hit_breakpoint_ids: Vec::new(), + timed_out: false, + exit_code: None, + output_since: Vec::new(), + } + } +} diff --git a/extensions/debug/src/protocol.rs b/extensions/debug/src/protocol.rs index f65d401..2bf1c0a 100644 --- a/extensions/debug/src/protocol.rs +++ b/extensions/debug/src/protocol.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::fmt; +use std::path::{Component, Path}; use extension_protocol::ToolDecl; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -11,6 +12,8 @@ use serde_json::{Map, Value}; #[serde(deny_unknown_fields)] pub struct DebugInitializeConfig { pub languages: BTreeMap, + #[serde(default)] + pub record: Option, } impl DebugInitializeConfig { @@ -26,7 +29,13 @@ impl DebugInitializeConfig { } pub fn is_empty(&self) -> bool { - self.languages.is_empty() + self.languages.is_empty() && self.record.is_none() + } + + pub fn supports_rr_record(&self) -> bool { + self.record + .as_ref() + .is_some_and(DebugRecordConfig::is_rr_backend) } fn validate(&self) -> Result<(), DebugInitError> { @@ -38,10 +47,68 @@ impl DebugInitializeConfig { } config.validate(language)?; } + if let Some(record) = &self.record { + record.validate()?; + } Ok(()) } } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DebugRecordConfig { + pub backend: String, + pub trace_dir: Option, + pub ttl_secs: Option, + pub max_traces: Option, + pub record_timeout_secs: Option, +} + +impl DebugRecordConfig { + pub fn is_rr_backend(&self) -> bool { + self.backend == "rr" + } + + fn validate(&self) -> Result<(), DebugInitError> { + if self.backend != "rr" { + return Err(DebugInitError::InvalidConfig(format!( + "debug.record.backend unsupported value {:?}", + self.backend + ))); + } + if let Some(trace_dir) = &self.trace_dir + && !is_valid_relative_trace_dir(Path::new(trace_dir)) + { + return Err(DebugInitError::InvalidConfig( + "debug.record.trace_dir is invalid".to_owned(), + )); + } + if self.ttl_secs == Some(0) { + return Err(DebugInitError::InvalidConfig( + "debug.record.ttl_secs is invalid".to_owned(), + )); + } + if self.max_traces == Some(0) { + return Err(DebugInitError::InvalidConfig( + "debug.record.max_traces is invalid".to_owned(), + )); + } + if self.record_timeout_secs == Some(0) { + return Err(DebugInitError::InvalidConfig( + "debug.record.record_timeout_secs is invalid".to_owned(), + )); + } + Ok(()) + } +} + +fn is_valid_relative_trace_dir(path: &Path) -> bool { + !path.as_os_str().is_empty() + && path + .components() + .all(|component| matches!(component, Component::Normal(_) | Component::CurDir)) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct DebugAdapterConfig { @@ -183,12 +250,20 @@ impl fmt::Display for DebugInitError { impl std::error::Error for DebugInitError {} pub fn debug_tool_declarations(config: Option<&DebugInitializeConfig>) -> Vec { - if config.is_none_or(DebugInitializeConfig::is_empty) { + let Some(config) = config else { return Vec::new(); + }; + if config.languages.is_empty() { + return Vec::new(); + } + + let mut specs = debug_tool_specs().to_vec(); + if config.supports_rr_record() { + specs.extend_from_slice(rr_debug_tool_specs()); } - debug_tool_specs() - .iter() + specs + .into_iter() .map(|spec| ToolDecl { name: spec.name.to_owned(), description: spec.description.to_owned(), @@ -223,6 +298,10 @@ fn debug_tool_specs() -> &'static [DebugToolSpec] { &DEBUG_TOOL_SPECS } +fn rr_debug_tool_specs() -> &'static [DebugToolSpec] { + &RR_DEBUG_TOOL_SPECS +} + const DEBUG_TOOL_SPECS: [DebugToolSpec; 13] = [ DebugToolSpec::new("launch", "Launch a debug session.", LAUNCH_SCHEMA), DebugToolSpec::new( @@ -255,6 +334,39 @@ const DEBUG_TOOL_SPECS: [DebugToolSpec; 13] = [ DebugToolSpec::new("sessions", "List debug sessions.", SESSIONS_SCHEMA), ]; +const RR_DEBUG_TOOL_SPECS: [DebugToolSpec; 9] = [ + DebugToolSpec::new("record", "Record a debug trace.", RR_RECORD_SCHEMA), + DebugToolSpec::new("replay", "Replay a debug trace.", RR_REPLAY_SCHEMA), + DebugToolSpec::new( + "reverse_continue", + "Continue a replay session backwards.", + RR_REVERSE_CONTINUE_SCHEMA, + ), + DebugToolSpec::new( + "step_back", + "Step a replay session backwards.", + RR_STEP_BACK_SCHEMA, + ), + DebugToolSpec::new( + "watchpoint", + "Set a replay watchpoint.", + RR_WATCHPOINT_SCHEMA, + ), + DebugToolSpec::new("traces", "List recorded debug traces.", SESSIONS_SCHEMA), + DebugToolSpec::new("delete_trace", "Delete a debug trace.", RR_TRACE_ID_SCHEMA), + DebugToolSpec::new( + "find_origin", + "Find the origin of a value in a replay session.", + RR_FIND_ORIGIN_SCHEMA, + ), + DebugToolSpec::new( + "record_and_find_origin", + "Record a trace and find a value origin.", + RR_RECORD_AND_FIND_ORIGIN_SCHEMA, + ), +]; + +#[derive(Clone, Copy)] struct DebugToolSpec { name: &'static str, description: &'static str, @@ -282,6 +394,14 @@ const VARIABLES_SCHEMA: &str = r#"{"type":"object","properties":{"session_id":{" const EVALUATE_SCHEMA: &str = r#"{"type":"object","properties":{"session_id":{"type":"string"},"frame_id":{"type":"integer","minimum":0},"expression":{"type":"string"}},"required":["session_id","frame_id","expression"],"additionalProperties":false}"#; const EVAL_AT_SCHEMA: &str = r#"{"type":"object","properties":{"lang":{"type":"string"},"program":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":["string","null"]},"env":{"type":"object","additionalProperties":{"type":"string"}},"breakpoint":{"type":["object","null"],"properties":{"path":{"type":"string"},"line":{"type":"integer","minimum":0},"condition":{"type":["string","null"]}},"required":["path","line"],"additionalProperties":false},"expressions":{"type":"array","items":{"type":"string"}},"capture":{"type":"object","properties":{"stack":{"type":"boolean"},"locals":{"type":"boolean"},"args":{"type":"boolean"}},"additionalProperties":false},"on_hit":{"type":"string","enum":["first","all"]},"max_hits":{"type":"integer","minimum":1},"max_depth":{"type":"integer","minimum":0},"max_children":{"type":"integer","minimum":0},"timeout_ms":{"type":["integer","null"],"minimum":0}},"required":["lang","program"],"additionalProperties":false}"#; const SESSIONS_SCHEMA: &str = r#"{"type":"object","properties":{},"additionalProperties":false}"#; +const RR_RECORD_SCHEMA: &str = r#"{"type":"object","properties":{"language":{"type":"string"},"program":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":["string","null"]},"env":{"type":"object","additionalProperties":{"type":"string"}},"timeout_ms":{"type":["integer","null"],"minimum":1}},"required":["language","program"],"additionalProperties":false}"#; +const RR_REPLAY_SCHEMA: &str = r#"{"type":"object","properties":{"trace_id":{"type":"string"},"language":{"type":"string"},"timeout_secs":{"type":["integer","null"],"minimum":0}},"required":["trace_id","language"],"additionalProperties":false}"#; +const RR_TRACE_ID_SCHEMA: &str = r#"{"type":"object","properties":{"trace_id":{"type":"string"}},"required":["trace_id"],"additionalProperties":false}"#; +const RR_REVERSE_CONTINUE_SCHEMA: &str = r#"{"type":"object","properties":{"session_id":{"type":"string"},"thread_id":{"type":["integer","null"],"minimum":0},"timeout_secs":{"type":["integer","null"],"minimum":0}},"required":["session_id"],"additionalProperties":false}"#; +const RR_STEP_BACK_SCHEMA: &str = r#"{"type":"object","properties":{"session_id":{"type":"string"},"thread_id":{"type":["integer","null"],"minimum":0},"granularity":{"type":["string","null"],"enum":["line","instruction","over",null]},"timeout_secs":{"type":["integer","null"],"minimum":0}},"required":["session_id"],"additionalProperties":false}"#; +const RR_WATCHPOINT_SCHEMA: &str = r#"{"type":"object","properties":{"session_id":{"type":"string"},"expression":{"type":["string","null"]},"address":{"type":["string","null"]},"kind":{"type":"string","enum":["write","read","access"]},"enabled":{"type":["boolean","null"]},"timeout_secs":{"type":["integer","null"],"minimum":0}},"required":["session_id","kind"],"additionalProperties":false}"#; +const RR_FIND_ORIGIN_SCHEMA: &str = r#"{"type":"object","properties":{"trace_id":{"type":"string"},"language":{"type":"string"},"at":{"oneOf":[{"type":"object","properties":{"kind":{"const":"crash"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"const":"end"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"const":"source"},"path":{"type":"string"},"line":{"type":"integer","minimum":0},"column":{"type":["integer","null"],"minimum":0}},"required":["kind","path","line"],"additionalProperties":false}]},"watch":{"type":"string"},"timeout_secs":{"type":["integer","null"],"minimum":0},"max_depth":{"type":["integer","null"],"minimum":0},"max_children":{"type":["integer","null"],"minimum":0}},"required":["trace_id","language","at","watch"],"additionalProperties":false}"#; +const RR_RECORD_AND_FIND_ORIGIN_SCHEMA: &str = r#"{"type":"object","properties":{"record":{"type":"object","properties":{"language":{"type":"string"},"program":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":["string","null"]},"env":{"type":"object","additionalProperties":{"type":"string"}},"timeout_ms":{"type":["integer","null"],"minimum":1}},"required":["language","program"],"additionalProperties":false},"origin":{"type":"object","properties":{"language":{"type":"string"},"at":{"oneOf":[{"type":"object","properties":{"kind":{"const":"crash"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"const":"end"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"const":"source"},"path":{"type":"string"},"line":{"type":"integer","minimum":0},"column":{"type":["integer","null"],"minimum":0}},"required":["kind","path","line"],"additionalProperties":false}]},"watch":{"type":"string"},"timeout_secs":{"type":["integer","null"],"minimum":0},"max_depth":{"type":["integer","null"],"minimum":0},"max_children":{"type":["integer","null"],"minimum":0}},"required":["language","at","watch"],"additionalProperties":false}},"required":["record","origin"],"additionalProperties":false}"#; #[cfg(test)] mod tests { @@ -289,7 +409,9 @@ mod tests { use serde_json::Value; - use super::{DebugAdapterConfig, DebugInitializeConfig, debug_tool_declarations}; + use super::{ + DebugAdapterConfig, DebugInitializeConfig, DebugRecordConfig, debug_tool_declarations, + }; #[test] fn protocol_declares_a_thirteenth_debug_tool_spec_named_exactly_eval_at_with_eval_at_request_fields() @@ -347,12 +469,286 @@ mod tests { fn debug_tool_declarations_none_and_empty_config_still_return_no_tools() { let empty_config = DebugInitializeConfig { languages: BTreeMap::new(), + record: None, }; assert!(debug_tool_declarations(None).is_empty()); assert!(debug_tool_declarations(Some(&empty_config)).is_empty()); } + #[test] + fn sidecar_side_debug_record_config_has_exact_public_serde_fields_and_preserves_raw_initialize_values() + { + let config: DebugRecordConfig = serde_json::from_value(serde_json::json!({ + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30 + })) + .expect("record config deserializes"); + + assert_eq!(config.backend, "rr"); + assert_eq!(config.trace_dir.as_deref(), Some(".tower/traces")); + assert_eq!(config.ttl_secs, Some(86_400)); + assert_eq!(config.max_traces, Some(25)); + assert_eq!(config.record_timeout_secs, Some(30)); + assert_eq!( + serde_json::to_value(&config).expect("record config serializes"), + serde_json::json!({ + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30 + }) + ); + } + + #[test] + fn debug_initialize_config_serializes_and_deserializes_record_through_extension_config_without_dropping_language_settings() + { + let parsed = DebugInitializeConfig::from_init_payload(Some(serde_json::json!({ + "languages": { + "rust": { + "extensions": ["rs"], + "command": "lldb-dap", + "args": ["--quiet"], + "adapter_type": "lldb", + "launch": { "request": "launch" }, + "default_timeout_secs": 15, + "idle_ttl_secs": 300 + } + }, + "record": { + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30 + } + }))) + .expect("initialize payload parses") + .expect("present payload yields config"); + + assert!(parsed.languages.contains_key("rust")); + assert_eq!( + parsed.record, + Some(DebugRecordConfig { + backend: "rr".to_owned(), + trace_dir: Some(".tower/traces".to_owned()), + ttl_secs: Some(86_400), + max_traces: Some(25), + record_timeout_secs: Some(30), + }) + ); + assert_eq!( + serde_json::to_value(&parsed).expect("initialize config serializes"), + serde_json::json!({ + "languages": { + "rust": { + "extensions": ["rs"], + "command": "lldb-dap", + "args": ["--quiet"], + "adapter_type": "lldb", + "launch": { "request": "launch" }, + "default_timeout_secs": 15, + "idle_ttl_secs": 300 + } + }, + "record": { + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30 + } + }) + ); + } + + #[test] + fn rr_record_support_requires_record_backend_rr_in_initialize_config() { + let mut config = debug_config(); + assert!(!config.supports_rr_record()); + + config.record = Some(DebugRecordConfig { + backend: "gdb".to_owned(), + trace_dir: None, + ttl_secs: None, + max_traces: None, + record_timeout_secs: None, + }); + assert!(!config.supports_rr_record()); + + config.record = Some(DebugRecordConfig { + backend: "rr".to_owned(), + trace_dir: None, + ttl_secs: None, + max_traces: None, + record_timeout_secs: None, + }); + assert!(config.supports_rr_record()); + } + + #[test] + fn debug_tool_declarations_append_rr_specific_tools_only_when_record_backend_rr_is_configured() + { + let mut config = debug_config(); + config.record = Some(DebugRecordConfig { + backend: "rr".to_owned(), + trace_dir: Some(".tower/traces".to_owned()), + ttl_secs: Some(86_400), + max_traces: Some(25), + record_timeout_secs: Some(30), + }); + + let declarations = debug_tool_declarations(Some(&config)); + let names = declarations + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + + assert_eq!( + &names[13..], + &[ + "record", + "replay", + "reverse_continue", + "step_back", + "watchpoint", + "traces", + "delete_trace", + "find_origin", + "record_and_find_origin" + ] + ); + assert_eq!(declarations.len(), 22); + for tool in declarations.iter().skip(13) { + serde_json::from_str::(&tool.schema_json) + .unwrap_or_else(|error| panic!("{} schema must be valid JSON: {error}", tool.name)); + } + } + + #[test] + fn rr_origin_tool_schemas_match_strict_origin_request_dtos() { + let mut config = debug_config(); + config.record = Some(DebugRecordConfig { + backend: "rr".to_owned(), + trace_dir: Some(".tower/traces".to_owned()), + ttl_secs: Some(86_400), + max_traces: Some(25), + record_timeout_secs: Some(30), + }); + + let declarations = debug_tool_declarations(Some(&config)); + let find_origin = declarations + .iter() + .find(|tool| tool.name == "find_origin") + .expect("find_origin schema is declared"); + let record_and_find = declarations + .iter() + .find(|tool| tool.name == "record_and_find_origin") + .expect("record_and_find_origin schema is declared"); + let find_schema: Value = + serde_json::from_str(&find_origin.schema_json).expect("find_origin schema is JSON"); + let record_and_find_schema: Value = serde_json::from_str(&record_and_find.schema_json) + .expect("record_and_find_origin schema is JSON"); + + assert!(find_schema["properties"]["at"]["oneOf"].is_array()); + assert!(record_and_find_schema["properties"]["origin"].is_object()); + assert!( + record_and_find_schema["properties"]["origin"]["properties"]["at"]["oneOf"].is_array() + ); + + assert_eq!( + find_schema["required"], + serde_json::json!(["trace_id", "language", "at", "watch"]) + ); + assert_eq!( + record_and_find_schema["required"], + serde_json::json!(["record", "origin"]) + ); + } + + #[test] + fn debug_initialize_config_rejects_malformed_record_config_in_initialize_payload() { + for (payload, expected_message) in [ + ( + serde_json::json!({ + "languages": {}, + "record": { + "backend": "gdb", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30 + } + }), + "debug.record.backend", + ), + ( + serde_json::json!({ + "languages": {}, + "record": { + "backend": "rr", + "trace_dir": "../traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 30 + } + }), + "debug.record.trace_dir", + ), + ( + serde_json::json!({ + "languages": {}, + "record": { + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 0, + "max_traces": 25, + "record_timeout_secs": 30 + } + }), + "debug.record.ttl_secs", + ), + ( + serde_json::json!({ + "languages": {}, + "record": { + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 0, + "record_timeout_secs": 30 + } + }), + "debug.record.max_traces", + ), + ( + serde_json::json!({ + "languages": {}, + "record": { + "backend": "rr", + "trace_dir": ".tower/traces", + "ttl_secs": 86400, + "max_traces": 25, + "record_timeout_secs": 0 + } + }), + "debug.record.record_timeout_secs", + ), + ] { + let error = DebugInitializeConfig::from_init_payload(Some(payload)) + .expect_err("malformed record config must fail closed"); + assert!( + error.jsonrpc_message().contains(expected_message), + "expected {expected_message} in error message; got {error}" + ); + } + } + fn debug_config() -> DebugInitializeConfig { DebugInitializeConfig { languages: BTreeMap::from([( @@ -367,6 +763,7 @@ mod tests { idle_ttl_secs: 60, }, )]), + record: None, } } } diff --git a/extensions/debug/src/rr.rs b/extensions/debug/src/rr.rs new file mode 100644 index 0000000..59c1ae2 --- /dev/null +++ b/extensions/debug/src/rr.rs @@ -0,0 +1,1706 @@ +#![forbid(unsafe_code)] + +use std::collections::BTreeMap; +use std::io::Read; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, mpsc}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::traces::{TraceId, TraceMetadata, TracePolicy, TraceStore}; +use crate::types::{DebugOutput, RuntimeFailure}; + +pub const RR_OUTPUT_MAX_BYTES: usize = 65_536; +pub const RR_UNSUPPORTED: &str = "rr_unsupported"; +pub const RECORD_TIMEOUT: &str = "record_timeout"; +pub const RECORD_FAILED: &str = "record_failed"; + +pub trait RrPreflight { + fn check(&self) -> RrPreflightStatus; +} + +#[derive(Clone, Debug, Default)] +pub struct RealRrPreflight; + +impl RealRrPreflight { + pub fn new() -> Self { + Self + } +} + +impl RrPreflight for RealRrPreflight { + fn check(&self) -> RrPreflightStatus { + if !cfg!(target_os = "linux") { + return RrPreflightStatus::Unsupported { + reason: RrUnsupportedReason::NonLinuxHost, + message: "rr recording is supported only on Linux hosts".to_owned(), + }; + } + + match Command::new("rr") + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Ok(status) if status.success() => RrPreflightStatus::Supported, + Ok(_) => RrPreflightStatus::Unsupported { + reason: RrUnsupportedReason::RrUnsupported, + message: "rr preflight command reported unsupported host configuration".to_owned(), + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + RrPreflightStatus::Unsupported { + reason: RrUnsupportedReason::RrMissing, + message: "rr binary is not available on PATH".to_owned(), + } + } + Err(_) => RrPreflightStatus::Unsupported { + reason: RrUnsupportedReason::RrUnsupported, + message: "rr preflight command could not verify host support".to_owned(), + }, + } + } +} + +#[derive(Clone, Debug)] +pub struct FakeRrPreflight { + status: RrPreflightStatus, +} + +impl FakeRrPreflight { + pub fn new(status: RrPreflightStatus) -> Self { + Self { status } + } +} + +impl RrPreflight for FakeRrPreflight { + fn check(&self) -> RrPreflightStatus { + self.status.clone() + } +} + +pub trait RrRecorder { + fn record(&self, request: RrRecordRequest, store: &mut TraceStore) -> RrRecordResult; +} + +#[derive(Clone, Debug, Default)] +pub struct RealRrRecorder; + +impl RealRrRecorder { + pub fn new() -> Self { + Self + } +} + +impl RrRecorder for RealRrRecorder { + fn record(&self, request: RrRecordRequest, store: &mut TraceStore) -> RrRecordResult { + if request + .trace_policy + .trace_root + .metadata() + .is_ok_and(|metadata| !metadata.is_dir()) + { + return record_failed("trace_register", "trace root is not a directory"); + } + + let now_unix_secs = current_unix_secs(); + let allocation = match store.allocate_trace(&request.program, now_unix_secs) { + Ok(allocation) => allocation, + Err(_) => return record_failed("trace_register", "trace allocation failed"), + }; + + let mut command = Command::new("rr"); + command + .arg("record") + .arg("-o") + .arg(&allocation.path) + .arg("--") + .arg(&request.program) + .args(&request.args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + apply_request_env(&mut command, &request.env); + if let Some(cwd) = &request.cwd { + command.current_dir(cwd); + } + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let _ = store.abort_trace(&allocation); + return rr_unsupported( + RrUnsupportedReason::RrMissing, + "rr binary is not available on PATH", + ); + } + Err(_) => { + let _ = store.abort_trace(&allocation); + return record_failed("spawn", "failed to spawn rr record"); + } + }; + + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + terminate_child(&mut child); + let _ = store.abort_trace(&allocation); + return record_failed("output", "rr stdout was unavailable"); + } + }; + let stderr = match child.stderr.take() { + Some(stderr) => stderr, + None => { + terminate_child(&mut child); + let _ = store.abort_trace(&allocation); + return record_failed("output", "rr stderr was unavailable"); + } + }; + + let output_capture = OutputCapture::new(); + let stdout_reader = output_capture.spawn_reader("stdout", stdout); + let stderr_reader = output_capture.spawn_reader("stderr", stderr); + + let status = match wait_with_timeout( + &mut child, + Duration::from_millis(request.effective_timeout_ms()), + ) { + WaitOutcome::Exited(status) => status, + WaitOutcome::TimedOut => { + terminate_child(&mut child); + let _ = store.abort_trace(&allocation); + return record_timeout(request.effective_timeout_ms()); + } + WaitOutcome::WaitFailed => { + terminate_child(&mut child); + let _ = store.abort_trace(&allocation); + return record_failed("wait", "failed while waiting for rr record"); + } + }; + + if let Err(_error) = collect_reader(stdout_reader) { + let _ = store.abort_trace(&allocation); + return record_failed("output", "failed to capture rr stdout"); + } + if let Err(_error) = collect_reader(stderr_reader) { + let _ = store.abort_trace(&allocation); + return record_failed("output", "failed to capture rr stderr"); + } + + let (chunks, readers_truncated) = match output_capture.finish() { + Ok(output) => output, + Err(_) => { + let _ = store.abort_trace(&allocation); + return record_failed("output", "failed to capture rr stdout"); + } + }; + + if status.code().is_none() { + let _ = store.abort_trace(&allocation); + return record_failed("wait", "rr record exited without a process status code"); + } + if looks_like_rr_output_failure(&chunks, status.code()) { + let _ = store.abort_trace(&allocation); + return record_failed("output", "rr record output was malformed"); + } + + let (output, output_truncated) = bounded_output_events(chunks, readers_truncated); + let output_summary = output + .iter() + .map(|output| output.text.clone()) + .collect::>(); + let exit_code = status.code().map(i64::from); + let trace = match store.register_completed( + allocation.clone(), + crate::traces::TraceCompletion { + program: request.program.clone(), + args_summary: request.args.clone(), + exit_code, + output_summary, + output_truncated, + }, + now_unix_secs, + ) { + Ok(trace) => trace, + Err(_) => { + let _ = store.abort_trace(&allocation); + return record_failed("trace_register", "trace registration failed"); + } + }; + + if store.prune(now_unix_secs).is_err() { + return record_failed("trace_register", "trace pruning failed"); + } + + RrRecordResult { + recordable: true, + reason: None, + trace_id: Some(trace.trace_id.clone()), + trace: Some(trace), + exit_code, + output, + output_truncated, + error: None, + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct FixtureRrRecorder; + +impl FixtureRrRecorder { + pub fn new() -> Self { + Self + } +} + +impl RrRecorder for FixtureRrRecorder { + fn record(&self, request: RrRecordRequest, store: &mut TraceStore) -> RrRecordResult { + record_fixture_scenario(request, store) + } +} + +#[derive(Clone, Debug)] +pub struct FakeRrRecorder { + result: RrRecordResult, +} + +impl FakeRrRecorder { + pub fn new(result: RrRecordResult) -> Self { + Self { result } + } +} + +impl RrRecorder for FakeRrRecorder { + fn record(&self, _request: RrRecordRequest, _store: &mut TraceStore) -> RrRecordResult { + self.result.clone() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RrPreflightStatus { + Supported, + Unsupported { + reason: RrUnsupportedReason, + message: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RrUnsupportedReason { + RrMissing, + NonLinuxHost, + UnsupportedCpu, + UnsupportedPerfCounters, + RrUnsupported, +} + +impl RrUnsupportedReason { + pub fn as_str(&self) -> &'static str { + match self { + Self::RrMissing => "rr_missing", + Self::NonLinuxHost => "non_linux_host", + Self::UnsupportedCpu => "unsupported_cpu", + Self::UnsupportedPerfCounters => "unsupported_perf_counters", + Self::RrUnsupported => RR_UNSUPPORTED, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RrRecordRequest { + pub language: String, + pub program: String, + pub args: Vec, + pub cwd: Option, + pub env: BTreeMap, + pub timeout_ms: Option, + pub trace_policy: TracePolicy, +} + +impl RrRecordRequest { + pub fn effective_timeout_ms(&self) -> u64 { + self.timeout_ms + .unwrap_or(self.trace_policy.record_timeout_secs * 1000) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RrRecordResult { + pub recordable: bool, + pub reason: Option, + pub trace_id: Option, + pub trace: Option, + pub exit_code: Option, + pub output: Vec, + pub output_truncated: bool, + pub error: Option, +} + +pub struct RrRuntime { + pub preflight: Box, + pub recorder: Box, + pub store: TraceStore, +} + +impl RrRuntime { + pub fn new(store: TraceStore) -> Self { + Self { + preflight: Box::new(RealRrPreflight::new()), + recorder: Box::new(RealRrRecorder::new()), + store, + } + } + + pub fn new_fixture(store: TraceStore) -> Self { + Self { + preflight: Box::new(FakeRrPreflight::new(RrPreflightStatus::Supported)), + recorder: Box::new(FixtureRrRecorder::new()), + store, + } + } + + pub fn with_parts( + preflight: Box, + recorder: Box, + store: TraceStore, + ) -> Self { + Self { + preflight, + recorder, + store, + } + } + + pub fn record(&mut self, request: RrRecordRequest) -> RrRecordResult { + record_with_preflight( + self.preflight.as_ref(), + self.recorder.as_ref(), + request, + &mut self.store, + ) + } +} + +enum WaitOutcome { + Exited(std::process::ExitStatus), + TimedOut, + WaitFailed, +} + +#[derive(Debug)] +struct CapturedChunk { + sequence: u64, + category: &'static str, + bytes: Vec, +} + +struct OutputCapture { + receiver: mpsc::Receiver, + sender: Option>, + next_sequence: Arc, + remaining_bytes: Arc, + truncated: Arc, +} + +impl OutputCapture { + fn new() -> Self { + let (sender, receiver) = mpsc::channel(); + Self { + receiver, + sender: Some(sender), + next_sequence: Arc::new(AtomicU64::new(1)), + remaining_bytes: Arc::new(AtomicUsize::new(RR_OUTPUT_MAX_BYTES)), + truncated: Arc::new(AtomicBool::new(false)), + } + } + + fn spawn_reader( + &self, + category: &'static str, + mut reader: R, + ) -> std::thread::JoinHandle> + where + R: Read + Send + 'static, + { + let sender = self + .sender + .as_ref() + .expect("output capture sender must exist while spawning readers") + .clone(); + let next_sequence = Arc::clone(&self.next_sequence); + let remaining_bytes = Arc::clone(&self.remaining_bytes); + let truncated = Arc::clone(&self.truncated); + + std::thread::spawn(move || { + let mut buffer = [0_u8; 8192]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + + let allowed = reserve_output_bytes(&remaining_bytes, read); + if allowed < read { + truncated.store(true, Ordering::Relaxed); + } + if allowed == 0 { + continue; + } + + let sequence = next_sequence.fetch_add(1, Ordering::Relaxed); + if sender + .send(CapturedChunk { + sequence, + category, + bytes: buffer[..allowed].to_vec(), + }) + .is_err() + { + break; + } + } + Ok(()) + }) + } + + fn finish(mut self) -> std::io::Result<(Vec, bool)> { + drop(self.sender.take()); + let mut chunks = self.receiver.into_iter().collect::>(); + chunks.sort_by_key(|chunk| chunk.sequence); + Ok((chunks, self.truncated.load(Ordering::Relaxed))) + } +} + +fn reserve_output_bytes(remaining_bytes: &AtomicUsize, requested: usize) -> usize { + let mut current = remaining_bytes.load(Ordering::Relaxed); + loop { + if current == 0 { + return 0; + } + let allowed = current.min(requested); + match remaining_bytes.compare_exchange_weak( + current, + current - allowed, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => return allowed, + Err(actual) => current = actual, + } + } +} + +pub fn record_with_preflight( + preflight: &dyn RrPreflight, + recorder: &dyn RrRecorder, + request: RrRecordRequest, + store: &mut TraceStore, +) -> RrRecordResult { + match preflight.check() { + RrPreflightStatus::Supported => recorder.record(request, store), + RrPreflightStatus::Unsupported { reason, message } => rr_unsupported(reason, &message), + } +} + +fn record_fixture_scenario(request: RrRecordRequest, store: &mut TraceStore) -> RrRecordResult { + if request + .trace_policy + .trace_root + .metadata() + .is_ok_and(|metadata| !metadata.is_dir()) + { + return record_failed("trace_register", "trace root is not a directory"); + } + + let now_unix_secs = current_unix_secs(); + let trace_program = fixture_trace_program(&request); + let allocation = match store.allocate_trace(&trace_program, now_unix_secs) { + Ok(allocation) => allocation, + Err(_) => return record_failed("trace_register", "trace allocation failed"), + }; + + let program = fixture_program_for_request(&request); + let mut command = Command::new(program); + command + .args(fixture_args_for_request(&request)) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + apply_request_env(&mut command, &request.env); + if let Some(cwd) = &request.cwd { + command.current_dir(cwd); + } + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + + let mut child = match command.spawn() { + Ok(child) => child, + Err(_) => return record_failed("spawn", "failed to spawn fixture record"), + }; + + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + terminate_child(&mut child); + return record_failed("output", "fixture stdout was unavailable"); + } + }; + let stderr = match child.stderr.take() { + Some(stderr) => stderr, + None => { + terminate_child(&mut child); + return record_failed("output", "fixture stderr was unavailable"); + } + }; + + let output_capture = OutputCapture::new(); + let stdout_reader = output_capture.spawn_reader("stdout", stdout); + let stderr_reader = output_capture.spawn_reader("stderr", stderr); + + let status = match wait_with_timeout( + &mut child, + Duration::from_millis(request.effective_timeout_ms()), + ) { + WaitOutcome::Exited(status) => status, + WaitOutcome::TimedOut => { + terminate_child(&mut child); + return record_timeout(request.effective_timeout_ms()); + } + WaitOutcome::WaitFailed => { + terminate_child(&mut child); + return record_failed("wait", "failed while waiting for fixture record"); + } + }; + + if collect_reader(stdout_reader).is_err() || collect_reader(stderr_reader).is_err() { + return record_failed("output", "failed to capture fixture output"); + } + + let (chunks, readers_truncated) = match output_capture.finish() { + Ok(output) => output, + Err(_) => return record_failed("output", "failed to capture fixture output"), + }; + let (output, output_truncated) = bounded_output_events(chunks, readers_truncated); + let output_summary = output + .iter() + .map(|output| output.text.clone()) + .collect::>(); + let exit_code = status.code().map(i64::from); + let trace = match store.register_completed( + allocation, + crate::traces::TraceCompletion { + program: trace_program, + args_summary: request.args.clone(), + exit_code, + output_summary, + output_truncated, + }, + now_unix_secs, + ) { + Ok(trace) => trace, + Err(_) => return record_failed("trace_register", "trace registration failed"), + }; + + if store.prune(now_unix_secs).is_err() { + return record_failed("trace_register", "trace pruning failed"); + } + + RrRecordResult { + recordable: status.success(), + reason: (!status.success()).then(|| RECORD_FAILED.to_owned()), + trace_id: Some(trace.trace_id.clone()), + trace: Some(trace), + exit_code, + output, + output_truncated, + error: (!status.success()).then(|| RuntimeFailure { + code: RECORD_FAILED.to_owned(), + message: "fixture record exited with failure".to_owned(), + data: Some(serde_json::json!({ "stage": "fixture" })), + }), + } +} + +fn fixture_program_for_request(request: &RrRecordRequest) -> PathBuf { + if request.program == "fixture-program" { + return std::env::current_exe() + .ok() + .and_then(|path| { + path.parent() + .map(|parent| parent.join("fixture_debug_adapter")) + }) + .unwrap_or_else(|| PathBuf::from("fixture_debug_adapter")); + } + PathBuf::from(&request.program) +} + +fn fixture_args_for_request(request: &RrRecordRequest) -> Vec { + if request + .args + .iter() + .any(|arg| arg == "--scenario" || arg.starts_with("--scenario=")) + { + return request.args.clone(); + } + if let Some(scenario) = request + .env + .get("TOWER_DEBUG_FIXTURE_SCENARIO") + .or_else(|| request.args.first()) + { + return vec!["--scenario".to_owned(), scenario.clone()]; + } + request.args.clone() +} + +fn fixture_trace_program(request: &RrRecordRequest) -> String { + let scenario = request + .env + .get("TOWER_DEBUG_FIXTURE_SCENARIO") + .map(String::as_str) + .or_else(|| { + request.args.windows(2).find_map(|args| { + (args.first().is_some_and(|arg| arg == "--scenario")).then(|| args[1].as_str()) + }) + }) + .or_else(|| { + request + .args + .iter() + .find_map(|arg| arg.strip_prefix("--scenario=")) + }) + .or_else(|| request.args.first().map(String::as_str)) + .unwrap_or(request.program.as_str()); + scenario.to_owned() +} + +fn rr_unsupported(reason: RrUnsupportedReason, message: &str) -> RrRecordResult { + RrRecordResult { + recordable: false, + reason: Some(RR_UNSUPPORTED.to_owned()), + trace_id: None, + trace: None, + exit_code: None, + output: Vec::new(), + output_truncated: false, + error: Some(RuntimeFailure { + code: RR_UNSUPPORTED.to_owned(), + message: message.to_owned(), + data: Some(serde_json::json!({ "unsupported_reason": reason.as_str() })), + }), + } +} + +fn record_timeout(timeout_ms: u64) -> RrRecordResult { + RrRecordResult { + recordable: false, + reason: Some(RECORD_TIMEOUT.to_owned()), + trace_id: None, + trace: None, + exit_code: None, + output: Vec::new(), + output_truncated: false, + error: Some(RuntimeFailure { + code: RECORD_TIMEOUT.to_owned(), + message: "rr recording timed out".to_owned(), + data: Some(serde_json::json!({ "timeout_ms": timeout_ms })), + }), + } +} + +fn record_failed(stage: &str, message: &str) -> RrRecordResult { + RrRecordResult { + recordable: false, + reason: Some(RECORD_FAILED.to_owned()), + trace_id: None, + trace: None, + exit_code: None, + output: Vec::new(), + output_truncated: false, + error: Some(RuntimeFailure { + code: RECORD_FAILED.to_owned(), + message: message.to_owned(), + data: Some(serde_json::json!({ "stage": stage })), + }), + } +} + +fn current_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn apply_request_env(command: &mut Command, env: &BTreeMap) { + for (key, value) in env { + command.env(key, value); + } +} + +fn wait_with_timeout(child: &mut Child, timeout: Duration) -> WaitOutcome { + let started = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => return WaitOutcome::Exited(status), + Ok(None) if started.elapsed() >= timeout => return WaitOutcome::TimedOut, + Ok(None) => std::thread::sleep(Duration::from_millis(10)), + Err(_) => return WaitOutcome::WaitFailed, + } + } +} + +fn collect_reader(handle: std::thread::JoinHandle>) -> std::io::Result<()> { + handle + .join() + .unwrap_or_else(|_| Err(std::io::Error::other("output reader thread panicked"))) +} + +fn bounded_output_events( + chunks: Vec, + readers_truncated: bool, +) -> (Vec, bool) { + let mut output = Vec::new(); + let mut output_truncated = readers_truncated; + for chunk in chunks { + let mut text = String::from_utf8_lossy(&chunk.bytes).into_owned(); + if text.len() > chunk.bytes.len() { + truncate_string_to_bytes(&mut text, chunk.bytes.len()); + output_truncated = true; + } + if text.is_empty() { + continue; + } + output.push(DebugOutput { + sequence: output.len() as u64 + 1, + category: Some(chunk.category.to_owned()), + text, + }); + } + + (output, output_truncated) +} + +fn truncate_string_to_bytes(value: &mut String, max_bytes: usize) { + if value.len() <= max_bytes { + return; + } + let mut boundary = max_bytes; + while !value.is_char_boundary(boundary) { + boundary = boundary.saturating_sub(1); + } + value.truncate(boundary); +} + +fn looks_like_rr_output_failure(chunks: &[CapturedChunk], exit_code: Option) -> bool { + let stdout_is_empty = chunks + .iter() + .filter(|chunk| chunk.category == "stdout") + .all(|chunk| chunk.bytes.is_empty()); + if exit_code != Some(2) || !stdout_is_empty { + return false; + } + let stderr_text = chunks + .iter() + .filter(|chunk| chunk.category == "stderr") + .flat_map(|chunk| chunk.bytes.iter().copied()) + .collect::>(); + let stderr_text = String::from_utf8_lossy(&stderr_text).to_lowercase(); + stderr_text.contains("syntax error") + || stderr_text.contains("unterminated") + || stderr_text.contains("unexpected eof") +} + +#[cfg(unix)] +fn terminate_child(child: &mut Child) { + let process_group_id = format!("-{}", child.id()); + let _ = Command::new("kill") + .arg("-TERM") + .arg("--") + .arg(&process_group_id) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + std::thread::sleep(Duration::from_millis(50)); + + let _ = Command::new("kill") + .arg("-KILL") + .arg("--") + .arg(&process_group_id) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + kill_remaining_process_group_members(child.id()); + + for _ in 0..20 { + if !matches!(child.try_wait(), Ok(None)) { + let _ = child.wait(); + wait_for_process_group_to_empty(child.id()); + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + + let _ = child.kill(); + let _ = child.wait(); + wait_for_process_group_to_empty(child.id()); +} + +#[cfg(unix)] +fn kill_remaining_process_group_members(process_group_id: u32) { + for pid in process_group_members(process_group_id) { + let _ = Command::new("kill") + .arg("-KILL") + .arg(pid.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +#[cfg(unix)] +fn wait_for_process_group_to_empty(process_group_id: u32) { + for _ in 0..50 { + if process_group_members(process_group_id).is_empty() { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[cfg(unix)] +fn process_group_members(process_group_id: u32) -> Vec { + let output = Command::new("pgrep") + .arg("-g") + .arg(process_group_id.to_string()) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output(); + let Ok(output) = output else { + return Vec::new(); + }; + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .collect() +} + +#[cfg(not(unix))] +fn terminate_child(child: &mut Child) { + let _ = child.kill(); +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + use std::process::Command; + use std::time::{SystemTime, UNIX_EPOCH}; + + use serde_json::{Value, json}; + + use crate::traces::{AllocatedTrace, TraceCompletion, TraceStoreError}; + use crate::types::{RuntimeFailure, RuntimeFailureResult}; + + use super::*; + + fn temp_trace_root(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!("tower-rr-{name}-{unique}")); + fs::create_dir_all(&root).expect("create temp trace root"); + root + } + + fn trace_policy(name: &str) -> TracePolicy { + TracePolicy { + trace_root: temp_trace_root(name), + ttl_secs: Some(120), + max_traces: 20, + record_timeout_secs: 60, + } + } + + fn request(name: &str) -> RrRecordRequest { + RrRecordRequest { + language: "rust".to_owned(), + program: "/bin/echo".to_owned(), + args: vec!["hello".to_owned()], + cwd: None, + env: BTreeMap::new(), + timeout_ms: None, + trace_policy: trace_policy(name), + } + } + + fn debug_output(sequence: u64, category: &str, text: &str) -> DebugOutput { + DebugOutput { + sequence, + category: Some(category.to_owned()), + text: text.to_owned(), + } + } + + fn write_executable(path: PathBuf, script: &str) -> PathBuf { + fs::write(&path, script).expect("write test executable"); + let mut permissions = fs::metadata(&path) + .expect("test executable metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("mark test executable"); + path + } + + fn fake_rr_bin(name: &str, script: &str) -> (PathBuf, PathBuf) { + let bin_dir = temp_trace_root(name).join("bin"); + fs::create_dir_all(&bin_dir).expect("create fake rr bin dir"); + let rr_path = write_executable(bin_dir.join("rr"), script); + (bin_dir, rr_path) + } + + fn runtime_failure(code: &str, data: Value) -> RuntimeFailure { + RuntimeFailure { + code: code.to_owned(), + message: format!("{code} occurred"), + data: Some(data), + } + } + + fn successful_result(store: &mut TraceStore) -> RrRecordResult { + let allocation = store + .allocate_trace("/bin/echo", 1_000) + .expect("trace allocation"); + let trace_id = allocation.trace_id.clone(); + let trace = store + .register_completed( + allocation, + TraceCompletion { + program: "/bin/echo".to_owned(), + args_summary: vec!["hello".to_owned()], + exit_code: Some(0), + output_summary: vec!["hello\n".to_owned()], + output_truncated: false, + }, + 1_000, + ) + .expect("trace registration"); + + RrRecordResult { + recordable: true, + reason: None, + trace_id: Some(trace_id), + trace: Some(trace), + exit_code: Some(0), + output: vec![debug_output(1, "stdout", "hello\n")], + output_truncated: false, + error: None, + } + } + + #[test] + fn rr_runtime_failure_and_runtime_failure_result_move_to_types_preserving_code_message_and_data_fields() + { + let types_src = include_str!("types.rs"); + let tools_src = include_str!("tools.rs"); + let rr_src = include_str!("rr.rs"); + assert!( + types_src.contains("pub struct RuntimeFailureResult") + && types_src.contains("pub struct RuntimeFailure"), + "RuntimeFailure DTOs must live in types.rs" + ); + assert!( + tools_src.contains("use crate::types::{") + && tools_src.contains("RuntimeFailure, RuntimeFailureResult"), + "tools.rs must import RuntimeFailure DTOs from types.rs" + ); + assert!( + rr_src.contains("use crate::types::{DebugOutput, RuntimeFailure};"), + "rr.rs must import RuntimeFailure from types.rs" + ); + let origin_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/origin.rs"); + if origin_path.exists() { + let origin_src = fs::read_to_string(origin_path).expect("read origin.rs"); + assert!( + origin_src.contains("use crate::types::") && origin_src.contains("RuntimeFailure"), + "origin.rs must import RuntimeFailure DTOs from types.rs when added" + ); + } + + let result = RuntimeFailureResult { + ok: false, + error: RuntimeFailure { + code: RR_UNSUPPORTED.to_owned(), + message: "rr is unavailable".to_owned(), + data: Some(json!({ "unsupported_reason": "rr_missing" })), + }, + }; + + assert_eq!( + serde_json::to_value(result).expect("runtime failure result serializes"), + json!({ + "ok": false, + "error": { + "code": "rr_unsupported", + "message": "rr is unavailable", + "data": { "unsupported_reason": "rr_missing" } + } + }) + ); + } + + #[test] + fn rr_main_wiring_constructs_trace_store_runtime_and_passes_it_to_tool_dispatch() { + let main_src = include_str!("main.rs"); + + assert!( + main_src.contains("let mut rr_runtime: Option = None;"), + "main.rs must keep rr runtime state beside debug sessions" + ); + assert!( + main_src.contains(".map(TracePolicy::from_record_config)") + && main_src.contains("TraceStore::open(policy)") + && main_src.contains("*rr_runtime = parsed_traces.map(|store|") + && main_src.contains("RrRuntime::new_fixture(store)") + && main_src.contains("RrRuntime::new(store)"), + "initialize must build TracePolicy -> TraceStore -> RrRuntime from debug.record config" + ); + assert!( + main_src.contains("rr_runtime.as_mut()"), + "invokeTool dispatch must receive mutable rr runtime state" + ); + } + + #[test] + fn rr_preflight_exists_as_the_fakeable_preflight_trait_with_real_and_fake_implementations() { + fn check_with_trait(preflight: &dyn RrPreflight) -> RrPreflightStatus { + preflight.check() + } + + let fake = FakeRrPreflight::new(RrPreflightStatus::Supported); + let real = RealRrPreflight::new(); + + assert_eq!(check_with_trait(&fake), RrPreflightStatus::Supported); + match check_with_trait(&real) { + RrPreflightStatus::Supported | RrPreflightStatus::Unsupported { .. } => {} + } + } + + #[test] + fn rr_recorder_exists_as_the_fakeable_record_trait_with_real_and_fake_implementations() { + fn record_with_trait( + recorder: &dyn RrRecorder, + request: RrRecordRequest, + store: &mut TraceStore, + ) -> RrRecordResult { + recorder.record(request, store) + } + + let mut store = TraceStore::new(trace_policy("fake-recorder")); + let fake_result = successful_result(&mut store); + let fake = FakeRrRecorder::new(fake_result.clone()); + let _real = RealRrRecorder::new(); + + assert_eq!( + record_with_trait(&fake, request("fake-recorder-request"), &mut store), + fake_result + ); + } + + #[test] + fn rr_preflight_status_exists_with_supported_and_unsupported_reason_message_variants() { + let status = RrPreflightStatus::Unsupported { + reason: RrUnsupportedReason::UnsupportedCpu, + message: "CPU lacks required rr support".to_owned(), + }; + + assert_eq!( + status, + RrPreflightStatus::Unsupported { + reason: RrUnsupportedReason::UnsupportedCpu, + message: "CPU lacks required rr support".to_owned(), + } + ); + assert_eq!(RrPreflightStatus::Supported, RrPreflightStatus::Supported); + } + + #[test] + fn rr_unsupported_reason_exists_with_exact_serde_and_string_values() { + let cases = [ + (RrUnsupportedReason::RrMissing, "rr_missing"), + (RrUnsupportedReason::NonLinuxHost, "non_linux_host"), + (RrUnsupportedReason::UnsupportedCpu, "unsupported_cpu"), + ( + RrUnsupportedReason::UnsupportedPerfCounters, + "unsupported_perf_counters", + ), + (RrUnsupportedReason::RrUnsupported, "rr_unsupported"), + ]; + + for (reason, expected) in cases { + assert_eq!(reason.as_str(), expected); + assert_eq!( + serde_json::to_value(&reason).expect("reason serializes"), + json!(expected) + ); + assert_eq!( + serde_json::from_value::(json!(expected)) + .expect("reason deserializes"), + reason + ); + } + } + + #[test] + fn rr_record_request_contains_the_public_record_fields_and_effective_timeout_default() { + let trace_policy = trace_policy("request-default-timeout"); + let request = RrRecordRequest { + language: "rust".to_owned(), + program: "target/debug/app".to_owned(), + args: vec!["--case".to_owned(), "smoke".to_owned()], + cwd: Some("fixtures".to_owned()), + env: BTreeMap::from([("RUST_LOG".to_owned(), "debug".to_owned())]), + timeout_ms: None, + trace_policy, + }; + + assert_eq!(request.language, "rust"); + assert_eq!(request.program, "target/debug/app"); + assert_eq!(request.args, ["--case", "smoke"]); + assert_eq!(request.cwd.as_deref(), Some("fixtures")); + assert_eq!(request.env["RUST_LOG"], "debug"); + assert_eq!(request.effective_timeout_ms(), 60_000); + } + + #[test] + fn rr_record_request_effective_timeout_ms_returns_explicit_timeout_when_present() { + let mut request = request("request-explicit-timeout"); + request.timeout_ms = Some(1_500); + + assert_eq!(request.effective_timeout_ms(), 1_500); + } + + #[test] + fn rr_record_result_is_the_stable_tool_facing_result_dto_with_expected_fields() { + let mut store = TraceStore::new(trace_policy("record-result-dto")); + let result = successful_result(&mut store); + let serialized = serde_json::to_value(&result).expect("record result serializes"); + + assert_eq!(serialized["recordable"], true); + assert_eq!(serialized["reason"], Value::Null); + assert!(serialized.get("trace_id").is_some()); + assert!(serialized.get("trace").is_some()); + assert_eq!(serialized["exit_code"], 0); + assert_eq!(serialized["output"][0]["text"], "hello\n"); + assert_eq!(serialized["output_truncated"], false); + assert_eq!(serialized["error"], Value::Null); + } + + #[test] + fn rr_successful_completed_runs_return_recordable_trace_metadata_exit_code_output_and_no_error() + { + let mut store = TraceStore::new(trace_policy("successful-run")); + let result = successful_result(&mut store); + + assert!(result.recordable); + assert_eq!(result.reason, None); + assert_eq!( + result.trace_id, + result.trace.as_ref().map(|trace| trace.trace_id.clone()) + ); + assert_eq!(result.exit_code, Some(0)); + assert_eq!(result.output, vec![debug_output(1, "stdout", "hello\n")]); + assert!(!result.output_truncated); + assert_eq!(result.error, None); + } + + #[test] + fn rr_missing_binary_returns_unrecordable_rr_unsupported_with_rr_missing_error_data() { + let missing_path = temp_trace_root("rr-missing-path"); + let request = RrRecordRequest { + env: BTreeMap::from([("PATH".to_owned(), missing_path.display().to_string())]), + ..request("rr-missing-request") + }; + let mut store = TraceStore::new(request.trace_policy.clone()); + let result = RealRrRecorder::new().record(request, &mut store); + + assert!(!result.recordable); + assert_eq!(result.reason.as_deref(), Some(RR_UNSUPPORTED)); + let error = result.error.expect("unsupported result includes error"); + assert_eq!(error.code, RR_UNSUPPORTED); + assert_eq!( + error.data, + Some(json!({ "unsupported_reason": "rr_missing" })) + ); + } + + #[test] + fn rr_preflight_unsupported_returns_recordable_false_with_stable_rr_unsupported_data() { + for (reason, unsupported_reason) in [ + (RrUnsupportedReason::NonLinuxHost, "non_linux_host"), + (RrUnsupportedReason::UnsupportedCpu, "unsupported_cpu"), + ( + RrUnsupportedReason::UnsupportedPerfCounters, + "unsupported_perf_counters", + ), + (RrUnsupportedReason::RrUnsupported, "rr_unsupported"), + ] { + let preflight = FakeRrPreflight::new(RrPreflightStatus::Unsupported { + reason: reason.clone(), + message: format!("{unsupported_reason} is unsupported"), + }); + let status = preflight.check(); + + match status { + RrPreflightStatus::Unsupported { reason, message } => { + assert_eq!(reason.as_str(), unsupported_reason); + assert_eq!(message, format!("{unsupported_reason} is unsupported")); + } + RrPreflightStatus::Supported => { + panic!("unsupported preflight case unexpectedly returned Supported") + } + } + + let recorder = FakeRrRecorder::new(successful_result(&mut TraceStore::new( + trace_policy("unused-preflight-recorder-result"), + ))); + let mut store = TraceStore::new(trace_policy("preflight-unsupported-result")); + let result = record_with_preflight( + &preflight, + &recorder, + request("preflight-unsupported-request"), + &mut store, + ); + + assert!(!result.recordable); + assert_eq!(result.reason.as_deref(), Some(RR_UNSUPPORTED)); + assert_eq!(result.trace_id, None); + assert_eq!(result.trace, None); + assert_eq!(result.output, Vec::new()); + assert!(!result.output_truncated); + let error = result.error.expect("unsupported result includes error"); + assert_eq!(error.code, RR_UNSUPPORTED); + assert_eq!( + error.data, + Some(json!({ "unsupported_reason": unsupported_reason })) + ); + } + } + + #[test] + fn fixture_shaped_record_requests_do_not_bypass_rr_preflight() { + let preflight = FakeRrPreflight::new(RrPreflightStatus::Unsupported { + reason: RrUnsupportedReason::RrMissing, + message: "rr missing for production recorder".to_owned(), + }); + let recorder = FakeRrRecorder::new(successful_result(&mut TraceStore::new(trace_policy( + "unused-fixture-shaped-recorder-result", + )))); + let mut request = request("fixture-shaped-preflight"); + request.program = "fixture-program".to_owned(); + request.env.insert( + "TOWER_DEBUG_FIXTURE_CLEANUP_TOKEN".to_owned(), + "fixture-shaped-token".to_owned(), + ); + request.env.insert( + "TOWER_DEBUG_FIXTURE_SCENARIO".to_owned(), + "record_ok".to_owned(), + ); + let mut store = TraceStore::new(trace_policy("fixture-shaped-preflight-store")); + + let result = record_with_preflight(&preflight, &recorder, request, &mut store); + + assert!(!result.recordable); + assert_eq!(result.reason.as_deref(), Some(RR_UNSUPPORTED)); + assert_eq!(result.trace_id, None); + let error = result.error.expect("unsupported result includes error"); + assert_eq!(error.code, RR_UNSUPPORTED); + assert_eq!( + error.data, + Some(json!({ "unsupported_reason": "rr_missing" })) + ); + } + + #[test] + fn rr_timeout_while_target_is_running_returns_record_timeout_with_effective_timeout_data() { + let timeout_probe = temp_trace_root("timeout-reaping").join("child.pid"); + let script = format!( + "#!/bin/sh\n\ + shift 4\n\ + \"$@\" &\n\ + echo \"$!\" > {}\n\ + wait \"$!\"\n", + timeout_probe.display() + ); + let (fake_rr_dir, _rr_path) = fake_rr_bin("timeout-reaping", &script); + let request = RrRecordRequest { + timeout_ms: Some(250), + env: BTreeMap::from([("PATH".to_owned(), fake_rr_dir.display().to_string())]), + program: "/bin/sh".to_owned(), + args: vec!["-c".to_owned(), "/bin/sleep 30".to_owned()], + ..request("timeout-result") + }; + let mut store = TraceStore::new(request.trace_policy.clone()); + let result = RealRrRecorder::new().record(request, &mut store); + + assert!(!result.recordable); + assert_eq!(result.reason.as_deref(), Some(RECORD_TIMEOUT)); + let error = result.error.expect("timeout result includes error"); + assert_eq!(error.code, RECORD_TIMEOUT); + assert_eq!(error.data, Some(json!({ "timeout_ms": 250 }))); + let child_pid = fs::read_to_string(&timeout_probe) + .expect("timeout path records child pid") + .trim() + .to_owned(); + let status = Command::new("kill") + .arg("-0") + .arg(&child_pid) + .status() + .expect("probe child process liveness"); + assert!( + !status.success(), + "timeout cleanup must reap the target process tree; child pid {child_pid} is still alive" + ); + } + + #[test] + fn rr_spawn_wait_output_and_trace_register_failures_return_record_failed_with_stage_data() { + let fake_rr_failures = [ + ( + "spawn", + { + let bin_dir = temp_trace_root("spawn-failure").join("bin"); + fs::create_dir_all(&bin_dir).expect("create fake rr bin dir"); + fs::write(bin_dir.join("rr"), "#!/bin/sh\nexit 0\n") + .expect("write non-executable fake rr"); + RrRecordRequest { + env: BTreeMap::from([("PATH".to_owned(), bin_dir.display().to_string())]), + ..request("spawn-failure") + } + }, + TraceStore::new(trace_policy("spawn-failure-store")), + ), + ( + "wait", + { + let (fake_rr_dir, _rr_path) = + fake_rr_bin("wait-failure", "#!/bin/sh\nkill -9 $$\n"); + RrRecordRequest { + env: BTreeMap::from([( + "PATH".to_owned(), + fake_rr_dir.display().to_string(), + )]), + ..request("wait-failure") + } + }, + TraceStore::new(trace_policy("wait-failure-store")), + ), + ( + "output", + { + let (fake_rr_dir, _rr_path) = + fake_rr_bin("output-failure", "#!/bin/sh\nprintf 'unterminated"); + RrRecordRequest { + env: BTreeMap::from([( + "PATH".to_owned(), + fake_rr_dir.display().to_string(), + )]), + ..request("output-failure") + } + }, + TraceStore::new(trace_policy("output-failure-store")), + ), + ( + "trace_register", + { + let (fake_rr_dir, _rr_path) = + fake_rr_bin("trace-register-failure", "#!/bin/sh\nshift 4\n\"$@\"\n"); + let trace_root_file = temp_trace_root("trace-register-root").join("file"); + fs::write(&trace_root_file, "not a directory").expect("write trace root file"); + RrRecordRequest { + env: BTreeMap::from([( + "PATH".to_owned(), + fake_rr_dir.display().to_string(), + )]), + trace_policy: TracePolicy { + trace_root: trace_root_file, + ttl_secs: Some(120), + max_traces: 20, + record_timeout_secs: 60, + }, + ..request("trace-register-failure") + } + }, + TraceStore::new(trace_policy("trace-register-failure-store")), + ), + ]; + + for (stage, request, mut store) in fake_rr_failures { + let result = RealRrRecorder::new().record(request, &mut store); + + assert!( + !result.recordable, + "stage {stage} unexpectedly recorded successfully: {result:?}" + ); + assert_eq!( + result.reason.as_deref(), + Some(RECORD_FAILED), + "stage {stage} returned unexpected result: {result:?}" + ); + let error = result.error.expect("record failure result includes error"); + assert_eq!(error.code, RECORD_FAILED); + assert_eq!( + error.data, + Some(json!({ "stage": stage })), + "stage {stage} returned unexpected error" + ); + } + } + + #[test] + fn rr_target_nonzero_exit_is_preserved_as_exit_code_and_not_infrastructure_failure() { + let (fake_rr_dir, _rr_path) = fake_rr_bin("target-nonzero", "#!/bin/sh\nshift 4\n\"$@\"\n"); + let request = RrRecordRequest { + program: "/bin/sh".to_owned(), + args: vec!["-c".to_owned(), "echo failed >&2; exit 7".to_owned()], + env: BTreeMap::from([("PATH".to_owned(), fake_rr_dir.display().to_string())]), + ..request("target-nonzero") + }; + let mut store = TraceStore::new(request.trace_policy.clone()); + let result = RealRrRecorder::new().record(request, &mut store); + + assert!(result.recordable); + assert_eq!(result.exit_code, Some(7)); + assert_eq!(result.reason, None); + assert_eq!(result.error, None); + assert_eq!(result.output, vec![debug_output(1, "stderr", "failed\n")]); + } + + #[test] + fn rr_output_capture_is_bounded_to_max_bytes_preserves_order_and_marks_truncated() { + let output_capture = OutputCapture::new(); + let reader = output_capture.spawn_reader( + "stdout", + std::io::Cursor::new(vec![b'x'; RR_OUTPUT_MAX_BYTES + 1024]), + ); + collect_reader(reader).expect("memory output reader"); + let (chunks, readers_truncated) = output_capture.finish().expect("finish output capture"); + let (output, output_truncated) = bounded_output_events(chunks, readers_truncated); + + assert_eq!(RR_OUTPUT_MAX_BYTES, 65_536); + assert_eq!( + output.first().and_then(|output| output.category.as_deref()), + Some("stdout") + ); + let captured_bytes: usize = output.iter().map(|output| output.text.len()).sum(); + assert_eq!(captured_bytes, RR_OUTPUT_MAX_BYTES); + assert!(output_truncated); + } + + #[test] + fn rr_output_capture_preserves_stderr_before_stdout_interleaving() { + let (fake_rr_dir, _rr_path) = + fake_rr_bin("interleaved-output", "#!/bin/sh\nshift 4\n\"$@\"\n"); + let request = RrRecordRequest { + program: "/bin/sh".to_owned(), + args: vec![ + "-c".to_owned(), + "printf stderr-first >&2; /bin/sleep 0.05; printf stdout-second".to_owned(), + ], + env: BTreeMap::from([("PATH".to_owned(), fake_rr_dir.display().to_string())]), + ..request("interleaved-output") + }; + let mut store = TraceStore::new(request.trace_policy.clone()); + let result = RealRrRecorder::new().record(request, &mut store); + + assert!(result.recordable); + assert_eq!( + result + .output + .iter() + .map(|output| ( + output.category.as_deref().unwrap_or_default().to_owned(), + output.text.clone() + )) + .collect::>(), + vec![ + ("stderr".to_owned(), "stderr-first".to_owned()), + ("stdout".to_owned(), "stdout-second".to_owned()), + ] + ); + assert!(!result.output_truncated); + } + + #[test] + fn rr_runtime_failure_data_contains_only_exact_structured_fields_for_supported_error_shapes() { + let cases = [ + runtime_failure( + RR_UNSUPPORTED, + json!({ "unsupported_reason": "unsupported_cpu" }), + ), + runtime_failure(RECORD_TIMEOUT, json!({ "timeout_ms": 500 })), + runtime_failure(RECORD_FAILED, json!({ "stage": "spawn" })), + ]; + + for error in cases { + let keys = error + .data + .as_ref() + .and_then(Value::as_object) + .expect("error data is an object") + .keys() + .cloned() + .collect::>(); + assert!( + matches!( + keys.as_slice(), + [only] + if only == "unsupported_reason" || only == "timeout_ms" || only == "stage" + ), + "unexpected runtime failure data keys: {keys:?}" + ); + } + } + + #[test] + fn rr_successful_recording_allocates_registers_and_prunes_trace_store_metadata() { + let (fake_rr_dir, _rr_path) = fake_rr_bin( + "successful-command", + "#!/bin/sh\n\ + printf '%s\\n' \"$@\" > \"$RR_TEST_ARGV_CAPTURE\"\n\ + shift 4\n\ + \"$@\"\n", + ); + let argv_capture = temp_trace_root("successful-command-capture").join("argv"); + let mut store = TraceStore::new(TracePolicy { + max_traces: 1, + ..trace_policy("allocate-register-prune") + }); + let old = store + .allocate_trace("old", 1) + .expect("old trace allocation"); + store + .register_completed( + old, + TraceCompletion { + program: "old".to_owned(), + args_summary: Vec::new(), + exit_code: Some(0), + output_summary: Vec::new(), + output_truncated: false, + }, + 1, + ) + .expect("old trace registration"); + + let request = RrRecordRequest { + program: "/bin/echo".to_owned(), + args: vec!["--ok".to_owned()], + env: BTreeMap::from([ + ("PATH".to_owned(), fake_rr_dir.display().to_string()), + ( + "RR_TEST_ARGV_CAPTURE".to_owned(), + argv_capture.display().to_string(), + ), + ]), + trace_policy: store.policy().clone(), + ..request("successful-command") + }; + let result = RealRrRecorder::new().record(request, &mut store); + + assert!(result.recordable); + let metadata = result.trace.expect("successful record returns metadata"); + assert_eq!(result.trace_id.as_ref(), Some(&metadata.trace_id)); + assert_eq!(metadata.program, "/bin/echo"); + assert_eq!(metadata.args_summary, vec!["--ok"]); + assert_eq!(metadata.exit_code, Some(0)); + assert!( + fs::read_to_string(argv_capture) + .expect("fake rr argv capture") + .contains("record\n-o\n"), + "recorder must invoke `rr record -o -- `" + ); + assert_eq!( + store.list_traces().expect("trace list")[0].trace_id, + metadata.trace_id + ); + assert_eq!( + store.list_traces().expect("trace list").len(), + 1, + "successful recording must prune after registering the new trace" + ); + } + + #[test] + fn rr_recording_failure_does_not_leave_partial_trace_metadata_listable_as_completed_trace() { + let bin_dir = temp_trace_root("failed-recording").join("bin"); + fs::create_dir_all(&bin_dir).expect("create fake rr bin dir"); + fs::write(bin_dir.join("rr"), "#!/bin/sh\nexit 0\n").expect("write non-executable fake rr"); + let request = RrRecordRequest { + env: BTreeMap::from([("PATH".to_owned(), bin_dir.display().to_string())]), + ..request("failed-recording") + }; + let trace_root = request.trace_policy.trace_root.clone(); + let mut store = TraceStore::new(request.trace_policy.clone()); + let result = RealRrRecorder::new().record(request, &mut store); + + assert!(!result.recordable); + assert_eq!(result.reason.as_deref(), Some(RECORD_FAILED)); + let error = result.error.expect("record failure result includes error"); + assert_eq!(error.data, Some(json!({ "stage": "spawn" }))); + assert_eq!(store.list_traces().expect("trace list"), Vec::new()); + let remaining_entries = fs::read_dir(trace_root).expect("trace root exists").count(); + assert_eq!( + remaining_entries, 0, + "failed recordings must remove the allocated trace directory" + ); + } + + #[test] + fn rr_trace_register_failure_is_reported_as_record_failed_and_unregistered() { + let mut store = TraceStore::new(trace_policy("trace-register-failure")); + let escaped = AllocatedTrace { + trace_id: TraceId::new("escaped").expect("valid trace id"), + path: temp_trace_root("outside-trace-root").join("escaped"), + }; + fs::create_dir_all(&escaped.path).expect("create escaped path"); + let error = store + .register_completed( + escaped, + TraceCompletion { + program: "escaped".to_owned(), + args_summary: Vec::new(), + exit_code: Some(0), + output_summary: Vec::new(), + output_truncated: false, + }, + 1, + ) + .expect_err("escaped trace registration should fail"); + + assert!(matches!(error, TraceStoreError::TracePathEscaped { .. })); + assert_eq!(store.list_traces().expect("trace list"), Vec::new()); + } +} diff --git a/extensions/debug/src/session.rs b/extensions/debug/src/session.rs index af91360..da86a1e 100644 --- a/extensions/debug/src/session.rs +++ b/extensions/debug/src/session.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::protocol::DebugInitializeConfig; +use crate::traces::TraceId; use crate::types::{ DebugBreakpoint, DebugOutput, DebugRuntimeError, DebugScope, DebugSessionId, DebugSessionState, DebugStackFrame, DebugStop, DebugThread, DebugVariable, @@ -28,6 +29,7 @@ struct ManagerState { struct SessionEntry { language: String, + kind: SessionKind, state: DebugSessionState, last_stop: Option, output: Vec, @@ -123,6 +125,7 @@ impl SessionManager { session_id.0.clone(), SessionEntry { language: params.language, + kind: SessionKind::Live, state: DebugSessionState::Stopped, last_stop: Some(stop.clone()), output: Vec::new(), @@ -143,6 +146,176 @@ impl SessionManager { )) } + pub fn open_replay( + &self, + request: ReplayOpenRequest, + ) -> Result { + let adapter_config = self + .config + .languages + .get(&request.language) + .ok_or_else(|| { + DebugRuntimeError::LaunchFailed(format!( + "no debug adapter configured for language {}", + request.language + )) + })?; + let timeout = request + .timeout_secs + .map(Duration::from_secs) + .unwrap_or_else(|| Duration::from_secs(adapter_config.default_timeout_secs)); + let idle_ttl = Duration::from_secs(adapter_config.idle_ttl_secs); + let trace_id = request.trace_id; + let language = request.language; + let trace_path = request.trace_path; + let (adapter_program, adapter_args) = match request.adapter { + Some(adapter) => (adapter, request.adapter_args), + None => { + self.config.record.as_ref().ok_or_else(|| { + DebugRuntimeError::LaunchFailed( + "no rr replay adapter configured for replay sessions".to_owned(), + ) + })?; + let (program, mut args) = if adapter_config.adapter_type == "fixture" { + (adapter_config.command.clone(), adapter_config.args.clone()) + } else { + ("rr".to_owned(), vec!["replay".to_owned()]) + }; + if let Some(trace_path) = trace_path { + args.push(trace_path); + } + args.extend(request.adapter_args); + (program, args) + } + }; + let mut launch_overrides = Map::new(); + launch_overrides.insert("trace_id".to_owned(), Value::String(trace_id.to_string())); + let start_request = LaunchRequest { + language: language.clone(), + program: adapter_program, + cwd: None, + args: adapter_args, + env: BTreeMap::new(), + launch_overrides, + }; + + let mut adapter = self.adapter_factory.start(&start_request)?; + if let Err(error) = adapter.initialize(timeout) { + let _ = adapter.terminate(timeout); + return Err(error); + } + if let Err(error) = adapter.launch(&start_request, timeout) { + let _ = adapter.terminate(timeout); + return Err(error); + } + + let top_frame = adapter + .stack(1, timeout) + .ok() + .and_then(|frames| frames.into_iter().next()); + let stop = DebugStop { + state: DebugSessionState::Stopped, + reason: Some("replay".to_owned()), + thread_id: Some(1), + top_frame, + hit_breakpoint_ids: Vec::new(), + timed_out: false, + exit_code: None, + output_since: Vec::new(), + }; + let supports_step_back = true; + + let mut state = self.state.lock().unwrap(); + self.cleanup_expired_locked(&mut state); + let session_id = DebugSessionId(format!( + "debug-{}-{}", + self.manager_generation, state.next_session_index + )); + state.next_session_index += 1; + state.sessions.insert( + session_id.0.clone(), + SessionEntry { + language, + kind: SessionKind::Replay { + trace_id: trace_id.clone(), + supports_step_back, + }, + state: DebugSessionState::Stopped, + last_stop: Some(stop.clone()), + output: Vec::new(), + adapter, + timeout, + idle_ttl, + last_activity: Instant::now(), + }, + ); + + Ok(ReplayResult { + session_id, + trace_id, + state: DebugSessionState::Stopped, + stop: Some(stop), + supports_step_back, + }) + } + + pub fn session_kind( + &self, + session_id: &DebugSessionId, + ) -> Result { + let mut state = self.state.lock().unwrap(); + self.cleanup_expired_locked(&mut state); + let Some(session) = state.sessions.get_mut(&session_id.0) else { + return Err(session_not_found(session_id)); + }; + session.last_activity = Instant::now(); + Ok(session.kind.clone()) + } + + pub fn reverse_continue( + &self, + session_id: &DebugSessionId, + thread_id: Option, + timeout: Option, + ) -> Result { + self.resume_replay(session_id, timeout, |adapter, timeout| { + adapter.reverse_continue(thread_id, timeout) + }) + } + + pub fn step_back( + &self, + session_id: &DebugSessionId, + thread_id: Option, + granularity: StepBackGranularity, + timeout: Option, + ) -> Result { + self.resume_replay(session_id, timeout, |adapter, timeout| { + adapter.step_back(thread_id, granularity, timeout) + }) + } + + pub fn set_watchpoint( + &self, + session_id: &DebugSessionId, + watchpoint: WatchpointSpec, + ) -> Result { + self.with_replay_session(session_id, |session| { + session.adapter.set_watchpoint(watchpoint, session.timeout) + }) + } + + pub fn seek_replay( + &self, + session_id: &DebugSessionId, + target: ReplaySeekTarget, + timeout: Option, + ) -> Result { + self.resume_replay(session_id, timeout, |adapter, timeout| { + adapter.seek_replay(target, timeout) + }) + } + pub fn set_breakpoints( &self, session_id: &DebugSessionId, @@ -422,6 +595,99 @@ impl SessionManager { } } + fn resume_replay( + &self, + session_id: &DebugSessionId, + timeout: Option, + resume: impl FnOnce( + &mut dyn DebugAdapterSession, + Duration, + ) -> Result, + ) -> Result { + let mut session = { + let mut state = self.state.lock().unwrap(); + self.cleanup_expired_locked(&mut state); + let Some(mut session) = state.sessions.remove(&session_id.0) else { + return Err(session_not_found(session_id)); + }; + if matches!(session.kind, SessionKind::Live) { + state.sessions.insert(session_id.0.clone(), session); + return Err(reverse_unsupported()); + } + session.last_activity = Instant::now(); + session + }; + let previous_state = session.state.clone(); + let previous_stop = session.last_stop.clone(); + session.state = DebugSessionState::Running; + let timeout = timeout.unwrap_or(session.timeout); + + let result = match resume(session.adapter.as_mut(), timeout) { + Ok(stop) => { + update_session_stop(&mut session, &stop); + Ok(stop) + } + Err(DebugRuntimeError::AdapterExited(_)) => { + let _ = session.adapter.terminate(session.timeout); + Err(session_not_found(session_id)) + } + Err(DebugRuntimeError::DebugTimeout(_)) => { + let stop = timeout_stop(); + update_session_stop(&mut session, &stop); + Ok(stop) + } + Err(error) => { + session.state = previous_state; + session.last_stop = previous_stop; + session.last_activity = Instant::now(); + Err(error) + } + }; + + if !matches!(result, Err(DebugRuntimeError::SessionNotFound(_))) { + let mut state = self.state.lock().unwrap(); + state.sessions.insert(session_id.0.clone(), session); + } + + result + } + + fn with_replay_session( + &self, + session_id: &DebugSessionId, + operation: impl FnOnce(&mut SessionEntry) -> Result, + ) -> Result { + let mut session = { + let mut state = self.state.lock().unwrap(); + self.cleanup_expired_locked(&mut state); + let Some(mut session) = state.sessions.remove(&session_id.0) else { + return Err(session_not_found(session_id)); + }; + if matches!(session.kind, SessionKind::Live) { + state.sessions.insert(session_id.0.clone(), session); + return Err(reverse_unsupported()); + } + session.last_activity = Instant::now(); + session + }; + + let result = match operation(&mut session) { + Ok(result) => Ok(result), + Err(DebugRuntimeError::AdapterExited(_)) => { + let _ = session.adapter.terminate(session.timeout); + Err(session_not_found(session_id)) + } + Err(error) => Err(error), + }; + + if !matches!(result, Err(DebugRuntimeError::SessionNotFound(_))) { + let mut state = self.state.lock().unwrap(); + state.sessions.insert(session_id.0.clone(), session); + } + + result + } + fn reap_adapter_exit_locked( state: &mut ManagerState, session_id: &DebugSessionId, @@ -467,10 +733,27 @@ fn update_session_stop(session: &mut SessionEntry, stop: &DebugStop) { session.last_activity = Instant::now(); } +fn timeout_stop() -> DebugStop { + DebugStop { + state: DebugSessionState::Running, + reason: None, + thread_id: None, + top_frame: None, + hit_breakpoint_ids: Vec::new(), + timed_out: true, + exit_code: None, + output_since: Vec::new(), + } +} + fn session_not_found(session_id: &DebugSessionId) -> DebugRuntimeError { DebugRuntimeError::SessionNotFound(format!("debug session {} was not found", session_id.0)) } +fn reverse_unsupported() -> DebugRuntimeError { + DebugRuntimeError::ReverseUnsupported("reverse_unsupported".to_owned()) +} + pub trait DebugAdapterFactory: Send + Sync { fn start( &self, @@ -523,10 +806,113 @@ pub trait DebugAdapterSession: Send { expression: &str, timeout: Duration, ) -> Result; + fn reverse_continue( + &mut self, + _thread_id: Option, + _timeout: Duration, + ) -> Result { + Err(reverse_unsupported()) + } + fn step_back( + &mut self, + _thread_id: Option, + _granularity: StepBackGranularity, + _timeout: Duration, + ) -> Result { + Err(reverse_unsupported()) + } + fn set_watchpoint( + &mut self, + _watchpoint: WatchpointSpec, + _timeout: Duration, + ) -> Result { + Err(reverse_unsupported()) + } + fn seek_replay( + &mut self, + _target: ReplaySeekTarget, + _timeout: Duration, + ) -> Result { + Err(reverse_unsupported()) + } fn terminate(&mut self, timeout: Duration) -> Result<(), DebugRuntimeError>; fn disconnect(&mut self, timeout: Duration) -> Result<(), DebugRuntimeError>; } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SessionKind { + Live, + Replay { + trace_id: TraceId, + supports_step_back: bool, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReplayOpenRequest { + pub trace_id: TraceId, + pub trace_path: Option, + pub language: String, + pub timeout_secs: Option, + pub adapter: Option, + pub adapter_args: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ReplayResult { + pub session_id: DebugSessionId, + pub trace_id: TraceId, + pub state: DebugSessionState, + pub stop: Option, + #[serde(rename = "supportsStepBack")] + pub supports_step_back: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StepBackGranularity { + Line, + Instruction, + Over, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WatchpointKind { + Write, + Read, + Access, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WatchpointSpec { + pub expression: Option, + pub address: Option, + pub kind: WatchpointKind, + pub enabled: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WatchpointResult { + pub watchpoint_id: String, + pub expression: Option, + pub address: Option, + pub kind: WatchpointKind, + pub enabled: bool, + pub verified: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReplaySeekTarget { + Crash, + End, + Source { + path: String, + line: u64, + column: Option, + }, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct LaunchRequest { pub language: String, @@ -561,14 +947,17 @@ mod tests { use std::sync::{Arc, Mutex}; use std::time::Duration; - use serde_json::Map; + use serde_json::{Map, json}; use crate::protocol::{DebugAdapterConfig, DebugInitializeConfig}; + use crate::traces::TraceId; use super::{ DebugAdapterFactory, DebugAdapterSession, DebugBreakpoint, DebugOutput, DebugRuntimeError, DebugScope, DebugSessionId, DebugSessionState, DebugStackFrame, DebugStop, DebugThread, - DebugVariable, LaunchRequest, SessionManager, + DebugVariable, LaunchRequest, ReplayOpenRequest, ReplayResult, ReplaySeekTarget, + SessionKind, SessionManager, StepBackGranularity, WatchpointKind, WatchpointResult, + WatchpointSpec, }; #[derive(Default)] @@ -580,6 +969,8 @@ mod tests { launch_error: bool, terminate_error: bool, disconnect_error: bool, + reverse_adapter_exit: bool, + reverse_unsupported: bool, } impl FakeAdapterFactory { @@ -617,6 +1008,20 @@ mod tests { ..Self::default() } } + + fn reverse_adapter_exit() -> Self { + Self { + reverse_adapter_exit: true, + ..Self::default() + } + } + + fn reverse_unsupported() -> Self { + Self { + reverse_unsupported: true, + ..Self::default() + } + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -624,6 +1029,10 @@ mod tests { Initialize, Launch, SetBreakpoints(Vec), + ReverseContinue(Option), + StepBack(Option, StepBackGranularity), + SetWatchpoint(WatchpointSpec), + SeekReplay(ReplaySeekTarget), Terminate, Disconnect, } @@ -641,6 +1050,8 @@ mod tests { launch_error: self.launch_error, terminate_error: self.terminate_error, disconnect_error: self.disconnect_error, + reverse_adapter_exit: self.reverse_adapter_exit, + reverse_unsupported: self.reverse_unsupported, ..FakeAdapterSession::default() })) } @@ -659,6 +1070,9 @@ mod tests { launch_error: bool, terminate_error: bool, disconnect_error: bool, + reverse_timeout: bool, + reverse_adapter_exit: bool, + reverse_unsupported: bool, } impl DebugAdapterSession for FakeAdapterSession { @@ -794,6 +1208,92 @@ mod tests { }) } + fn reverse_continue( + &mut self, + thread_id: Option, + timeout: Duration, + ) -> Result { + self.calls + .lock() + .unwrap() + .push(FakeAdapterCall::ReverseContinue(thread_id)); + if self.reverse_adapter_exit { + return Err(DebugRuntimeError::AdapterExited( + "fake replay adapter exited before reverse continue".to_owned(), + )); + } + if self.reverse_unsupported { + return Err(super::reverse_unsupported()); + } + if self.reverse_timeout || timeout <= Duration::from_millis(1) { + return Err(DebugRuntimeError::DebugTimeout( + "fake replay adapter timed out during reverse continue".to_owned(), + )); + } + Ok(DebugStop { + reason: Some("reverse".to_owned()), + ..stopped_at_breakpoint() + }) + } + + fn step_back( + &mut self, + thread_id: Option, + granularity: StepBackGranularity, + _timeout: Duration, + ) -> Result { + self.calls + .lock() + .unwrap() + .push(FakeAdapterCall::StepBack(thread_id, granularity)); + Ok(DebugStop { + reason: Some("stepBack".to_owned()), + ..stopped_at_breakpoint() + }) + } + + fn set_watchpoint( + &mut self, + watchpoint: WatchpointSpec, + _timeout: Duration, + ) -> Result { + self.calls + .lock() + .unwrap() + .push(FakeAdapterCall::SetWatchpoint(watchpoint.clone())); + Ok(WatchpointResult { + watchpoint_id: "watch-1".to_owned(), + expression: watchpoint.expression, + address: watchpoint.address, + kind: watchpoint.kind, + enabled: watchpoint.enabled, + verified: true, + }) + } + + fn seek_replay( + &mut self, + target: ReplaySeekTarget, + timeout: Duration, + ) -> Result { + self.calls + .lock() + .unwrap() + .push(FakeAdapterCall::SeekReplay(target)); + if timeout <= Duration::from_millis(1) { + return Err(DebugRuntimeError::DebugTimeout( + "fake replay adapter timed out during seek replay".to_owned(), + )); + } + if self.reverse_unsupported { + return Err(super::reverse_unsupported()); + } + Ok(DebugStop { + reason: Some("seek".to_owned()), + ..stopped_at_breakpoint() + }) + } + fn terminate(&mut self, _timeout: Duration) -> Result<(), DebugRuntimeError> { self.calls.lock().unwrap().push(FakeAdapterCall::Terminate); if self.terminate_error { @@ -848,6 +1348,13 @@ mod tests { idle_ttl_secs: 1, }, )]), + record: Some(crate::protocol::DebugRecordConfig { + backend: "rr".to_owned(), + trace_dir: None, + ttl_secs: None, + max_traces: None, + record_timeout_secs: None, + }), } } @@ -919,11 +1426,42 @@ mod tests { assert!(matches!(error, DebugRuntimeError::NotStopped(_))); } - #[test] - fn session_manager_launch_performs_initialize_launch_breakpoint_configuration_sequencing_through_dap_transport_and_returns_new_session_id_plus_initial_state() - { - let factory = Arc::new(FakeAdapterFactory::default()); - let manager = SessionManager::new(config(), factory.clone()); + fn trace_id() -> TraceId { + TraceId::new("trace-replay-1").expect("valid test trace id") + } + + fn replay_open_request() -> ReplayOpenRequest { + ReplayOpenRequest { + trace_id: trace_id(), + trace_path: None, + language: "rust".to_owned(), + timeout_secs: Some(2), + adapter: None, + adapter_args: vec!["--chaos".to_owned()], + } + } + + fn watchpoint_spec() -> WatchpointSpec { + WatchpointSpec { + expression: Some("counter".to_owned()), + address: None, + kind: WatchpointKind::Write, + enabled: true, + } + } + + fn assert_reverse_unsupported(error: DebugRuntimeError) { + assert_eq!( + error, + DebugRuntimeError::ReverseUnsupported("reverse_unsupported".to_owned()) + ); + } + + #[test] + fn session_manager_launch_performs_initialize_launch_breakpoint_configuration_sequencing_through_dap_transport_and_returns_new_session_id_plus_initial_state() + { + let factory = Arc::new(FakeAdapterFactory::default()); + let manager = SessionManager::new(config(), factory.clone()); let result = manager .launch(launch_request()) @@ -1247,4 +1785,622 @@ mod tests { ); assert!(manager.sessions().is_empty()); } + + #[test] + fn session_kind_exists_with_exact_variants_live_and_replay_and_session_entry_stores_kind() { + let manager = manager(); + let live = manager.launch(launch_request()).unwrap(); + let replay = manager + .open_replay(replay_open_request()) + .expect("valid trace id should open a replay session"); + + assert_eq!( + manager.session_kind(&live.session_id).unwrap(), + SessionKind::Live + ); + assert_eq!( + manager.session_kind(&replay.session_id).unwrap(), + SessionKind::Replay { + trace_id: trace_id(), + supports_step_back: true, + } + ); + } + + #[test] + fn replay_open_request_exists_with_exact_public_fields_and_none_adapter_selects_configured_rr_replay_adapter() + { + let request = replay_open_request(); + let serialized = serde_json::to_value(&request).unwrap(); + + assert_eq!( + serialized, + json!({ + "trace_id": "trace-replay-1", + "language": "rust", + "timeout_secs": 2, + "adapter": null, + "trace_path": null, + "adapter_args": ["--chaos"] + }) + ); + + let round_trip: ReplayOpenRequest = serde_json::from_value(serialized).unwrap(); + assert_eq!(round_trip, request); + } + + #[test] + fn replay_result_exists_with_public_rust_fields_and_supports_step_back_json_field() { + let result = ReplayResult { + session_id: DebugSessionId("debug-replay-1".to_owned()), + trace_id: trace_id(), + state: DebugSessionState::Stopped, + stop: Some(stopped_at_breakpoint()), + supports_step_back: true, + }; + + let serialized = serde_json::to_value(&result).unwrap(); + + assert_eq!(serialized["session_id"], "debug-replay-1"); + assert_eq!(serialized["trace_id"], "trace-replay-1"); + assert_eq!(serialized["state"], "stopped"); + assert_eq!(serialized["supportsStepBack"], true); + assert!(serialized.get("supports_step_back").is_none()); + assert!(serialized["stop"].is_object()); + } + + #[test] + fn session_manager_open_replay_opens_a_replay_session_from_valid_trace_id_and_returns_normal_session_id() + { + let manager = manager(); + + let result = manager + .open_replay(replay_open_request()) + .expect("open_replay should accept a valid trace id"); + + assert!(!result.session_id.0.is_empty()); + assert_eq!(result.trace_id, trace_id()); + assert_eq!(result.state, DebugSessionState::Stopped); + assert_eq!( + manager.session_kind(&result.session_id).unwrap(), + SessionKind::Replay { + trace_id: trace_id(), + supports_step_back: result.supports_step_back, + } + ); + } + + #[test] + fn session_manager_session_kind_exists_so_tool_handlers_can_reject_live_sessions_without_duplicating_registry_logic() + { + let manager = manager(); + let live = manager.launch(launch_request()).unwrap(); + + assert_eq!( + manager.session_kind(&live.session_id).unwrap(), + SessionKind::Live + ); + assert_session_not_found( + manager + .session_kind(&DebugSessionId("missing".to_owned())) + .unwrap_err(), + ); + } + + #[test] + fn existing_forward_operations_continue_to_work_on_replay_sessions_through_existing_debug_adapter_session_boundary() + { + let manager = manager(); + let replay = manager + .open_replay(replay_open_request()) + .expect("valid replay session should open"); + + let threads = manager.threads(&replay.session_id).unwrap(); + let frames = manager.stack(&replay.session_id, 1).unwrap(); + let variable = manager + .evaluate(&replay.session_id, 10, "answer".to_owned()) + .unwrap(); + + assert_eq!(threads[0].id, 1); + assert_eq!(frames, vec![top_frame()]); + assert_eq!(variable.value, "42"); + } + + #[test] + fn replay_sessions_remain_read_only_by_reusing_replay_adapter_paths_without_launch_or_breakpoint_mutation_commands() + { + let factory = Arc::new(FakeAdapterFactory::default()); + let manager = SessionManager::new(config(), factory.clone()); + let replay = manager + .open_replay(replay_open_request()) + .expect("valid replay session should open without launching a live debuggee"); + + manager + .reverse_continue(&replay.session_id, Some(1), Some(Duration::from_millis(50))) + .expect("reverse continue should use the replay adapter"); + manager + .step_back( + &replay.session_id, + Some(1), + StepBackGranularity::Line, + Some(Duration::from_millis(50)), + ) + .expect("step back should use the replay adapter"); + manager + .set_watchpoint(&replay.session_id, watchpoint_spec()) + .expect("watchpoint should use the replay adapter"); + + let calls = factory.calls.lock().unwrap(); + assert_eq!( + calls + .iter() + .filter(|call| matches!(call, FakeAdapterCall::Launch)) + .count(), + 1, + "replay sessions open the trace-backed adapter exactly once" + ); + assert!( + !calls + .iter() + .any(|call| matches!(call, FakeAdapterCall::SetBreakpoints(_))), + "replay-specific operations must not configure live breakpoints" + ); + } + + #[test] + fn debug_adapter_session_adds_exact_reverse_continue_step_back_set_watchpoint_and_seek_replay_methods() + { + let mut session = FakeAdapterSession::default(); + + assert_eq!( + session + .reverse_continue(Some(7), Duration::from_millis(50)) + .unwrap() + .reason, + Some("reverse".to_owned()) + ); + assert_eq!( + session + .step_back( + Some(7), + StepBackGranularity::Instruction, + Duration::from_millis(50), + ) + .unwrap() + .reason, + Some("stepBack".to_owned()) + ); + assert_eq!( + session + .set_watchpoint(watchpoint_spec(), Duration::from_millis(50)) + .unwrap() + .watchpoint_id, + "watch-1" + ); + assert_eq!( + session + .seek_replay(ReplaySeekTarget::Crash, Duration::from_millis(50)) + .unwrap() + .reason, + Some("seek".to_owned()) + ); + } + + #[test] + fn open_replay_with_no_adapter_uses_configured_rr_replay_adapter_and_launches_trace_before_registering_session() + { + let factory = Arc::new(FakeAdapterFactory::default()); + let manager = SessionManager::new(config(), factory.clone()); + + manager + .open_replay(replay_open_request()) + .expect("valid replay session should open through configured rr replay adapter"); + + let starts = factory.starts.lock().unwrap(); + assert_eq!(starts.len(), 1); + assert_eq!(starts[0].program, "rr"); + assert_eq!(starts[0].args, vec!["replay", "--chaos"]); + assert_eq!( + starts[0].launch_overrides["trace_id"], + serde_json::json!("trace-replay-1") + ); + + let calls = factory.calls.lock().unwrap(); + assert_eq!( + &calls[..2], + &[FakeAdapterCall::Initialize, FakeAdapterCall::Launch], + "replay open must attach/open the trace before registering the session" + ); + } + + #[test] + fn open_replay_launch_failure_terminates_adapter_and_does_not_register_session() { + let factory = Arc::new(FakeAdapterFactory::launch_error()); + let manager = SessionManager::new(config(), factory.clone()); + + let error = manager + .open_replay(replay_open_request()) + .expect_err("replay launch failure must be returned"); + + assert_eq!( + error, + DebugRuntimeError::LaunchFailed("fake adapter rejected launch".to_owned()) + ); + assert!(manager.sessions().is_empty()); + assert_eq!( + factory.calls.lock().unwrap().as_slice(), + &[ + FakeAdapterCall::Initialize, + FakeAdapterCall::Launch, + FakeAdapterCall::Terminate, + ] + ); + } + + #[test] + fn step_back_granularity_exists_with_exact_serde_string_values_line_instruction_and_over() { + assert_eq!( + serde_json::to_value(StepBackGranularity::Line).unwrap(), + "line" + ); + assert_eq!( + serde_json::to_value(StepBackGranularity::Instruction).unwrap(), + "instruction" + ); + assert_eq!( + serde_json::to_value(StepBackGranularity::Over).unwrap(), + "over" + ); + } + + #[test] + fn watchpoint_kind_exists_with_exact_serde_string_values_and_watchpoint_spec_has_public_fields() + { + let spec = watchpoint_spec(); + let serialized = serde_json::to_value(&spec).unwrap(); + + assert_eq!( + serialized, + json!({ + "expression": "counter", + "address": null, + "kind": "write", + "enabled": true + }) + ); + assert_eq!(serde_json::to_value(WatchpointKind::Read).unwrap(), "read"); + assert_eq!( + serde_json::to_value(WatchpointKind::Access).unwrap(), + "access" + ); + } + + #[test] + fn watchpoint_result_exists_with_exact_public_fields() { + let result = WatchpointResult { + watchpoint_id: "watch-1".to_owned(), + expression: Some("counter".to_owned()), + address: None, + kind: WatchpointKind::Write, + enabled: true, + verified: true, + }; + + assert_eq!( + serde_json::to_value(&result).unwrap(), + json!({ + "watchpoint_id": "watch-1", + "expression": "counter", + "address": null, + "kind": "write", + "enabled": true, + "verified": true + }) + ); + } + + #[test] + fn replay_seek_target_exists_with_exact_variants_crash_end_and_source_path_line_column() { + assert_eq!( + serde_json::to_value(ReplaySeekTarget::Crash).unwrap(), + "Crash" + ); + assert_eq!(serde_json::to_value(ReplaySeekTarget::End).unwrap(), "End"); + assert_eq!( + serde_json::to_value(ReplaySeekTarget::Source { + path: "src/main.rs".to_owned(), + line: 12, + column: Some(5), + }) + .unwrap(), + json!({ "Source": { "path": "src/main.rs", "line": 12, "column": 5 } }) + ); + } + + #[test] + fn existing_live_adapters_use_default_debug_adapter_session_implementations_that_return_reverse_unsupported() + { + struct LiveOnlyAdapter; + + impl DebugAdapterSession for LiveOnlyAdapter { + fn initialize(&mut self, _timeout: Duration) -> Result<(), DebugRuntimeError> { + Ok(()) + } + + fn launch( + &mut self, + _request: &LaunchRequest, + _timeout: Duration, + ) -> Result<(), DebugRuntimeError> { + Ok(()) + } + + fn set_breakpoints( + &mut self, + breakpoints: &[DebugBreakpoint], + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + Ok(breakpoints.to_vec()) + } + + fn continue_session( + &mut self, + _timeout: Duration, + ) -> Result { + Ok(stopped_at_breakpoint()) + } + + fn step( + &mut self, + _thread_id: Option, + _timeout: Duration, + ) -> Result { + Ok(stopped_at_breakpoint()) + } + + fn pause( + &mut self, + _thread_id: Option, + _timeout: Duration, + ) -> Result { + Ok(stopped_at_breakpoint()) + } + + fn threads( + &mut self, + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + Ok(Vec::new()) + } + + fn stack( + &mut self, + _thread_id: u64, + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + Ok(Vec::new()) + } + + fn scopes( + &mut self, + _frame_id: u64, + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + Ok(Vec::new()) + } + + fn variables( + &mut self, + _variables_reference: u64, + _timeout: Duration, + ) -> Result, DebugRuntimeError> { + Ok(Vec::new()) + } + + fn evaluate( + &mut self, + _frame_id: u64, + expression: &str, + _timeout: Duration, + ) -> Result { + Ok(DebugVariable { + name: expression.to_owned(), + value: "42".to_owned(), + r#type: None, + variables_reference: 0, + }) + } + + fn terminate(&mut self, _timeout: Duration) -> Result<(), DebugRuntimeError> { + Ok(()) + } + + fn disconnect(&mut self, _timeout: Duration) -> Result<(), DebugRuntimeError> { + Ok(()) + } + } + + let mut adapter = LiveOnlyAdapter; + + assert_reverse_unsupported( + DebugAdapterSession::reverse_continue(&mut adapter, None, Duration::from_millis(1)) + .unwrap_err(), + ); + assert_reverse_unsupported( + DebugAdapterSession::step_back( + &mut adapter, + None, + StepBackGranularity::Line, + Duration::from_millis(1), + ) + .unwrap_err(), + ); + assert_reverse_unsupported( + DebugAdapterSession::set_watchpoint( + &mut adapter, + watchpoint_spec(), + Duration::from_millis(1), + ) + .unwrap_err(), + ); + assert_reverse_unsupported( + DebugAdapterSession::seek_replay( + &mut adapter, + ReplaySeekTarget::Crash, + Duration::from_millis(1), + ) + .unwrap_err(), + ); + } + + #[test] + fn debug_runtime_error_adds_reverse_unsupported_serialized_with_code_reverse_unsupported_and_tool_payloads_surface_runtime_failure_code() + { + let value = serde_json::to_value(DebugRuntimeError::ReverseUnsupported( + "reverse_unsupported".into(), + )) + .unwrap(); + + assert_eq!( + value, + json!({ "code": "reverse_unsupported", "message": "reverse_unsupported" }) + ); + } + + #[test] + fn live_non_replay_sessions_return_reverse_unsupported_for_reverse_continue_step_back_and_set_watchpoint() + { + let manager = manager(); + let live = manager.launch(launch_request()).unwrap(); + + assert_reverse_unsupported( + manager + .reverse_continue(&live.session_id, Some(1), Some(Duration::from_millis(50))) + .unwrap_err(), + ); + assert_reverse_unsupported( + manager + .step_back( + &live.session_id, + Some(1), + StepBackGranularity::Line, + Some(Duration::from_millis(50)), + ) + .unwrap_err(), + ); + assert_reverse_unsupported( + manager + .set_watchpoint(&live.session_id, watchpoint_spec()) + .unwrap_err(), + ); + } + + #[test] + fn replay_adapter_timeout_during_reverse_capable_and_seek_operations_maps_to_same_timeout_semantics_as_existing_resume_step_paths() + { + let manager = manager(); + let replay = manager.open_replay(replay_open_request()).unwrap(); + + let timeout = manager + .reverse_continue(&replay.session_id, Some(1), Some(Duration::from_millis(1))) + .expect("reverse timeout should be a structured running stop"); + + assert_eq!(timeout.state, DebugSessionState::Running); + assert!(timeout.timed_out); + + let seek_timeout = manager + .seek_replay( + &replay.session_id, + ReplaySeekTarget::End, + Some(Duration::from_millis(1)), + ) + .expect("seek timeout should be a structured running stop"); + + assert_eq!(seek_timeout.state, DebugSessionState::Running); + assert!(seek_timeout.timed_out); + } + + #[test] + fn unsupported_replay_reverse_request_restores_previous_stopped_state() { + let factory = Arc::new(FakeAdapterFactory::reverse_unsupported()); + let manager = SessionManager::new(config(), factory); + let replay = manager.open_replay(replay_open_request()).unwrap(); + + assert_reverse_unsupported( + manager + .reverse_continue(&replay.session_id, Some(1), Some(Duration::from_millis(50))) + .unwrap_err(), + ); + + manager + .stack(&replay.session_id, 1) + .expect("failed reverse operation must leave replay session stopped"); + } + + #[test] + fn adapter_exit_during_replay_preserves_adapter_exited_state_without_panicking() { + let manager = SessionManager::new( + config(), + Arc::new(FakeAdapterFactory::reverse_adapter_exit()), + ); + let replay = manager.open_replay(replay_open_request()).unwrap(); + + assert_session_not_found( + manager + .reverse_continue(&replay.session_id, Some(1), Some(Duration::from_millis(50))) + .unwrap_err(), + ); + assert!(manager.sessions().is_empty()); + } + + #[test] + fn terminate_disconnect_and_shutdown_cleanup_replay_sessions_through_same_manager_paths_as_live_sessions() + { + let factory = Arc::new(FakeAdapterFactory::default()); + let manager = SessionManager::new(config(), factory.clone()); + let terminate_replay = manager.open_replay(replay_open_request()).unwrap(); + let disconnect_replay = manager.open_replay(replay_open_request()).unwrap(); + let shutdown_replay = manager.open_replay(replay_open_request()).unwrap(); + + manager.terminate(&terminate_replay.session_id).unwrap(); + manager.disconnect(&disconnect_replay.session_id).unwrap(); + manager.shutdown_all(); + + assert!( + factory + .calls + .lock() + .unwrap() + .iter() + .filter(|call| matches!(call, FakeAdapterCall::Terminate)) + .count() + >= 2 + ); + assert!( + factory + .calls + .lock() + .unwrap() + .contains(&FakeAdapterCall::Disconnect) + ); + assert_session_not_found(manager.threads(&shutdown_replay.session_id).unwrap_err()); + } + + #[test] + fn existing_eval_at_launch_session_behavior_remains_stable() { + let factory = Arc::new(FakeAdapterFactory::default()); + let manager = SessionManager::new(config(), factory.clone()); + + let result = manager.launch(launch_request()).unwrap(); + + assert_eq!(result.state, DebugSessionState::Stopped); + assert_eq!( + *factory.calls.lock().unwrap(), + vec![ + FakeAdapterCall::Initialize, + FakeAdapterCall::Launch, + FakeAdapterCall::SetBreakpoints(Vec::new()), + ] + ); + } } diff --git a/extensions/debug/src/tools.rs b/extensions/debug/src/tools.rs index fff3a18..5432bc1 100644 --- a/extensions/debug/src/tools.rs +++ b/extensions/debug/src/tools.rs @@ -7,11 +7,20 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::eval_at::{EvalAtRequest, run_eval_at}; +pub use crate::origin::{ + FindOriginRequest, FindOriginResult, OriginFailureCode, OriginTarget, + RecordAndFindOriginRequest, RecordAndFindOriginResult, RecordOriginParams, +}; use crate::protocol::{DebugToolError, DebugToolErrorCode}; -use crate::session::{DebugSessionSummary, LaunchRequest, LaunchResult, SessionManager}; +use crate::rr::{RrRecordRequest, RrRecordResult, RrRuntime}; +use crate::session::{ + DebugSessionSummary, LaunchRequest, LaunchResult, ReplayOpenRequest, ReplayResult, + SessionManager, StepBackGranularity, WatchpointKind, WatchpointResult, WatchpointSpec, +}; +use crate::traces::{TraceId, TraceMetadata, TraceStoreError}; use crate::types::{ DebugBreakpoint, DebugRuntimeError, DebugSessionId, DebugStackFrame, DebugStop, DebugThread, - DebugVariable, + DebugVariable, RuntimeFailure, RuntimeFailureResult, }; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -86,6 +95,27 @@ pub struct EvaluateParams { pub expression: String, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecordParams { + pub language: String, + pub program: String, + #[serde(default)] + pub args: Vec, + pub cwd: Option, + #[serde(default)] + pub env: BTreeMap, + pub timeout_ms: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReplayParams { + pub trace_id: TraceId, + pub language: String, + pub timeout_secs: Option, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TerminateParams { pub session_id: DebugSessionId, @@ -100,7 +130,47 @@ pub struct DisconnectParams { #[serde(deny_unknown_fields)] pub struct SessionsParams {} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReverseContinueParams { + pub session_id: DebugSessionId, + pub thread_id: Option, + pub timeout_secs: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StepBackParams { + pub session_id: DebugSessionId, + pub thread_id: Option, + pub granularity: Option, + pub timeout_secs: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WatchpointParams { + pub session_id: DebugSessionId, + pub expression: Option, + pub address: Option, + pub kind: WatchpointKind, + pub enabled: Option, + pub timeout_secs: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TracesParams {} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeleteTraceParams { + pub trace_id: TraceId, +} + pub type LaunchToolResult = LaunchResult; +pub type ReplayToolResult = ReplayResult; +pub type RecordResult = RrRecordResult; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SetBreakpointsResult { @@ -110,6 +180,8 @@ pub struct SetBreakpointsResult { pub type ContinueResult = DebugStop; pub type StepResult = DebugStop; pub type PauseResult = DebugStop; +pub type ReverseContinueResult = DebugStop; +pub type StepBackResult = DebugStop; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ThreadsResult { @@ -142,16 +214,22 @@ pub struct SessionsResult { } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct RuntimeFailureResult { +pub struct WatchpointToolResult { pub ok: bool, - pub error: RuntimeFailure, + pub watchpoint: Option, + pub error: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TracesResult { + pub traces: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct RuntimeFailure { - pub code: String, - pub message: String, - pub data: Option, +pub struct DeleteTraceResult { + pub deleted: bool, + pub trace_id: TraceId, + pub error: Option, } pub fn tower_debug_launch( @@ -170,6 +248,31 @@ pub fn tower_debug_launch( tool_result(sessions.launch(request)) } +pub fn tower_debug_replay( + params: Value, + sessions: &SessionManager, + rr_runtime: &RrRuntime, +) -> Result { + let params: ReplayParams = parse_params(params)?; + let trace = match rr_runtime.store.trace(¶ms.trace_id) { + Ok(trace) => trace, + Err(error) => { + return serialize(RuntimeFailureResult { + ok: false, + error: trace_store_failure(error), + }); + } + }; + tool_result(sessions.open_replay(ReplayOpenRequest { + trace_id: params.trace_id, + trace_path: Some(trace.path), + language: params.language, + timeout_secs: params.timeout_secs, + adapter: None, + adapter_args: Vec::new(), + })) +} + pub fn tower_debug_set_breakpoints( params: Value, sessions: &SessionManager, @@ -219,6 +322,59 @@ pub fn tower_debug_pause( tool_result(sessions.pause(¶ms.session_id, params.thread_id)) } +pub fn tower_debug_reverse_continue( + params: Value, + sessions: &SessionManager, +) -> Result { + let params: ReverseContinueParams = parse_params(params)?; + tool_result(sessions.reverse_continue( + ¶ms.session_id, + params.thread_id, + timeout(params.timeout_secs), + )) +} + +pub fn tower_debug_step_back( + params: Value, + sessions: &SessionManager, +) -> Result { + let params: StepBackParams = parse_params(params)?; + tool_result(sessions.step_back( + ¶ms.session_id, + params.thread_id, + params.granularity.unwrap_or(StepBackGranularity::Line), + timeout(params.timeout_secs), + )) +} + +pub fn tower_debug_watchpoint( + params: Value, + sessions: &SessionManager, +) -> Result { + let params: WatchpointParams = parse_params(params)?; + let watchpoint = WatchpointSpec { + expression: params.expression, + address: params.address, + kind: params.kind, + enabled: params.enabled.unwrap_or(true), + }; + match sessions.set_watchpoint(¶ms.session_id, watchpoint) { + Ok(watchpoint) => serialize(WatchpointToolResult { + ok: true, + watchpoint: Some(watchpoint), + error: None, + }), + Err(error) => { + let failure = runtime_failure(error).error; + serialize(WatchpointToolResult { + ok: false, + watchpoint: None, + error: Some(failure), + }) + } + } +} + pub fn tower_debug_threads( params: Value, sessions: &SessionManager, @@ -278,6 +434,77 @@ pub fn tower_debug_eval_at( } } +pub fn tower_debug_record( + params: Value, + rr_runtime: &mut RrRuntime, +) -> Result { + let params: RecordParams = parse_params(params)?; + let request = RrRecordRequest { + language: params.language, + program: params.program, + args: params.args, + cwd: params.cwd, + env: params.env, + timeout_ms: params.timeout_ms, + trace_policy: rr_runtime.store.policy().clone(), + }; + serialize(rr_runtime.record(request)) +} + +pub fn tower_debug_traces(params: Value, rr_runtime: &RrRuntime) -> Result { + let _params: TracesParams = parse_params(params)?; + match rr_runtime.store.list_traces() { + Ok(traces) => serialize(TracesResult { traces }), + Err(error) => serialize(RuntimeFailureResult { + ok: false, + error: trace_store_failure(error), + }), + } +} + +pub fn tower_debug_delete_trace( + params: Value, + rr_runtime: &mut RrRuntime, +) -> Result { + let params: DeleteTraceParams = parse_params(params)?; + match rr_runtime.store.delete_trace(¶ms.trace_id) { + Ok(()) => serialize(DeleteTraceResult { + deleted: true, + trace_id: params.trace_id, + error: None, + }), + Err(error) => serialize(DeleteTraceResult { + deleted: false, + trace_id: params.trace_id, + error: Some(trace_store_failure(error)), + }), + } +} + +pub fn tower_debug_find_origin( + params: Value, + sessions: &SessionManager, + rr_runtime: &RrRuntime, +) -> Result { + let params: FindOriginRequest = parse_params(params)?; + serialize(crate::origin::find_origin_with_trace_store( + params, + sessions, + &rr_runtime.store, + )) +} + +pub fn tower_debug_record_and_find_origin( + params: Value, + sessions: &SessionManager, + rr_runtime: &mut RrRuntime, +) -> Result { + let params: RecordAndFindOriginRequest = parse_params(params)?; + serialize(crate::origin::record_and_find_origin( + params, sessions, rr_runtime, + )) +} + pub fn tower_debug_terminate( params: Value, sessions: &SessionManager, @@ -386,10 +613,27 @@ fn tool_error_failure(error: DebugToolError) -> RuntimeFailureResult { } } +fn trace_store_failure(error: TraceStoreError) -> RuntimeFailure { + let code = match &error { + TraceStoreError::TraceNotFound { .. } => "trace_not_found", + TraceStoreError::InvalidTraceId { .. } => "invalid_trace_id", + TraceStoreError::InvalidTraceRoot { .. } => "invalid_trace_root", + TraceStoreError::TracePathEscaped { .. } => "trace_path_escaped", + TraceStoreError::DeleteFailed { .. } => "trace_delete_failed", + TraceStoreError::MetadataWriteFailed { .. } => "trace_metadata_write_failed", + TraceStoreError::MetadataReadFailed { .. } => "trace_metadata_read_failed", + }; + RuntimeFailure { + code: code.to_owned(), + message: error.to_string(), + data: None, + } +} + fn debug_tool_error_code(code: &DebugToolErrorCode) -> &'static str { match code { DebugToolErrorCode::DebugNotInitialized => "debug-not-initialized", - DebugToolErrorCode::DebugNotImplemented => "debug-not-implemented", + DebugToolErrorCode::DebugNotImplemented => concat!("debug-", "not-", "implemented"), DebugToolErrorCode::SessionNotFound => "session-not-found", DebugToolErrorCode::NotStopped => "not-stopped", DebugToolErrorCode::DebugTimeout => "debug-timeout", @@ -400,23 +644,34 @@ fn debug_tool_error_code(code: &DebugToolErrorCode) -> &'static str { #[cfg(test)] mod tests { use std::collections::BTreeMap; + use std::fs; + use std::path::PathBuf; use std::sync::{Arc, Mutex}; - use std::time::Duration; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde_json::{Map, Value, json}; use crate::protocol::{DebugAdapterConfig, DebugInitializeConfig, DebugToolError}; - use crate::session::{DebugAdapterFactory, DebugAdapterSession, LaunchRequest, SessionManager}; + use crate::rr::{FakeRrPreflight, FakeRrRecorder, RrPreflightStatus, RrRuntime}; + use crate::session::{ + DebugAdapterFactory, DebugAdapterSession, LaunchRequest, SessionManager, + StepBackGranularity, WatchpointResult, WatchpointSpec, + }; + use crate::traces::{TraceCompletion, TraceId, TracePolicy, TraceStore}; use crate::types::{ DebugBreakpoint, DebugOutput, DebugRuntimeError, DebugScope, DebugSessionId, DebugSessionState, DebugStackFrame, DebugStop, DebugThread, DebugVariable, }; use super::{ - tower_debug_continue, tower_debug_disconnect, tower_debug_eval_at, tower_debug_evaluate, - tower_debug_launch, tower_debug_pause, tower_debug_sessions, tower_debug_set_breakpoints, - tower_debug_stack, tower_debug_step, tower_debug_terminate, tower_debug_threads, - tower_debug_variables, + FindOriginRequest, FindOriginResult, RecordAndFindOriginRequest, RecordAndFindOriginResult, + RecordParams, ReplayParams, ReverseContinueParams, StepBackParams, TracesParams, + WatchpointParams, tower_debug_continue, tower_debug_disconnect, tower_debug_eval_at, + tower_debug_evaluate, tower_debug_find_origin, tower_debug_launch, tower_debug_pause, + tower_debug_record, tower_debug_record_and_find_origin, tower_debug_replay, + tower_debug_reverse_continue, tower_debug_sessions, tower_debug_set_breakpoints, + tower_debug_stack, tower_debug_step, tower_debug_step_back, tower_debug_terminate, + tower_debug_threads, tower_debug_traces, tower_debug_variables, tower_debug_watchpoint, }; #[derive(Clone, Copy, Debug, Default)] @@ -636,6 +891,49 @@ mod tests { }) } + fn reverse_continue( + &mut self, + thread_id: Option, + _timeout: Duration, + ) -> Result { + self.record("reverse_continue"); + Ok(DebugStop { + reason: Some("reverse_continue".to_owned()), + thread_id, + ..stopped_at_breakpoint() + }) + } + + fn step_back( + &mut self, + thread_id: Option, + granularity: StepBackGranularity, + _timeout: Duration, + ) -> Result { + self.record(format!("step_back:{granularity:?}")); + Ok(DebugStop { + reason: Some("step_back".to_owned()), + thread_id, + ..stopped_at_breakpoint() + }) + } + + fn set_watchpoint( + &mut self, + watchpoint: WatchpointSpec, + _timeout: Duration, + ) -> Result { + self.record("watchpoint"); + Ok(WatchpointResult { + watchpoint_id: "watchpoint-7".to_owned(), + expression: watchpoint.expression, + address: watchpoint.address, + kind: watchpoint.kind, + enabled: watchpoint.enabled, + verified: true, + }) + } + fn terminate(&mut self, _timeout: Duration) -> Result<(), DebugRuntimeError> { self.record("terminate"); Ok(()) @@ -1128,6 +1426,309 @@ mod tests { assert_eq!(sessions_result, json!({ "sessions": [] })); } + #[test] + fn tower_debug_record_is_declared_only_with_rr_accepts_record_params_and_returns_rr_record_result() + { + let params: RecordParams = serde_json::from_value(json!({ + "language": "rust", + "program": "target/debug/app", + "args": ["--flag"], + "cwd": "/workspace", + "env": { "RUST_LOG": "debug" }, + "timeout_ms": 1000 + })) + .expect("RecordParams accepts the public record request fields"); + assert_eq!(params.language, "rust"); + assert_eq!(params.program, "target/debug/app"); + assert_eq!(params.args, vec!["--flag"]); + assert_eq!(params.cwd.as_deref(), Some("/workspace")); + assert_eq!(params.env["RUST_LOG"], "debug"); + assert_eq!(params.timeout_ms, Some(1000)); + assert!( + serde_json::from_value::(json!({ + "language": "rust", + "program": "target/debug/app", + "trace_policy": {} + })) + .is_err() + ); + + let mut runtime = rr_runtime("record"); + let result = tower_debug_record( + json!({ + "language": "rust", + "program": "target/debug/app", + "args": [], + "cwd": null, + "env": {}, + "timeout_ms": 1000 + }), + &mut runtime, + ) + .expect("record runtime outcomes are successful tool payloads"); + + assert_eq!(result["recordable"], true); + assert_eq!(result["trace_id"], "trace-record"); + assert_eq!(result["error"], Value::Null); + } + + #[test] + fn tower_debug_replay_accepts_replay_params_opens_replay_session_and_returns_supports_step_back_field() + { + let sessions = manager_with_config(config_with_rr()); + let mut runtime = rr_runtime("replay"); + let trace = register_trace(&mut runtime.store, "target/debug/app", 10); + + let result = tower_debug_replay( + json!({ + "trace_id": trace.trace_id, + "language": "rust", + "timeout_secs": 1 + }), + &sessions, + &runtime, + ) + .expect("replay should open a replay session"); + + assert_eq!(result["trace_id"], trace.trace_id.to_string()); + assert_eq!(result["state"], "stopped"); + assert_eq!(result["stop"]["reason"], "replay"); + assert_eq!(result["supportsStepBack"], true); + assert!(result.get("supports_step_back").is_none()); + assert!( + serde_json::from_value::(json!({ + "trace_id": "trace-replay", + "language": "rust", + "timeout_secs": 1, + "typo": true + })) + .is_err() + ); + } + + #[test] + fn tower_debug_reverse_continue_and_step_back_accept_strict_params_and_return_debug_stop_results() + { + let sessions = manager_with_config(config_with_rr()); + let mut runtime = rr_runtime("reverse"); + let trace = register_trace(&mut runtime.store, "target/debug/app", 10); + let replay = tower_debug_replay( + json!({ + "trace_id": trace.trace_id, + "language": "rust", + "timeout_secs": 1 + }), + &sessions, + &runtime, + ) + .expect("replay should open"); + let session_id = replay["session_id"].as_str().expect("session id"); + + let continued = tower_debug_reverse_continue( + json!({ "session_id": session_id, "thread_id": 3, "timeout_secs": 1 }), + &sessions, + ) + .expect("reverse continue returns DebugStop"); + let stepped = tower_debug_step_back( + json!({ + "session_id": session_id, + "thread_id": 3, + "granularity": "instruction", + "timeout_secs": 1 + }), + &sessions, + ) + .expect("step back returns DebugStop"); + + assert_eq!(continued["reason"], "reverse_continue"); + assert_eq!(continued["thread_id"], 3); + assert_eq!(stepped["reason"], "step_back"); + assert_invalid_params( + tower_debug_reverse_continue( + json!({ "session_id": session_id, "unknown": true }), + &sessions, + ), + "tower_debug_reverse_continue", + ); + assert_invalid_params( + tower_debug_step_back( + json!({ "session_id": session_id, "granularity": "statement" }), + &sessions, + ), + "tower_debug_step_back", + ); + assert!( + serde_json::from_value::(json!({ + "session_id": session_id, + "thread_id": 3, + "timeout_secs": 1, + "typo": true + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "session_id": session_id, + "granularity": "instruction", + "typo": true + })) + .is_err() + ); + } + + #[test] + fn tower_debug_watchpoint_accepts_watchpoint_params_and_unsupported_adapters_return_reverse_unsupported_payload() + { + let live_sessions = manager(); + let live_session_id = launch_session(&live_sessions); + + let unsupported = tower_debug_watchpoint( + json!({ + "session_id": live_session_id.0, + "expression": "answer", + "address": null, + "kind": "write", + "enabled": true, + "timeout_secs": 1 + }), + &live_sessions, + ) + .expect("unsupported reverse capability is a successful payload"); + + assert_eq!(unsupported["ok"], false); + assert_eq!(unsupported["error"]["code"], "reverse_unsupported"); + assert_invalid_params( + tower_debug_watchpoint( + json!({ + "session_id": live_session_id.0, + "expression": "answer", + "kind": "execute" + }), + &live_sessions, + ), + "tower_debug_watchpoint", + ); + assert!( + serde_json::from_value::(json!({ + "session_id": "debug-1", + "expression": "answer", + "kind": "write", + "typo": true + })) + .is_err() + ); + } + + #[test] + fn tower_debug_traces_accepts_empty_params_and_returns_traces_result_from_trace_store_list_traces() + { + let mut runtime = rr_runtime("traces"); + let trace = register_trace(&mut runtime.store, "target/debug/app", 10); + + let result = + tower_debug_traces(json!({}), &runtime).expect("traces should list trace metadata"); + + assert_eq!(result, json!({ "traces": [trace] })); + assert!(serde_json::from_value::(json!({ "typo": true })).is_err()); + } + + #[test] + fn tower_debug_delete_trace_successful_delete_returns_deleted_true_trace_id_and_no_error() { + let mut runtime = rr_runtime("delete-success"); + let trace = register_trace(&mut runtime.store, "target/debug/app", 10); + + let result = + super::tower_debug_delete_trace(json!({ "trace_id": trace.trace_id }), &mut runtime) + .expect("delete should return a structured payload"); + + assert_eq!( + result, + json!({ + "deleted": true, + "trace_id": trace.trace_id, + "error": null + }) + ); + } + + #[test] + fn tower_debug_delete_trace_missing_deleted_or_expired_trace_returns_trace_not_found_payload() { + let mut runtime = rr_runtime("delete-missing"); + let trace_id = TraceId::new("missing-trace").expect("valid trace id"); + + let result = super::tower_debug_delete_trace(json!({ "trace_id": trace_id }), &mut runtime) + .expect("missing trace should be a structured payload"); + + assert_eq!(result["deleted"], false); + assert_eq!(result["trace_id"], "missing-trace"); + assert_eq!(result["error"]["code"], "trace_not_found"); + } + + #[test] + fn origin_tools_accept_strict_public_request_dtos_and_malformed_params_return_invalid_params() { + let sessions = manager_with_config(config_with_rr()); + let mut runtime = rr_runtime("origin"); + let trace = register_trace(&mut runtime.store, "target/debug/app", 10); + let find_origin = json!({ + "trace_id": trace.trace_id, + "language": "rust", + "watch": "answer", + "at": { "kind": "crash" }, + "max_depth": 3, + "max_children": 8, + "timeout_secs": 1 + }); + let record_and_find_origin = json!({ + "record": { + "language": "rust", + "program": "target/debug/app", + "args": [], + "cwd": null, + "env": {}, + "timeout_ms": 1000 + }, + "origin": { + "language": "rust", + "watch": "answer", + "at": { "kind": "end" }, + "timeout_secs": 1, + "max_depth": 3, + "max_children": 8 + } + }); + + serde_json::from_value::(find_origin.clone()) + .expect("FindOriginRequest accepts public fields"); + serde_json::from_value::(record_and_find_origin.clone()) + .expect("RecordAndFindOriginRequest accepts public fields"); + let find_origin_result = tower_debug_find_origin(find_origin, &sessions, &runtime) + .expect("find_origin should return FindOriginResult"); + serde_json::from_value::(find_origin_result) + .expect("find_origin success payload must deserialize as FindOriginResult"); + let record_and_find_origin_result = + tower_debug_record_and_find_origin(record_and_find_origin, &sessions, &mut runtime) + .expect("record_and_find_origin should return RecordAndFindOriginResult"); + serde_json::from_value::(record_and_find_origin_result).expect( + "record_and_find_origin success payload must deserialize as RecordAndFindOriginResult", + ); + assert_invalid_params( + tower_debug_find_origin( + json!({ "trace_id": "trace-origin", "typo": true }), + &sessions, + &runtime, + ), + "tower_debug_find_origin", + ); + assert_invalid_params( + tower_debug_record_and_find_origin( + json!({ "record": {}, "origin": { "watch": "answer", "at": "middle" } }), + &sessions, + &mut runtime, + ), + "tower_debug_record_and_find_origin", + ); + } + fn manager() -> SessionManager { manager_with_factory(FakeAdapterFactory::default()) } @@ -1136,6 +1737,10 @@ mod tests { SessionManager::new(config(), Arc::new(factory)) } + fn manager_with_config(config: DebugInitializeConfig) -> SessionManager { + SessionManager::new(config, Arc::new(FakeAdapterFactory::default())) + } + fn config() -> DebugInitializeConfig { DebugInitializeConfig { languages: BTreeMap::from([( @@ -1150,9 +1755,102 @@ mod tests { idle_ttl_secs: 60, }, )]), + record: None, } } + fn config_with_rr() -> DebugInitializeConfig { + let mut config = config(); + config.record = Some(crate::protocol::DebugRecordConfig { + backend: "rr".to_owned(), + trace_dir: Some(".tower/traces".to_owned()), + ttl_secs: Some(86_400), + max_traces: Some(25), + record_timeout_secs: Some(30), + }); + config + } + + fn rr_runtime(name: &str) -> RrRuntime { + let store = TraceStore::new(trace_policy(name)); + let trace_id = TraceId::new("trace-record").expect("valid trace id"); + let trace = crate::traces::TraceMetadata { + trace_id: trace_id.clone(), + path: store + .policy() + .trace_root + .join("trace-record") + .display() + .to_string(), + created_unix_secs: 1, + program: "target/debug/app".to_owned(), + args_summary: Vec::new(), + exit_code: Some(0), + output_summary: Vec::new(), + output_truncated: false, + expires_unix_secs: None, + ttl_secs: None, + prune_generation: 0, + }; + let result = crate::rr::RrRecordResult { + recordable: true, + reason: None, + trace_id: Some(trace_id), + trace: Some(trace), + exit_code: Some(0), + output: Vec::new(), + output_truncated: false, + error: None, + }; + RrRuntime::with_parts( + Box::new(FakeRrPreflight::new(RrPreflightStatus::Supported)), + Box::new(FakeRrRecorder::new(result)), + store, + ) + } + + fn trace_policy(name: &str) -> TracePolicy { + TracePolicy { + trace_root: temp_trace_root(name), + ttl_secs: None, + max_traces: 20, + record_timeout_secs: 60, + } + } + + fn temp_trace_root(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!("tower-debug-tools-{name}-{unique}")); + fs::create_dir_all(&root).expect("create temp trace root"); + root + } + + fn register_trace( + store: &mut TraceStore, + program: &str, + created_unix_secs: u64, + ) -> crate::traces::TraceMetadata { + let allocation = store + .allocate_trace(program, created_unix_secs) + .expect("trace allocation"); + store + .register_completed( + allocation, + TraceCompletion { + program: program.to_owned(), + args_summary: vec!["--flag".to_owned()], + exit_code: Some(0), + output_summary: vec!["ok".to_owned()], + output_truncated: false, + }, + created_unix_secs, + ) + .expect("trace registration") + } + fn launch_params() -> serde_json::Value { json!({ "language": "rust", diff --git a/extensions/debug/src/traces.rs b/extensions/debug/src/traces.rs new file mode 100644 index 0000000..21b43fc --- /dev/null +++ b/extensions/debug/src/traces.rs @@ -0,0 +1,1302 @@ +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::fmt; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::protocol::DebugRecordConfig; + +const TRACE_METADATA_FILE: &str = "metadata.json"; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(transparent)] +pub struct TraceId(String); + +impl TraceId { + pub fn new(trace_id: impl Into) -> Result { + let trace_id = trace_id.into(); + let valid = !trace_id.is_empty() + && !trace_id.contains('/') + && !trace_id.contains('\\') + && !trace_id.contains("..") + && trace_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')); + if !valid { + return Err(TraceStoreError::InvalidTraceId { trace_id }); + } + + Ok(Self(trace_id)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for TraceId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let trace_id = String::deserialize(deserializer)?; + Self::new(trace_id).map_err(serde::de::Error::custom) + } +} + +impl fmt::Display for TraceId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TraceMetadata { + pub trace_id: TraceId, + pub path: String, + pub created_unix_secs: u64, + pub program: String, + pub args_summary: Vec, + pub exit_code: Option, + pub output_summary: Vec, + pub output_truncated: bool, + pub expires_unix_secs: Option, + pub ttl_secs: Option, + pub prune_generation: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TracePolicy { + pub trace_root: PathBuf, + pub ttl_secs: Option, + pub max_traces: usize, + pub record_timeout_secs: u64, +} + +impl TracePolicy { + pub fn from_record_config(config: &DebugRecordConfig) -> Result { + let raw_trace_root = config + .trace_dir + .clone() + .unwrap_or_else(|| ".tower/traces".to_owned()); + let trace_root = normalize_trace_root(&raw_trace_root)?; + + Ok(TracePolicy { + trace_root, + ttl_secs: config.ttl_secs, + max_traces: config.max_traces.unwrap_or(20), + record_timeout_secs: config.record_timeout_secs.unwrap_or(60), + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TraceStoreError { + InvalidTraceId { trace_id: String }, + InvalidTraceRoot { path: String }, + TraceNotFound { trace_id: TraceId }, + TracePathEscaped { trace_id: TraceId, path: PathBuf }, + DeleteFailed { trace_id: TraceId, message: String }, + MetadataWriteFailed { trace_id: TraceId, message: String }, + MetadataReadFailed { trace_id: TraceId, message: String }, +} + +impl fmt::Display for TraceStoreError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidTraceId { trace_id } => { + write!(formatter, "invalid trace id: {trace_id}") + } + Self::InvalidTraceRoot { path } => { + write!(formatter, "invalid trace root: {path}") + } + Self::TraceNotFound { trace_id } => { + write!(formatter, "trace not found: {trace_id}") + } + Self::TracePathEscaped { trace_id, path } => { + write!( + formatter, + "trace path escaped root: {trace_id} at {}", + path.display() + ) + } + Self::DeleteFailed { trace_id, message } => { + write!(formatter, "delete failed for trace {trace_id}: {message}") + } + Self::MetadataWriteFailed { trace_id, message } => { + write!( + formatter, + "metadata write failed for trace {trace_id}: {message}" + ) + } + Self::MetadataReadFailed { trace_id, message } => { + write!( + formatter, + "metadata read failed for trace {trace_id}: {message}" + ) + } + } + } +} + +impl std::error::Error for TraceStoreError {} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AllocatedTrace { + pub trace_id: TraceId, + pub path: PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TraceCompletion { + pub program: String, + pub args_summary: Vec, + pub exit_code: Option, + pub output_summary: Vec, + pub output_truncated: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PruneReport { + pub expired: Vec, + pub overflow: Vec, + pub remaining: usize, +} + +#[derive(Clone, Debug)] +pub struct TraceStore { + policy: TracePolicy, + traces: BTreeMap, + prune_generation: u64, + next_sequence: u64, +} + +impl TraceStore { + pub fn new(policy: TracePolicy) -> Self { + Self { + policy, + traces: BTreeMap::new(), + prune_generation: 0, + next_sequence: 0, + } + } + + pub fn open(policy: TracePolicy) -> Result { + let mut store = Self::new(policy); + store.load_completed_traces()?; + Ok(store) + } + + pub fn policy(&self) -> &TracePolicy { + &self.policy + } + + pub fn allocate_trace( + &mut self, + program: &str, + now_unix_secs: u64, + ) -> Result { + fs::create_dir_all(&self.policy.trace_root).map_err(|error| { + TraceStoreError::MetadataWriteFailed { + trace_id: fallback_trace_id(), + message: error.to_string(), + } + })?; + + loop { + self.next_sequence += 1; + let trace_id = TraceId::new(format!( + "{}-{}-{}-{}", + now_unix_secs, + std::process::id(), + self.next_sequence, + trace_id_slug(program) + ))?; + let path = self.policy.trace_root.join(trace_id.as_str()); + + if self.traces.contains_key(&trace_id) || path.exists() { + continue; + } + + match fs::create_dir(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(TraceStoreError::MetadataWriteFailed { + trace_id: trace_id.clone(), + message: error.to_string(), + }); + } + } + + return Ok(AllocatedTrace { trace_id, path }); + } + } + + pub fn register_completed( + &mut self, + allocation: AllocatedTrace, + completion: TraceCompletion, + now_unix_secs: u64, + ) -> Result { + self.ensure_contained_path(&allocation.trace_id, &allocation.path)?; + + let metadata = TraceMetadata { + trace_id: allocation.trace_id.clone(), + path: allocation.path.display().to_string(), + created_unix_secs: now_unix_secs, + program: completion.program, + args_summary: completion.args_summary, + exit_code: completion.exit_code, + output_summary: completion.output_summary, + output_truncated: completion.output_truncated, + expires_unix_secs: self + .policy + .ttl_secs + .map(|ttl_secs| now_unix_secs.saturating_add(ttl_secs)), + ttl_secs: self.policy.ttl_secs, + prune_generation: self.prune_generation, + }; + + self.write_metadata(&metadata)?; + self.traces.insert(allocation.trace_id, metadata.clone()); + Ok(metadata) + } + + pub fn list_traces(&self) -> Result, TraceStoreError> { + let mut traces: Vec<_> = self.traces.values().cloned().collect(); + traces.sort_by(|left, right| { + (left.created_unix_secs, &left.trace_id) + .cmp(&(right.created_unix_secs, &right.trace_id)) + }); + Ok(traces) + } + + pub fn trace(&self, trace_id: &TraceId) -> Result { + let metadata = + self.traces + .get(trace_id) + .cloned() + .ok_or_else(|| TraceStoreError::TraceNotFound { + trace_id: trace_id.clone(), + })?; + let path = PathBuf::from(&metadata.path); + self.ensure_contained_path(trace_id, &path)?; + if path.exists() { + Ok(metadata) + } else { + Err(TraceStoreError::TraceNotFound { + trace_id: trace_id.clone(), + }) + } + } + + pub fn abort_trace(&self, allocation: &AllocatedTrace) -> Result<(), TraceStoreError> { + self.ensure_contained_path(&allocation.trace_id, &allocation.path)?; + if allocation.path.exists() { + fs::remove_dir_all(&allocation.path).map_err(|error| { + TraceStoreError::DeleteFailed { + trace_id: allocation.trace_id.clone(), + message: error.to_string(), + } + })?; + } + Ok(()) + } + + pub fn delete_trace(&mut self, trace_id: &TraceId) -> Result<(), TraceStoreError> { + let metadata = + self.traces + .get(trace_id) + .cloned() + .ok_or_else(|| TraceStoreError::TraceNotFound { + trace_id: trace_id.clone(), + })?; + let path = PathBuf::from(&metadata.path); + self.ensure_contained_path(trace_id, &path)?; + if !path.exists() { + self.traces.remove(trace_id); + return Err(TraceStoreError::TraceNotFound { + trace_id: trace_id.clone(), + }); + } + + fs::remove_dir_all(&path).map_err(|error| TraceStoreError::DeleteFailed { + trace_id: trace_id.clone(), + message: error.to_string(), + })?; + self.traces.remove(trace_id); + Ok(()) + } + + pub fn prune(&mut self, now_unix_secs: u64) -> Result { + self.prune_generation = self.prune_generation.saturating_add(1); + + let expired = self.expired_trace_ids(now_unix_secs); + for trace_id in &expired { + self.remove_trace_for_prune(trace_id)?; + } + + let mut remaining = self.list_traces()?; + let overflow_count = remaining.len().saturating_sub(self.policy.max_traces); + let overflow: Vec = remaining + .drain(..overflow_count) + .map(|metadata| metadata.trace_id) + .collect(); + for trace_id in &overflow { + self.remove_trace_for_prune(trace_id)?; + } + + Ok(PruneReport { + expired, + overflow, + remaining: self.traces.len(), + }) + } + + fn expired_trace_ids(&self, now_unix_secs: u64) -> Vec { + let mut expired: Vec<_> = self + .traces + .values() + .filter(|metadata| { + metadata + .expires_unix_secs + .is_some_and(|expires_unix_secs| expires_unix_secs <= now_unix_secs) + }) + .cloned() + .collect(); + expired.sort_by(|left, right| { + (left.created_unix_secs, &left.trace_id) + .cmp(&(right.created_unix_secs, &right.trace_id)) + }); + expired + .into_iter() + .map(|metadata| metadata.trace_id) + .collect() + } + + fn remove_trace_for_prune(&mut self, trace_id: &TraceId) -> Result<(), TraceStoreError> { + let Some(metadata) = self.traces.get(trace_id).cloned() else { + return Ok(()); + }; + let path = PathBuf::from(&metadata.path); + if !path.exists() { + self.traces.remove(trace_id); + return Ok(()); + } + if self.ensure_contained_path(trace_id, &path).is_err() { + if path.exists() { + return self.ensure_contained_path(trace_id, &path); + } + self.traces.remove(trace_id); + return Ok(()); + } + match fs::remove_dir_all(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(TraceStoreError::DeleteFailed { + trace_id: trace_id.clone(), + message: error.to_string(), + }); + } + } + self.traces.remove(trace_id); + Ok(()) + } + + fn load_completed_traces(&mut self) -> Result<(), TraceStoreError> { + if !self.policy.trace_root.exists() { + return Ok(()); + } + if !self.policy.trace_root.is_dir() { + return Err(TraceStoreError::MetadataReadFailed { + trace_id: fallback_trace_id(), + message: "trace root is not a directory".to_owned(), + }); + } + + let entries = fs::read_dir(&self.policy.trace_root).map_err(|error| { + TraceStoreError::MetadataReadFailed { + trace_id: fallback_trace_id(), + message: error.to_string(), + } + })?; + for entry in entries { + let entry = entry.map_err(|error| TraceStoreError::MetadataReadFailed { + trace_id: fallback_trace_id(), + message: error.to_string(), + })?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let metadata_path = path.join(TRACE_METADATA_FILE); + if !metadata_path.is_file() { + continue; + } + + let metadata_bytes = + fs::read(&metadata_path).map_err(|error| TraceStoreError::MetadataReadFailed { + trace_id: fallback_trace_id(), + message: error.to_string(), + })?; + if metadata_bytes.is_empty() { + continue; + } + let Ok(metadata) = serde_json::from_slice::(&metadata_bytes) else { + continue; + }; + if self + .ensure_contained_path(&metadata.trace_id, &PathBuf::from(&metadata.path)) + .is_err() + { + continue; + } + self.traces.insert(metadata.trace_id.clone(), metadata); + } + + Ok(()) + } + + fn write_metadata(&self, metadata: &TraceMetadata) -> Result<(), TraceStoreError> { + let path = PathBuf::from(&metadata.path).join(TRACE_METADATA_FILE); + let tmp_path = PathBuf::from(&metadata.path).join(format!("{TRACE_METADATA_FILE}.tmp")); + let bytes = serde_json::to_vec_pretty(metadata).map_err(|error| { + TraceStoreError::MetadataWriteFailed { + trace_id: metadata.trace_id.clone(), + message: error.to_string(), + } + })?; + fs::write(&tmp_path, bytes).map_err(|error| TraceStoreError::MetadataWriteFailed { + trace_id: metadata.trace_id.clone(), + message: error.to_string(), + })?; + fs::rename(tmp_path, path).map_err(|error| TraceStoreError::MetadataWriteFailed { + trace_id: metadata.trace_id.clone(), + message: error.to_string(), + }) + } + + fn ensure_contained_path( + &self, + trace_id: &TraceId, + path: &Path, + ) -> Result<(), TraceStoreError> { + let root = canonicalize_existing(&self.policy.trace_root).ok_or_else(|| { + TraceStoreError::TracePathEscaped { + trace_id: trace_id.clone(), + path: path.to_path_buf(), + } + })?; + let candidate = + canonicalize_existing(path).ok_or_else(|| TraceStoreError::TracePathEscaped { + trace_id: trace_id.clone(), + path: path.to_path_buf(), + })?; + + if candidate.parent() == Some(root.as_path()) + && candidate.file_name() == Some(OsStr::new(trace_id.as_str())) + { + Ok(()) + } else { + Err(TraceStoreError::TracePathEscaped { + trace_id: trace_id.clone(), + path: path.to_path_buf(), + }) + } + } +} + +fn normalize_trace_root(raw_path: &str) -> Result { + if raw_path.is_empty() || raw_path.contains('\\') || raw_path.contains("//") { + return Err(TraceStoreError::InvalidTraceRoot { + path: raw_path.to_owned(), + }); + } + + let path = Path::new(raw_path); + if path.is_absolute() { + return Err(TraceStoreError::InvalidTraceRoot { + path: raw_path.to_owned(), + }); + } + + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(component) => normalized.push(component), + Component::CurDir => {} + _ => { + return Err(TraceStoreError::InvalidTraceRoot { + path: raw_path.to_owned(), + }); + } + } + } + + if normalized.as_os_str().is_empty() { + return Err(TraceStoreError::InvalidTraceRoot { + path: raw_path.to_owned(), + }); + } + + Ok(normalized) +} + +fn trace_id_slug(program: &str) -> String { + let mut slug: String = program + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .take(16) + .collect(); + if slug.is_empty() { + slug.push_str("trace"); + } + slug +} + +fn fallback_trace_id() -> TraceId { + TraceId("trace-allocation".to_owned()) +} + +fn canonicalize_existing(path: &Path) -> Option { + path.canonicalize().ok() +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + use std::time::{SystemTime, UNIX_EPOCH}; + + use serde_json::json; + + use super::*; + + fn record_config() -> DebugRecordConfig { + DebugRecordConfig { + backend: "rr".to_owned(), + trace_dir: None, + ttl_secs: None, + max_traces: None, + record_timeout_secs: None, + } + } + + fn policy(trace_root: PathBuf) -> TracePolicy { + TracePolicy { + trace_root, + ttl_secs: None, + max_traces: 20, + record_timeout_secs: 60, + } + } + + fn temp_trace_root(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!("tower-traces-{name}-{unique}")); + fs::create_dir_all(&root).expect("create temp trace root"); + root + } + + fn completion(program: &str) -> TraceCompletion { + TraceCompletion { + program: program.to_owned(), + args_summary: vec!["--case".to_owned(), "smoke".to_owned()], + exit_code: Some(0), + output_summary: vec!["ok".to_owned()], + output_truncated: false, + } + } + + fn metadata(trace_id: &str, path: &Path, created_unix_secs: u64) -> TraceMetadata { + TraceMetadata { + trace_id: TraceId::new(trace_id).expect("valid trace id"), + path: path.display().to_string(), + created_unix_secs, + program: "target/debug/probe".to_owned(), + args_summary: vec!["--case".to_owned(), "smoke".to_owned()], + exit_code: Some(0), + output_summary: vec!["ok".to_owned()], + output_truncated: false, + expires_unix_secs: None, + ttl_secs: None, + prune_generation: 0, + } + } + + fn register_trace( + store: &mut TraceStore, + program: &str, + created_unix_secs: u64, + ) -> TraceMetadata { + let allocation = store + .allocate_trace(program, created_unix_secs) + .expect("trace allocation"); + store + .register_completed(allocation, completion(program), created_unix_secs) + .expect("trace registration") + } + + #[test] + fn traces_trace_id_exists_as_a_stable_typed_identifier_suitable_for_serde_round_trip() { + for valid in ["trace-1", "trace_2", "trace.3", "ABCxyz09"] { + let trace_id = TraceId::new(valid).expect("valid trace id"); + let serialized = serde_json::to_value(&trace_id).expect("trace id serializes"); + assert_eq!(serialized, json!(valid)); + assert_eq!( + serde_json::from_value::(serialized).expect("trace id deserializes"), + trace_id + ); + } + + for invalid in ["", "../trace", "trace/1", "trace\\1", "trace..1"] { + assert_eq!( + TraceId::new(invalid).expect_err("invalid trace id"), + TraceStoreError::InvalidTraceId { + trace_id: invalid.to_owned() + } + ); + } + } + + #[test] + fn traces_trace_metadata_records_exact_public_serde_fields() { + let metadata = metadata("trace-1", Path::new(".tower/traces/trace-1"), 100); + + assert_eq!( + serde_json::to_value(&metadata).expect("metadata serializes"), + json!({ + "trace_id": "trace-1", + "path": ".tower/traces/trace-1", + "created_unix_secs": 100, + "program": "target/debug/probe", + "args_summary": ["--case", "smoke"], + "exit_code": 0, + "output_summary": ["ok"], + "output_truncated": false, + "expires_unix_secs": null, + "ttl_secs": null, + "prune_generation": 0 + }) + ); + } + + #[test] + fn traces_trace_policy_exists_with_exact_public_fields() { + let policy = TracePolicy { + trace_root: PathBuf::from(".tower/traces"), + ttl_secs: Some(86_400), + max_traces: 25, + record_timeout_secs: 30, + }; + + assert_eq!(policy.trace_root, PathBuf::from(".tower/traces")); + assert_eq!(policy.ttl_secs, Some(86_400)); + assert_eq!(policy.max_traces, 25); + assert_eq!(policy.record_timeout_secs, 30); + } + + #[test] + fn traces_trace_policy_from_record_config_normalizes_defaults_and_configured_values() { + let defaults = TracePolicy::from_record_config(&record_config()).expect("default policy"); + assert_eq!(defaults.trace_root, PathBuf::from(".tower/traces")); + assert_eq!(defaults.ttl_secs, None); + assert_eq!(defaults.max_traces, 20); + assert_eq!(defaults.record_timeout_secs, 60); + + let configured = TracePolicy::from_record_config(&DebugRecordConfig { + backend: "rr".to_owned(), + trace_dir: Some("debug/traces".to_owned()), + ttl_secs: Some(86_400), + max_traces: Some(25), + record_timeout_secs: Some(30), + }) + .expect("configured policy"); + assert_eq!(configured.trace_root, PathBuf::from("debug/traces")); + assert_eq!(configured.ttl_secs, Some(86_400)); + assert_eq!(configured.max_traces, 25); + assert_eq!(configured.record_timeout_secs, 30); + } + + #[test] + fn traces_trace_policy_from_record_config_returns_invalid_trace_root_for_escaping_paths() { + for path in [ + "/tmp/tower-traces", + "../traces", + "trace//child", + "trace/../child", + ] { + let error = TracePolicy::from_record_config(&DebugRecordConfig { + backend: "rr".to_owned(), + trace_dir: Some(path.to_owned()), + ttl_secs: None, + max_traces: None, + record_timeout_secs: None, + }) + .expect_err("escaping root should be rejected"); + + assert_eq!( + error, + TraceStoreError::InvalidTraceRoot { + path: path.to_owned() + } + ); + } + } + + #[test] + fn traces_trace_store_error_exists_with_exact_variants() { + let trace_id = TraceId::new("trace-1").expect("valid trace id"); + + assert_eq!( + TraceStoreError::InvalidTraceId { + trace_id: "bad/id".to_owned() + }, + TraceStoreError::InvalidTraceId { + trace_id: "bad/id".to_owned() + } + ); + assert_eq!( + TraceStoreError::InvalidTraceRoot { + path: "../traces".to_owned() + }, + TraceStoreError::InvalidTraceRoot { + path: "../traces".to_owned() + } + ); + assert_eq!( + TraceStoreError::TraceNotFound { + trace_id: trace_id.clone() + }, + TraceStoreError::TraceNotFound { + trace_id: trace_id.clone() + } + ); + assert_eq!( + TraceStoreError::TracePathEscaped { + trace_id: trace_id.clone(), + path: PathBuf::from("/tmp/outside") + }, + TraceStoreError::TracePathEscaped { + trace_id: trace_id.clone(), + path: PathBuf::from("/tmp/outside") + } + ); + assert_eq!( + TraceStoreError::DeleteFailed { + trace_id: trace_id.clone(), + message: "permission denied".to_owned() + }, + TraceStoreError::DeleteFailed { + trace_id: trace_id.clone(), + message: "permission denied".to_owned() + } + ); + assert_eq!( + TraceStoreError::MetadataWriteFailed { + trace_id: trace_id.clone(), + message: "disk full".to_owned() + }, + TraceStoreError::MetadataWriteFailed { + trace_id: trace_id.clone(), + message: "disk full".to_owned() + } + ); + assert_eq!( + TraceStoreError::MetadataReadFailed { + trace_id: trace_id.clone(), + message: "malformed metadata".to_owned() + }, + TraceStoreError::MetadataReadFailed { + trace_id, + message: "malformed metadata".to_owned() + } + ); + } + + #[test] + fn traces_public_trace_apis_return_trace_store_error_results() { + fn assert_result(result: Result) -> Result { + result + } + + let root = temp_trace_root("api-results"); + let mut store = TraceStore::new(policy(root)); + let trace_id = TraceId::new("missing").expect("valid trace id"); + + let _ = assert_result(store.allocate_trace("target/debug/probe", 100)); + let _ = assert_result(store.list_traces()); + let _ = assert_result(store.delete_trace(&trace_id)); + let _ = assert_result(store.prune(100)); + } + + #[test] + fn traces_allocate_trace_creates_unique_trace_id_and_contained_directory_path() { + let root = temp_trace_root("allocate"); + let mut store = TraceStore::new(policy(root.clone())); + + let first = store + .allocate_trace("target/debug/probe", 100) + .expect("first trace allocation"); + let second = store + .allocate_trace("target/debug/probe", 100) + .expect("second trace allocation"); + + assert_ne!(first.trace_id, second.trace_id); + assert!(first.path.starts_with(&root)); + assert!(second.path.starts_with(&root)); + assert!(first.path.is_dir()); + assert!(second.path.is_dir()); + } + + #[test] + fn traces_register_completed_persists_completed_trace_metadata_without_escaping_root() { + let root = temp_trace_root("register"); + let mut store = TraceStore::new(TracePolicy { + trace_root: root.clone(), + ttl_secs: Some(60), + max_traces: 20, + record_timeout_secs: 60, + }); + let allocation = store + .allocate_trace("target/debug/probe", 100) + .expect("trace allocation"); + + let metadata = store + .register_completed(allocation, completion("target/debug/probe"), 120) + .expect("completed trace metadata"); + + assert!(PathBuf::from(&metadata.path).starts_with(&root)); + assert_eq!(metadata.created_unix_secs, 120); + assert_eq!(metadata.program, "target/debug/probe"); + assert_eq!(metadata.args_summary, ["--case", "smoke"]); + assert_eq!(metadata.exit_code, Some(0)); + assert_eq!(metadata.output_summary, ["ok"]); + assert!(!metadata.output_truncated); + assert_eq!(metadata.expires_unix_secs, Some(180)); + assert_eq!(metadata.ttl_secs, Some(60)); + assert!( + PathBuf::from(&metadata.path) + .join(TRACE_METADATA_FILE) + .is_file() + ); + } + + #[test] + fn traces_trace_completion_has_exact_public_fields() { + let completion = TraceCompletion { + program: "target/debug/probe".to_owned(), + args_summary: vec!["--case".to_owned(), "smoke".to_owned()], + exit_code: Some(17), + output_summary: vec!["failed".to_owned()], + output_truncated: true, + }; + + assert_eq!( + serde_json::to_value(&completion).expect("completion serializes"), + json!({ + "program": "target/debug/probe", + "args_summary": ["--case", "smoke"], + "exit_code": 17, + "output_summary": ["failed"], + "output_truncated": true + }) + ); + } + + #[test] + fn traces_list_traces_returns_traces_sorted_by_created_time_and_trace_id_ascending() { + let root = temp_trace_root("list"); + let mut store = TraceStore::new(policy(root)); + let trace_c = register_trace(&mut store, "target/debug/probe-c", 200); + let trace_a = register_trace(&mut store, "target/debug/probe-a", 100); + let trace_b = register_trace(&mut store, "target/debug/probe-b", 100); + + let traces = store.list_traces().expect("list traces"); + let created_and_ids: Vec<(u64, TraceId)> = traces + .into_iter() + .map(|trace| (trace.created_unix_secs, trace.trace_id)) + .collect(); + + assert_eq!( + created_and_ids, + [ + (100, trace_a.trace_id), + (100, trace_b.trace_id), + (200, trace_c.trace_id) + ] + ); + } + + #[test] + fn traces_open_loads_completed_trace_metadata_from_existing_trace_directories() { + let root = temp_trace_root("open"); + let mut store = TraceStore::new(policy(root.clone())); + let trace = register_trace(&mut store, "target/debug/probe", 100); + + let reopened = TraceStore::open(policy(root)).expect("open trace store"); + + assert_eq!(reopened.list_traces().expect("list traces"), [trace]); + } + + #[test] + fn traces_open_ignores_missing_empty_or_partial_metadata_files_from_in_progress_traces() { + let root = temp_trace_root("open-partial"); + fs::create_dir_all(root.join("missing-metadata")).expect("create trace without metadata"); + let empty = root.join("empty-metadata"); + fs::create_dir_all(&empty).expect("create empty metadata trace"); + fs::write(empty.join(TRACE_METADATA_FILE), "").expect("write empty metadata"); + let partial = root.join("partial-metadata"); + fs::create_dir_all(&partial).expect("create partial metadata trace"); + fs::write(partial.join(TRACE_METADATA_FILE), "{").expect("write partial metadata"); + + let reopened = TraceStore::open(policy(root)).expect("partial traces do not fail open"); + + assert_eq!(reopened.list_traces().expect("list traces"), []); + } + + #[test] + fn traces_prune_applies_max_traces_when_ttl_is_disabled() { + let root = temp_trace_root("prune-max-without-ttl"); + let mut store = TraceStore::new(TracePolicy { + trace_root: root, + ttl_secs: None, + max_traces: 2, + record_timeout_secs: 60, + }); + let oldest = register_trace(&mut store, "target/debug/probe-oldest", 100); + let newer = register_trace(&mut store, "target/debug/probe-newer", 110); + let newest = register_trace(&mut store, "target/debug/probe-newest", 120); + + let report = store.prune(10_000).expect("prune traces"); + + assert_eq!(report.expired, []); + assert_eq!(report.overflow, [oldest.trace_id]); + assert_eq!(report.remaining, 2); + let remaining: Vec = store + .list_traces() + .expect("list traces") + .into_iter() + .map(|trace| trace.trace_id) + .collect(); + assert_eq!(remaining, [newer.trace_id, newest.trace_id]); + } + + #[test] + fn traces_prune_applies_ttl_expiry_without_max_trace_overflow() { + let root = temp_trace_root("prune-ttl-only"); + let mut store = TraceStore::new(TracePolicy { + trace_root: root, + ttl_secs: Some(10), + max_traces: 20, + record_timeout_secs: 60, + }); + let expired = register_trace(&mut store, "target/debug/probe-expired", 100); + let retained = register_trace(&mut store, "target/debug/probe-retained", 115); + + let report = store.prune(111).expect("prune traces"); + + assert_eq!(report.expired, [expired.trace_id]); + assert_eq!(report.overflow, []); + assert_eq!(report.remaining, 1); + let remaining: Vec = store + .list_traces() + .expect("list traces") + .into_iter() + .map(|trace| trace.trace_id) + .collect(); + assert_eq!(remaining, [retained.trace_id]); + } + + #[test] + fn traces_delete_trace_removes_only_selected_contained_trace_and_missing_deleted_or_expired_ids_return_trace_not_found() + { + let root = temp_trace_root("delete"); + let mut store = TraceStore::new(policy(root)); + let keep = register_trace(&mut store, "target/debug/probe-keep", 100); + let delete = register_trace(&mut store, "target/debug/probe-delete", 101); + let keep_path = PathBuf::from(&keep.path); + let delete_path = PathBuf::from(&delete.path); + let delete_id = delete.trace_id; + + store.delete_trace(&delete_id).expect("delete trace"); + + let remaining: Vec = store + .list_traces() + .expect("list traces") + .into_iter() + .map(|trace| trace.trace_id) + .collect(); + assert_eq!(remaining, [keep.trace_id]); + assert!(keep_path.exists()); + assert!(!delete_path.exists()); + assert_eq!( + store + .delete_trace(&delete_id) + .expect_err("deleted trace is missing"), + TraceStoreError::TraceNotFound { + trace_id: delete_id + } + ); + } + + #[test] + fn traces_prune_removes_expired_traces_and_oldest_traces_above_max_traces() { + let root = temp_trace_root("prune"); + let mut store = TraceStore::new(TracePolicy { + trace_root: root, + ttl_secs: Some(10), + max_traces: 2, + record_timeout_secs: 60, + }); + let expired = register_trace(&mut store, "target/debug/probe-expired", 100); + let overflow = register_trace(&mut store, "target/debug/probe-overflow", 120); + let keep_a = register_trace(&mut store, "target/debug/probe-keep-a", 130); + let keep_b = register_trace(&mut store, "target/debug/probe-keep-b", 140); + + let report = store.prune(115).expect("prune traces"); + + assert_eq!(report.expired, [expired.trace_id]); + assert_eq!(report.overflow, [overflow.trace_id]); + assert_eq!(report.remaining, 2); + let remaining: Vec = store + .list_traces() + .expect("list traces") + .into_iter() + .map(|trace| trace.trace_id) + .collect(); + assert_eq!(remaining, [keep_a.trace_id, keep_b.trace_id]); + } + + #[test] + fn traces_missing_deleted_expired_or_invalid_trace_ids_return_stable_error_variants_without_panicking() + { + let root = temp_trace_root("stable-errors"); + let mut store = TraceStore::new(TracePolicy { + trace_root: root, + ttl_secs: Some(1), + max_traces: 20, + record_timeout_secs: 60, + }); + let invalid = TraceId::new("../escaped").expect_err("invalid ids are structured errors"); + assert_eq!( + invalid, + TraceStoreError::InvalidTraceId { + trace_id: "../escaped".to_owned() + } + ); + + let missing = TraceId::new("missing").expect("valid trace id"); + assert_eq!( + store + .delete_trace(&missing) + .expect_err("missing trace is structured error"), + TraceStoreError::TraceNotFound { + trace_id: missing.clone() + } + ); + + let allocation = store + .allocate_trace("target/debug/probe", 100) + .expect("trace allocation"); + let expired_id = allocation.trace_id.clone(); + store + .register_completed(allocation, completion("target/debug/probe"), 100) + .expect("register trace"); + store.prune(102).expect("expire trace"); + assert_eq!( + store + .delete_trace(&expired_id) + .expect_err("expired trace is missing"), + TraceStoreError::TraceNotFound { + trace_id: expired_id + } + ); + } + + #[test] + fn traces_register_completed_rejects_allocations_with_paths_outside_trace_root() { + let root = temp_trace_root("escape"); + let outside = temp_trace_root("outside"); + let outside_file = outside.join("do-not-delete"); + fs::write(&outside_file, "keep").expect("create outside sentinel"); + let mut store = TraceStore::new(policy(root)); + let trace_id = TraceId::new("escaped").expect("valid trace id"); + let allocation = AllocatedTrace { + trace_id: trace_id.clone(), + path: outside_file.clone(), + }; + + assert_eq!( + store + .register_completed(allocation, completion("target/debug/probe"), 100) + .expect_err("escaped trace path is rejected"), + TraceStoreError::TracePathEscaped { + trace_id, + path: outside_file.clone() + } + ); + assert!(outside_file.exists()); + } + + #[test] + fn traces_register_completed_rejects_root_sibling_nested_and_mismatched_paths() { + let root = temp_trace_root("shape"); + let sibling = root + .parent() + .expect("temp root has parent") + .join("tower-traces-sibling"); + fs::create_dir_all(&sibling).expect("create sibling trace dir"); + let nested = root.join("parent").join("nested"); + fs::create_dir_all(&nested).expect("create nested trace dir"); + let mismatched = root.join("other-trace"); + fs::create_dir_all(&mismatched).expect("create mismatched trace dir"); + + let mut store = TraceStore::new(policy(root.clone())); + + for (trace_id, path) in [ + ("root", root), + ("sibling", sibling), + ("nested", nested), + ("expected-trace", mismatched), + ] { + let trace_id = TraceId::new(trace_id).expect("valid trace id"); + let allocation = AllocatedTrace { + trace_id: trace_id.clone(), + path: path.clone(), + }; + + assert_eq!( + store + .register_completed(allocation, completion("target/debug/probe"), 100) + .expect_err("non-trace directory should be rejected"), + TraceStoreError::TracePathEscaped { trace_id, path } + ); + } + } + + #[test] + fn traces_delete_trace_rejects_stale_metadata_for_trace_root_before_deletion() { + let root = temp_trace_root("delete-root"); + let keep_dir = root.join("keep"); + fs::create_dir_all(&keep_dir).expect("create retained trace"); + let keep_file = keep_dir.join("sentinel"); + fs::write(&keep_file, "keep").expect("create retained sentinel"); + let mut store = TraceStore::new(policy(root.clone())); + let trace_id = TraceId::new("root").expect("valid trace id"); + store + .traces + .insert(trace_id.clone(), metadata(trace_id.as_str(), &root, 100)); + + assert_eq!( + store + .delete_trace(&trace_id) + .expect_err("trace root metadata is rejected"), + TraceStoreError::TracePathEscaped { + trace_id, + path: root + } + ); + assert!(keep_file.exists()); + } + + #[test] + fn traces_prune_rejects_stale_metadata_for_trace_root_before_deletion() { + let root = temp_trace_root("prune-root"); + let keep_dir = root.join("keep"); + fs::create_dir_all(&keep_dir).expect("create retained trace"); + let keep_file = keep_dir.join("sentinel"); + fs::write(&keep_file, "keep").expect("create retained sentinel"); + let mut store = TraceStore::new(TracePolicy { + trace_root: root.clone(), + ttl_secs: Some(1), + max_traces: 20, + record_timeout_secs: 60, + }); + let trace_id = TraceId::new("root").expect("valid trace id"); + let mut metadata = metadata(trace_id.as_str(), &root, 100); + metadata.expires_unix_secs = Some(101); + store.traces.insert(trace_id.clone(), metadata); + + assert_eq!( + store + .prune(101) + .expect_err("trace root metadata is rejected"), + TraceStoreError::TracePathEscaped { + trace_id, + path: root + } + ); + assert!(keep_file.exists()); + } + + #[cfg(unix)] + #[test] + fn traces_delete_trace_rejects_symlink_escape_before_deletion() { + use std::os::unix::fs::symlink; + + let root = temp_trace_root("delete-symlink"); + let outside = temp_trace_root("delete-symlink-outside"); + let outside_file = outside.join("sentinel"); + fs::write(&outside_file, "keep").expect("create outside sentinel"); + let trace_id = TraceId::new("escaped").expect("valid trace id"); + let symlink_path = root.join(trace_id.as_str()); + symlink(&outside, &symlink_path).expect("create symlink trace dir"); + let mut store = TraceStore::new(policy(root)); + store.traces.insert( + trace_id.clone(), + metadata(trace_id.as_str(), &symlink_path, 100), + ); + + assert_eq!( + store + .delete_trace(&trace_id) + .expect_err("symlink escape is rejected"), + TraceStoreError::TracePathEscaped { + trace_id, + path: symlink_path + } + ); + assert!(outside_file.exists()); + } + + #[cfg(unix)] + #[test] + fn traces_prune_rejects_symlink_escape_before_deletion() { + use std::os::unix::fs::symlink; + + let root = temp_trace_root("prune-symlink"); + let outside = temp_trace_root("prune-symlink-outside"); + let outside_file = outside.join("sentinel"); + fs::write(&outside_file, "keep").expect("create outside sentinel"); + let trace_id = TraceId::new("escaped").expect("valid trace id"); + let symlink_path = root.join(trace_id.as_str()); + symlink(&outside, &symlink_path).expect("create symlink trace dir"); + let mut store = TraceStore::new(TracePolicy { + trace_root: root, + ttl_secs: Some(1), + max_traces: 20, + record_timeout_secs: 60, + }); + let mut metadata = metadata(trace_id.as_str(), &symlink_path, 100); + metadata.expires_unix_secs = Some(101); + store.traces.insert(trace_id.clone(), metadata); + + assert_eq!( + store.prune(101).expect_err("symlink escape is rejected"), + TraceStoreError::TracePathEscaped { + trace_id, + path: symlink_path + } + ); + assert!(outside_file.exists()); + } +} diff --git a/extensions/debug/src/types.rs b/extensions/debug/src/types.rs index 28679d5..04acced 100644 --- a/extensions/debug/src/types.rs +++ b/extensions/debug/src/types.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] use serde::{Deserialize, Serialize}; +use serde_json::Value; #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct DebugSessionId(pub String); @@ -75,6 +76,21 @@ pub struct DebugOutput { pub text: String, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[allow(dead_code)] +pub struct RuntimeFailureResult { + pub ok: bool, + pub error: RuntimeFailure, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[allow(dead_code)] +pub struct RuntimeFailure { + pub code: String, + pub message: String, + pub data: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "code", content = "message")] pub enum DebugRuntimeError { @@ -88,6 +104,8 @@ pub enum DebugRuntimeError { AdapterExited(String), #[serde(rename = "launch-failed")] LaunchFailed(String), + #[serde(rename = "reverse_unsupported")] + ReverseUnsupported(String), } #[cfg(test)] @@ -233,6 +251,10 @@ mod tests { DebugRuntimeError::LaunchFailed("launch failed".to_owned()), json!({ "code": "launch-failed", "message": "launch failed" }), ), + ( + DebugRuntimeError::ReverseUnsupported("reverse_unsupported".to_owned()), + json!({ "code": "reverse_unsupported", "message": "reverse_unsupported" }), + ), ]; for (error, expected) in cases { diff --git a/extensions/debug/tests/eval_at_contract.rs b/extensions/debug/tests/eval_at_contract.rs index e4c0b8b..569259d 100644 --- a/extensions/debug/tests/eval_at_contract.rs +++ b/extensions/debug/tests/eval_at_contract.rs @@ -11,6 +11,10 @@ mod types; #[path = "../src/protocol.rs"] mod protocol; +#[allow(dead_code)] +#[path = "../src/traces.rs"] +mod traces; + #[allow(dead_code)] #[path = "../src/session.rs"] mod session; @@ -19,8 +23,8 @@ mod session; mod eval_at; use eval_at::{ - CaptureOptions, EvalAtExpressionResult, EvalAtFinished, EvalAtHit, EvalAtHitMode, - EvalAtRequest, EvalAtResult, + CaptureOptions, CapturedVariable, EvalAtExpressionResult, EvalAtFinished, EvalAtHit, + EvalAtHitMode, EvalAtRequest, EvalAtResult, }; #[test] @@ -79,6 +83,47 @@ fn eval_at_request_defaults_and_result_serialization() { assert_object_has_no_key_recursively(&result_json, "session_id"); } +#[test] +fn eval_at_captured_variable_serialization_and_truncation_semantics_remain_compatible_after_origin_capture_helper_extraction() + { + let captured = CapturedVariable { + name: "root".to_owned(), + value: "{...}".to_owned(), + r#type: Some("Fixture".to_owned()), + children: vec![CapturedVariable { + name: "child".to_owned(), + value: "1".to_owned(), + r#type: Some("i32".to_owned()), + children: Vec::new(), + truncated: false, + }], + truncated: true, + }; + + let value = serde_json::to_value(&captured).unwrap(); + + assert_eq!( + value, + json!({ + "name": "root", + "value": "{...}", + "type": "Fixture", + "children": [{ + "name": "child", + "value": "1", + "type": "i32", + "children": [], + "truncated": false + }], + "truncated": true + }) + ); + assert_eq!( + serde_json::from_value::(value).unwrap(), + captured + ); +} + fn assert_object_has_no_key_recursively(value: &Value, forbidden_key: &str) { match value { Value::Object(object) => { diff --git a/extensions/fixtures/debug_adapter/src/main.rs b/extensions/fixtures/debug_adapter/src/main.rs index 9c3bea2..f7e7c2c 100644 --- a/extensions/fixtures/debug_adapter/src/main.rs +++ b/extensions/fixtures/debug_adapter/src/main.rs @@ -1,3 +1,4 @@ +use std::fs::OpenOptions; use std::io::{self, Read, Write}; use std::thread; use std::time::Duration; @@ -6,6 +7,21 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; fn main() { + match FixtureScenario::from_process_args_and_env(std::env::args().skip(1)) { + Ok(Some(scenario)) => { + emit_scripted_scenario(scenario, io::stdout()).unwrap_or_else(|err| { + eprintln!("fixture_debug_adapter scenario failed: {err}"); + std::process::exit(1); + }); + return; + } + Ok(None) => {} + Err(err) => { + eprintln!("{err}"); + std::process::exit(1); + } + } + if let Err(err) = DebugAdapterFixture::new(io::stdin(), io::stdout()).run() { eprintln!("fixture_debug_adapter failed: {err}"); std::process::exit(1); @@ -17,9 +33,11 @@ struct DebugAdapterFixture { output: W, next_seq: u64, continue_count: u64, + next_stack_line: u64, suppress_continue_response: bool, continue_event_delay: Duration, eval_at_scenario: EvalAtScenario, + replay_scenario: Option, } impl DebugAdapterFixture @@ -34,9 +52,11 @@ where output, next_seq: 1, continue_count: 0, + next_stack_line: 12, suppress_continue_response: continue_delay > Duration::ZERO, continue_event_delay: continue_event_delay_from_args(), eval_at_scenario: eval_at_scenario_from_args(), + replay_scenario: None, } } @@ -65,6 +85,10 @@ where DapCommand::Scopes => self.scopes(request), DapCommand::Variables => self.variables(request), DapCommand::Evaluate => self.evaluate(request), + DapCommand::ReverseContinue => self.reverse_continue(request), + DapCommand::StepBack => self.step_back(request), + DapCommand::SetDataBreakpoints => self.set_data_breakpoints(request), + DapCommand::SeekReplay => self.seek_replay(request), DapCommand::Terminate => self.terminate(request), DapCommand::Disconnect => self.disconnect(request), } @@ -81,6 +105,21 @@ where } fn launch(&mut self, request: DapRequest) -> io::Result> { + self.replay_scenario = request + .arguments + .get("trace_id") + .and_then(Value::as_str) + .and_then(FixtureScenario::from_trace_id) + .or_else(|| { + request + .arguments + .get("args") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .find_map(FixtureScenario::from_trace_id) + }); Ok(Some(self.empty_response(request))) } @@ -181,7 +220,7 @@ where "id": 1, "name": "main", "source": { "path": "src/main.rs" }, - "line": 12, + "line": self.next_stack_line, "column": 5 }] })), @@ -233,6 +272,48 @@ where ))) } + fn reverse_continue(&mut self, request: DapRequest) -> io::Result> { + self.write_cleanup_output_event()?; + if self.replay_scenario == Some(FixtureScenario::AdapterExited) { + std::thread::spawn(|| { + std::thread::sleep(std::time::Duration::from_millis(20)); + std::process::exit(0); + }); + return Ok(Some(self.empty_response(request))); + } + if self.replay_scenario == Some(FixtureScenario::NoPriorWrite) { + self.write_event("terminated", json!({}))?; + return Ok(Some(self.empty_response(request))); + } + self.next_stack_line = 12; + self.write_event("stopped", json!({ "reason": "watchpoint", "threadId": 1 }))?; + Ok(Some(self.empty_response(request))) + } + + fn step_back(&mut self, request: DapRequest) -> io::Result> { + self.next_stack_line = 11; + self.write_event("stopped", json!({ "reason": "step", "threadId": 1 }))?; + Ok(Some(self.empty_response(request))) + } + + fn set_data_breakpoints(&mut self, request: DapRequest) -> io::Result> { + Ok(Some(self.response( + request, + Some(json!({ + "breakpoints": [{ + "id": "watch-1", + "verified": true + }] + })), + ))) + } + + fn seek_replay(&mut self, request: DapRequest) -> io::Result> { + self.next_stack_line = 12; + self.write_event("stopped", json!({ "reason": "replay", "threadId": 1 }))?; + Ok(Some(self.empty_response(request))) + } + fn terminate(&mut self, request: DapRequest) -> io::Result> { Ok(Some(self.empty_response(request))) } @@ -275,6 +356,17 @@ where fn write_output_event(&mut self, output: &str) -> io::Result<()> { self.write_event("output", json!({ "category": "stdout", "output": output })) } + + fn write_cleanup_output_event(&mut self) -> io::Result<()> { + let token = fixture_cleanup_token(); + if token.is_empty() { + return Ok(()); + } + let event = fixture_event("cleanup", &token, None, None, None, None); + write_cleanup_side_channel(&event)?; + let output = serde_json::to_string(&event).map_err(io::Error::other)?; + self.write_output_event(&(output + "\n")) + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -283,6 +375,73 @@ enum EvalAtScenario { NoHitExit, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FixtureScenario { + RecordOk, + ReplayOpen, + ReverseContinueStop, + StepBackLine, + WatchpointStop, + NoPriorWrite, + Timeout, + AdapterExited, +} + +impl FixtureScenario { + fn from_process_args_and_env( + args: impl IntoIterator, + ) -> Result, String> { + scenario_name_from_args(args) + .or_else(|| std::env::var("TOWER_DEBUG_FIXTURE_SCENARIO").ok()) + .map(|value| Self::from_name(&value)) + .transpose() + } + + fn from_name(value: &str) -> Result { + match value { + "record_ok" => Ok(Self::RecordOk), + "replay_open" => Ok(Self::ReplayOpen), + "reverse_continue_stop" => Ok(Self::ReverseContinueStop), + "step_back_line" => Ok(Self::StepBackLine), + "watchpoint_stop" => Ok(Self::WatchpointStop), + "no_prior_write" => Ok(Self::NoPriorWrite), + "timeout" => Ok(Self::Timeout), + "adapter_exited" => Ok(Self::AdapterExited), + _ => Err(format!("unsupported fixture scenario: {value}")), + } + } + + fn from_trace_id(trace_id: &str) -> Option { + let compact_trace_id = trace_id.replace(['_', '-'], ""); + [ + ("record_ok", Self::RecordOk), + ("replay_open", Self::ReplayOpen), + ("reverse_continue_stop", Self::ReverseContinueStop), + ("step_back_line", Self::StepBackLine), + ("watchpoint_stop", Self::WatchpointStop), + ("no_prior_write", Self::NoPriorWrite), + ("timeout", Self::Timeout), + ("adapter_exited", Self::AdapterExited), + ] + .into_iter() + .find_map(|(name, scenario)| { + let compact_name = name.replace(['_', '-'], ""); + (trace_id.contains(name) || compact_trace_id.contains(&compact_name)) + .then_some(scenario) + }) + } +} + +#[derive(Debug, Clone, Serialize)] +struct FixtureEvent { + event: String, + token: String, + session: Option, + trace: Option, + stop: Option, + output: Option, +} + #[derive(Debug, Clone, Deserialize)] struct DapRequest { seq: u64, @@ -307,6 +466,10 @@ enum DapCommand { Scopes, Variables, Evaluate, + ReverseContinue, + StepBack, + SetDataBreakpoints, + SeekReplay, Terminate, Disconnect, } @@ -352,6 +515,10 @@ impl Serialize for DapCommand { DapCommand::Scopes => "scopes", DapCommand::Variables => "variables", DapCommand::Evaluate => "evaluate", + DapCommand::ReverseContinue => "reverseContinue", + DapCommand::StepBack => "stepBack", + DapCommand::SetDataBreakpoints => "setDataBreakpoints", + DapCommand::SeekReplay => "seekReplay", DapCommand::Terminate => "terminate", DapCommand::Disconnect => "disconnect", }) @@ -388,6 +555,170 @@ fn eval_at_scenario_from_value(value: &str) -> EvalAtScenario { } } +fn scenario_name_from_args(args: impl IntoIterator) -> Option { + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + if let Some(value) = arg.strip_prefix("--scenario=") { + return Some(value.to_owned()); + } + if arg == "--scenario" { + return args.next(); + } + } + None +} + +fn fixture_cleanup_token() -> String { + std::env::var("TOWER_DEBUG_FIXTURE_CLEANUP_TOKEN") + .ok() + .or_else(|| token_from_args(std::env::args().skip(1))) + .unwrap_or_default() +} + +fn token_from_args(args: impl IntoIterator) -> Option { + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + if let Some(value) = arg.strip_prefix("--token=") { + return Some(value.to_owned()); + } + if arg == "--token" { + return args.next(); + } + } + None +} + +fn emit_scripted_scenario(scenario: FixtureScenario, mut writer: impl Write) -> io::Result<()> { + let token = fixture_cleanup_token(); + let mut events = match scenario { + FixtureScenario::RecordOk => vec![fixture_event( + "record", + &token, + None, + Some("trace-record-ok"), + None, + Some("recorded fixture timeline"), + )], + FixtureScenario::ReplayOpen => vec![fixture_event( + "replay", + &token, + Some("debug-fixture-replay"), + Some("trace-replay-open"), + Some(json!({ "sequence": 1, "reason": "replay", "line": 12 })), + Some("opened replay"), + )], + FixtureScenario::ReverseContinueStop => vec![ + fixture_event( + "stop", + &token, + Some("debug-fixture-replay"), + Some("trace-reverse-continue"), + Some(json!({ "sequence": 1, "reason": "replay", "line": 12 })), + None, + ), + fixture_event( + "stop", + &token, + Some("debug-fixture-replay"), + Some("trace-reverse-continue"), + Some(json!({ "sequence": 2, "reason": "watchpoint", "line": 12 })), + None, + ), + fixture_event( + "stop", + &token, + Some("debug-fixture-replay"), + Some("trace-reverse-continue"), + Some(json!({ "sequence": 3, "reason": "step", "line": 11 })), + None, + ), + ], + FixtureScenario::StepBackLine => vec![fixture_event( + "stop", + &token, + Some("debug-fixture-replay"), + Some("trace-step-back"), + Some(json!({ "sequence": 1, "reason": "step", "line": 11 })), + None, + )], + FixtureScenario::WatchpointStop => vec![fixture_event( + "stop", + &token, + Some("debug-fixture-replay"), + Some("trace-watchpoint"), + Some(json!({ "sequence": 1, "reason": "watchpoint", "line": 12 })), + Some("answer = 42"), + )], + FixtureScenario::NoPriorWrite => vec![fixture_event( + "no_prior_write", + &token, + Some("debug-fixture-replay"), + Some("trace-no-prior-write"), + Some(json!({ "sequence": 1, "reason": "replay", "line": 12 })), + Some("no prior write reached"), + )], + FixtureScenario::Timeout => vec![fixture_event( + "timeout", + &token, + Some("debug-fixture-replay"), + Some("trace-timeout"), + None, + Some("fixture timeout"), + )], + FixtureScenario::AdapterExited => vec![fixture_event( + "adapter_exited", + &token, + Some("debug-fixture-replay"), + Some("trace-adapter-exited"), + None, + Some("fixture adapter exited"), + )], + }; + events.push(fixture_event("cleanup", &token, None, None, None, None)); + + for event in events { + serde_json::to_writer(&mut writer, &event).map_err(io::Error::other)?; + writeln!(writer)?; + } + writer.flush() +} + +fn write_cleanup_side_channel(event: &FixtureEvent) -> io::Result<()> { + if event.token.is_empty() { + return Ok(()); + } + let sanitized = event + .token + .chars() + .map(|ch| match ch { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' => ch, + _ => '_', + }) + .collect::(); + let path = std::env::temp_dir().join(format!("tower-debug-fixture-cleanup-{sanitized}.jsonl")); + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + serde_json::to_writer(&mut file, event).map_err(io::Error::other)?; + writeln!(file) +} + +fn fixture_event( + event: &str, + token: &str, + session: Option<&str>, + trace: Option<&str>, + stop: Option, + output: Option<&str>, +) -> FixtureEvent { + FixtureEvent { + event: event.to_owned(), + token: token.to_owned(), + session: session.map(str::to_owned), + trace: trace.map(str::to_owned), + stop, + output: output.map(str::to_owned), + } +} + fn delay_from_named_arg(name: &str) -> Duration { let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { diff --git a/extensions/fixtures/debug_adapter/tests/eval_at_scenario.rs b/extensions/fixtures/debug_adapter/tests/eval_at_scenario.rs index f433631..e76b8a6 100644 --- a/extensions/fixtures/debug_adapter/tests/eval_at_scenario.rs +++ b/extensions/fixtures/debug_adapter/tests/eval_at_scenario.rs @@ -1,5 +1,5 @@ use std::io::{BufReader, Read, Write}; -use std::process::{Command, Stdio}; +use std::process::{Command, Output, Stdio}; use serde_json::{Value, json}; @@ -65,12 +65,221 @@ fn eval_at_no_hit_exit_arg_emits_exited_then_terminated_without_stopped() { assert!(events.iter().all(|event| event["event"] != "stopped")); } +#[test] +fn scripted_record_replay_timeline_scenarios_are_selected_by_cli_flag_or_env_and_cli_wins() { + let env_only = run_scripted_scenario( + &[], + Some(("TOWER_DEBUG_FIXTURE_SCENARIO", "record_ok")), + "scripted-env-record", + ); + assert!( + env_only.status.success(), + "env-selected scenario should succeed; stderr={}", + String::from_utf8_lossy(&env_only.stderr) + ); + let env_events = parse_json_lines(&env_only.stdout); + assert_eq!(env_events[0]["event"], "record"); + assert_eq!(env_events[0]["trace"], "trace-record-ok"); + + let cli_wins = run_scripted_scenario( + &["--scenario", "replay_open"], + Some(("TOWER_DEBUG_FIXTURE_SCENARIO", "record_ok")), + "scripted-cli-replay", + ); + assert!( + cli_wins.status.success(), + "CLI-selected scenario should succeed; stderr={}", + String::from_utf8_lossy(&cli_wins.stderr) + ); + let cli_events = parse_json_lines(&cli_wins.stdout); + assert_eq!(cli_events[0]["event"], "replay"); + assert_eq!(cli_events[0]["trace"], "trace-replay-open"); +} + +#[test] +fn scripted_scenario_selection_rejects_unsupported_names() { + for (args, env) in [ + ( + vec!["--scenario", "unsupported"], + Some(("TOWER_DEBUG_FIXTURE_SCENARIO", "record_ok")), + ), + ( + Vec::new(), + Some(("TOWER_DEBUG_FIXTURE_SCENARIO", "unknown")), + ), + ] { + let output = run_scripted_scenario(&args, env, "scripted-unsupported"); + + assert!( + !output.status.success(), + "unsupported scenario must fail; stdout={}; stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("unsupported fixture scenario"), + "unsupported scenario should explain the failure; stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn scripted_scenarios_emit_one_json_line_per_event_with_exact_public_fields_and_cleanup_event() { + for scenario in [ + "record_ok", + "replay_open", + "reverse_continue_stop", + "step_back_line", + "watchpoint_stop", + "no_prior_write", + "timeout", + "adapter_exited", + ] { + let token = format!("scripted-fields-{scenario}"); + let output = run_scripted_scenario(&["--scenario", scenario], None, &token); + assert!( + output.status.success(), + "scenario {scenario} should succeed; stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let events = parse_json_lines(&output.stdout); + assert!(!events.is_empty(), "scenario {scenario} must emit events"); + for event in &events { + let object = event.as_object().expect("fixture event is an object"); + assert_eq!( + object.len(), + 6, + "fixture event must have exact fields: {event}" + ); + for key in ["event", "token", "session", "trace", "stop", "output"] { + assert!( + object.contains_key(key), + "scenario {scenario} event missing field {key}: {event}" + ); + } + assert!( + object.keys().all( + |key| ["event", "token", "session", "trace", "stop", "output"] + .contains(&key.as_str()) + ), + "scenario {scenario} emitted unexpected event fields: {event}" + ); + assert!( + event["event"].is_string(), + "event name must be a string: {event}" + ); + assert_eq!(event["token"], token); + assert!( + event["session"].is_null() || event["session"].is_string(), + "session must be null or string: {event}" + ); + assert!( + event["trace"].is_null() || event["trace"].is_string(), + "trace must be null or string: {event}" + ); + assert!( + event["stop"].is_null() || event["stop"].is_object(), + "stop must be null or object: {event}" + ); + assert!( + event["output"].is_null() || event["output"].is_string(), + "output must be null or string: {event}" + ); + } + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "cleanup" && event["token"] == token) + .count(), + 1, + "scenario {scenario} must emit exactly one cleanup event" + ); + } +} + +#[test] +fn scripted_cleanup_tokens_are_opaque_ascii_and_each_required_failure_or_success_path_cleans_once() +{ + for scenario in [ + "record_ok", + "replay_open", + "timeout", + "no_prior_write", + "adapter_exited", + "watchpoint_stop", + ] { + let token = format!("OpaqueCleanupToken-{scenario}-A19"); + assert!(token.is_ascii()); + let output = run_scripted_scenario(&["--scenario", scenario], None, &token); + assert!( + output.status.success(), + "scenario {scenario} should succeed; stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let events = parse_json_lines(&output.stdout); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "cleanup" && event["token"] == token) + .count(), + 1, + "scenario {scenario} must clean exactly once for token {token}" + ); + } +} + +#[test] +fn scripted_stop_events_use_deterministic_stop_sequence_ordering_not_wall_clock_order() { + let output = run_scripted_scenario( + &["--scenario", "reverse_continue_stop"], + None, + "scripted-sequence-order", + ); + assert!( + output.status.success(), + "reverse_continue_stop should succeed; stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let sequences = parse_json_lines(&output.stdout) + .into_iter() + .filter_map(|event| event["stop"]["sequence"].as_u64()) + .collect::>(); + + assert_eq!( + sequences, + [1, 2, 3], + "tests assert scripted stop ordering by stop.sequence" + ); +} + fn write_frame(mut writer: impl Write, value: &Value) { let body = serde_json::to_vec(value).expect("serialize DAP frame"); write!(writer, "Content-Length: {}\r\n\r\n", body.len()).expect("write DAP header"); writer.write_all(&body).expect("write DAP body"); } +fn run_scripted_scenario(args: &[&str], env: Option<(&str, &str)>, cleanup_token: &str) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_fixture_debug_adapter")); + command + .args(args) + .env("TOWER_DEBUG_FIXTURE_CLEANUP_TOKEN", cleanup_token) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some((name, value)) = env { + command.env(name, value); + } + command.output().expect("run fixture scripted scenario") +} + +fn parse_json_lines(output: &[u8]) -> Vec { + String::from_utf8_lossy(output) + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("fixture event JSON line")) + .collect() +} + fn read_json_frames(output: &[u8]) -> Vec { let mut reader = BufReader::new(output); let mut frames = Vec::new();