From 87479ca1f8477cac9ceee0ca66f1de79ae232a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 9 Jul 2026 14:42:11 -0400 Subject: [PATCH 01/14] Add TTD trace recording command Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-tool/src/cli.rs | 42 +++++ crates/windbg-tool/src/cli/dispatch.rs | 1 + crates/windbg-tool/src/cli/platform.rs | 232 ++++++++++++++++++++++++- docs/cli.md | 17 ++ 4 files changed, 290 insertions(+), 2 deletions(-) diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 3a3f7e5..52d79f9 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -486,6 +486,8 @@ enum ArchitectureCommand { enum TraceCommand { #[command(about = "Enumerate traces in a .run/.idx/.ttd file without opening a session")] List(TraceListArgs), + #[command(about = "Launch a process through TTD.exe and wait for its trace to be finalized")] + Record(TraceRecordArgs), } #[derive(Debug, Subcommand)] @@ -642,6 +644,46 @@ struct LiveLaunchArgs { end: String, } +#[derive(Debug, Args, Clone)] +struct TraceRecordArgs { + #[arg( + long, + value_name = "PATH", + help = "Output .run trace path; its parent directory must already exist" + )] + output: PathBuf, + #[arg( + long, + help = "Full target command line; it is passed directly to TTD.exe without a shell" + )] + command_line: String, + #[arg( + long, + value_name = "PATH", + help = "TTD.exe path; defaults to TTD_EXE or ttd.exe found on PATH" + )] + ttd_exe: Option, + #[arg( + long, + help = "Pass -accepteula to TTD.exe after you have reviewed and accepted its EULA" + )] + accept_eula: bool, + #[arg(long, help = "Record child processes created by the launch target")] + children: bool, + #[arg( + long, + value_name = "MEGABYTES", + help = "Pass -maxFile to bound the trace size" + )] + max_file_mb: Option, + #[arg( + long, + requires = "max_file_mb", + help = "Use a fixed-size ring buffer; requires --max-file-mb" + )] + ring: bool, +} + #[derive(Debug, Args)] struct LiveSessionStartArgs { #[arg(long, help = "Full command line to launch under DbgEng")] diff --git a/crates/windbg-tool/src/cli/dispatch.rs b/crates/windbg-tool/src/cli/dispatch.rs index 74156a7..a300f06 100644 --- a/crates/windbg-tool/src/cli/dispatch.rs +++ b/crates/windbg-tool/src/cli/dispatch.rs @@ -32,6 +32,7 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { Some(Commands::CliSchema(args)) => print_value(cli_schema(args)?, &output), Some(Commands::Trace { command }) => match command { TraceCommand::List(args) => call_and_print(pipe, trace_list_call(args), &output).await, + TraceCommand::Record(args) => platform::run_trace_record(args, &output), }, Some(Commands::TraceList(args)) => { call_and_print(pipe, trace_list_call(args), &output).await diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 446a7b2..be71446 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -1,5 +1,9 @@ -use anyhow::{bail, Context}; +use anyhow::{bail, ensure, Context}; use serde_json::{json, Value}; +use std::env; +use std::ffi::OsString; +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; use std::process::Command; use windbg_dbgeng::{ live_launch_initial_break, open_dump_session, start_process_server, write_process_dump, @@ -10,7 +14,8 @@ use windbg_install::WindbgManager; use super::output::{print_value, OutputOptions}; use super::{ - CliDumpKind, DbgEngServerArgs, DumpCreateArgs, DumpInspectArgs, LiveLaunchArgs, WindbgCommand, + CliDumpKind, DbgEngServerArgs, DumpCreateArgs, DumpInspectArgs, LiveLaunchArgs, + TraceRecordArgs, WindbgCommand, }; pub(super) fn run_dbgeng_server( @@ -47,6 +52,157 @@ pub(super) fn run_live_launch(args: LiveLaunchArgs, output: &OutputOptions) -> a ) } +pub(super) fn run_trace_record( + args: TraceRecordArgs, + output: &OutputOptions, +) -> anyhow::Result<()> { + let plan = trace_record_plan(&args)?; + let status = command_from_trace_record_plan(&plan) + .status() + .with_context(|| format!("launching TTD recorder {}", plan.ttd_exe.display()))?; + ensure!( + status.success(), + "TTD recorder exited with {}", + status + .code() + .map_or_else(|| "no exit code".to_string(), |code| code.to_string()) + ); + ensure!( + plan.output.is_file(), + "TTD recorder completed without creating {}", + plan.output.display() + ); + + let artifact_paths = trace_artifact_paths(&plan.output); + print_value( + json!({ + "recorder": plan.ttd_exe, + "output": plan.output, + "command_line": plan.command_line, + "exit_code": status.code(), + "artifacts": artifact_paths, + "notes": [ + "TTD recording is invasive and can significantly slow the target process.", + "The trace is finalized after the launched target exits.", + "TTD traces can contain sensitive process-memory data." + ] + }), + output, + ) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TraceRecordPlan { + ttd_exe: PathBuf, + output: PathBuf, + command_line: String, + recorder_args: Vec, +} + +fn trace_record_plan(args: &TraceRecordArgs) -> anyhow::Result { + ensure!( + !args.command_line.trim().is_empty(), + "--command-line must not be empty" + ); + ensure!( + args.output + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("run")), + "--output must name a .run trace file" + ); + let output_parent = args + .output + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + ensure!( + output_parent.is_dir(), + "trace output directory does not exist: {}", + output_parent.display() + ); + ensure!( + !args.output.exists(), + "trace output already exists: {}", + args.output.display() + ); + + let ttd_exe = resolve_ttd_exe(args.ttd_exe.as_deref())?; + let mut recorder_args = vec![ + OsString::from("-noUI"), + OsString::from("-out"), + args.output.as_os_str().to_os_string(), + ]; + if args.accept_eula { + recorder_args.push(OsString::from("-accepteula")); + } + if args.children { + recorder_args.push(OsString::from("-children")); + } + if let Some(max_file_mb) = args.max_file_mb { + ensure!(max_file_mb > 0, "--max-file-mb must be greater than zero"); + recorder_args.push(OsString::from("-maxFile")); + recorder_args.push(OsString::from(max_file_mb.to_string())); + } + if args.ring { + recorder_args.push(OsString::from("-ring")); + } + recorder_args.push(OsString::from("-launch")); + + Ok(TraceRecordPlan { + ttd_exe, + output: args.output.clone(), + command_line: args.command_line.clone(), + recorder_args, + }) +} + +fn command_from_trace_record_plan(plan: &TraceRecordPlan) -> Command { + let mut command = Command::new(&plan.ttd_exe); + command.args(&plan.recorder_args); + // TTD requires its target command line after -launch. raw_arg preserves the caller's + // Windows command-line quoting without introducing a shell. + command.raw_arg(&plan.command_line); + command +} + +fn resolve_ttd_exe(explicit: Option<&Path>) -> anyhow::Result { + if let Some(path) = explicit { + return validate_ttd_exe(path); + } + if let Some(path) = env::var_os("TTD_EXE") { + return validate_ttd_exe(Path::new(&path)); + } + find_executable_on_path("ttd.exe").context( + "could not find TTD.exe; install the Microsoft Time Travel Debugging command-line utility, add it to PATH, set TTD_EXE, or pass --ttd-exe", + ) +} + +fn validate_ttd_exe(path: &Path) -> anyhow::Result { + ensure!( + path.is_file(), + "TTD recorder executable does not exist: {}", + path.display() + ); + Ok(path.to_path_buf()) +} + +fn find_executable_on_path(name: &str) -> Option { + env::var_os("PATH").and_then(|paths| { + env::split_paths(&paths) + .map(|directory| directory.join(name)) + .find(|candidate| candidate.is_file()) + }) +} + +fn trace_artifact_paths(output: &Path) -> Vec { + let mut artifacts = vec![output.to_path_buf()]; + let index = output.with_extension("idx"); + if index.exists() { + artifacts.push(index); + } + artifacts +} + pub(super) fn run_dump_create(args: DumpCreateArgs, output: &OutputOptions) -> anyhow::Result<()> { let result = write_process_dump(ProcessDumpOptions { process_id: args.process_id, @@ -267,3 +423,75 @@ pub(super) fn run_windbg_command( } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn record_args(output: PathBuf) -> TraceRecordArgs { + TraceRecordArgs { + output, + command_line: r#""C:\Program Files\App\app.exe" --flag "two words""#.to_string(), + ttd_exe: Some(env::current_exe().unwrap()), + accept_eula: true, + children: true, + max_file_mb: Some(128), + ring: true, + } + } + + fn test_directory() -> PathBuf { + env::temp_dir().join(format!( + "windbg-tool-record-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + #[test] + fn trace_record_plan_uses_documented_ttd_arguments() { + let temp = test_directory(); + fs::create_dir_all(&temp).unwrap(); + let output = temp.join("capture.run"); + + let plan = trace_record_plan(&record_args(output.clone())).unwrap(); + + assert_eq!(plan.output, output); + assert_eq!( + plan.recorder_args, + vec![ + "-noUI", + "-out", + output.to_string_lossy().as_ref(), + "-accepteula", + "-children", + "-maxFile", + "128", + "-ring", + "-launch", + ] + .into_iter() + .map(OsString::from) + .collect::>() + ); + assert_eq!(plan.command_line, record_args(output).command_line); + + fs::remove_dir_all(temp).unwrap(); + } + + #[test] + fn trace_record_plan_rejects_existing_or_non_run_output() { + let temp = test_directory(); + fs::create_dir_all(&temp).unwrap(); + let existing = temp.join("capture.run"); + fs::write(&existing, []).unwrap(); + assert!(trace_record_plan(&record_args(existing)).is_err()); + assert!(trace_record_plan(&record_args(temp.join("capture.ttd"))).is_err()); + fs::remove_dir_all(temp).unwrap(); + } +} diff --git a/docs/cli.md b/docs/cli.md index da64e18..04e12e5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,6 +6,7 @@ - **Discovery commands** do not require the daemon: `discover`, `cli-schema`, `recipes`, `tools`, `schema`, `trace-list`, `symbols inspect` - **Replay commands** usually talk to the local daemon and operate on a `session_id` and `cursor_id` +- **Recording commands** use the separately installed Microsoft `TTD.exe` recorder and do not require the daemon - **Canonical agent debugging commands** start with `debug`, `triage`, `symbols doctor`, and `breakpoint plan` - **Platform helper commands** cover DbgEng, remote debugging doctors/plans, live-launch probing, and WinDbg installation @@ -42,6 +43,21 @@ target\debug\windbg-tool.exe memory strings --session 1 --cursor 1 --address 0x1 `open` is the preferred starting point because it loads the trace, creates a cursor, and optionally seeks to a position in one response. `debug snapshot` is the canonical cross-backend snapshot for agents; `context snapshot` remains available as the legacy TTD-focused snapshot. +## Record a trace + +`trace record` launches a target with the Microsoft `TTD.exe` command-line recorder, waits for it to exit, and emits the resulting `.run` path. It uses TTD's documented `-noUI -out -launch ` invocation; the target command line is passed directly, not through a shell. + +Install the standalone TTD recorder so `ttd.exe` is on `PATH`, set `TTD_EXE`, or pass `--ttd-exe`. TTD generally requires an elevated console. Review the recorder EULA before using `--accept-eula`. + +```powershell +target\debug\windbg-tool.exe --compact trace record ` + --output C:\traces\rdm.run ` + --command-line '"C:\apps\RemoteDesktopManager.exe" /AutoCloseAfter:10' ` + --accept-eula +``` + +The output directory must already exist and the `.run` path must not already exist. `--max-file-mb` bounds trace growth; combine it with `--ring` to retain only the most recent recording window. TTD traces include process-memory data and should be handled as sensitive artifacts. + ## Canonical agent debugging commands Use `debug capabilities` before choosing actions. With no subject it returns a backend matrix for TTD cursors, daemon-owned live/dump targets, and remote process-server plans. With `--session/--cursor` or `--target`, it includes selected-subject status/evidence where available. @@ -153,6 +169,7 @@ The schema includes curated metadata where available and inferred metadata for o | Discover capabilities | `discover`, `cli-schema [command...]`, `recipes`, `tools`, `schema ` | | Canonical agent context | `debug capabilities`, `debug snapshot`, `triage `, `symbols doctor`, `breakpoint plan`, `debug log summarize` | | Manage daemon/session state | `daemon ensure`, `daemon status`, `sessions`, `open`, `load`, `close`, `info` | +| Capture a new trace | `trace record --output --command-line ` | | Move through a trace | `cursor create`, `position get`, `position set`, `step`, `replay to`, `replay watch-memory`, `sweep watch-memory` | | Inspect trace metadata | `threads`, `modules`, `exceptions`, `keyframes`, `timeline events`, `module info`, `module audit` | | Inspect runtime state | `registers`, `register-context`, `active-threads`, `command-line`, `architecture state` | From 4e710ae3c98e7370ac1a4c24b7f643a44d3edfa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 9 Jul 2026 14:49:53 -0400 Subject: [PATCH 02/14] Guide TTD recorder installation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-tool/src/cli/platform.rs | 15 ++++++++++++--- docs/cli.md | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index be71446..4fe8f96 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -172,9 +172,11 @@ fn resolve_ttd_exe(explicit: Option<&Path>) -> anyhow::Result { if let Some(path) = env::var_os("TTD_EXE") { return validate_ttd_exe(Path::new(&path)); } - find_executable_on_path("ttd.exe").context( - "could not find TTD.exe; install the Microsoft Time Travel Debugging command-line utility, add it to PATH, set TTD_EXE, or pass --ttd-exe", - ) + find_executable_on_path("ttd.exe").context(ttd_not_found_message()) +} + +fn ttd_not_found_message() -> &'static str { + "could not find TTD.exe; install it with `winget install --id Microsoft.TimeTravelDebugging`, then open a new elevated terminal. Alternatively, add it to PATH, set TTD_EXE, or pass --ttd-exe" } fn validate_ttd_exe(path: &Path) -> anyhow::Result { @@ -494,4 +496,11 @@ mod tests { assert!(trace_record_plan(&record_args(temp.join("capture.ttd"))).is_err()); fs::remove_dir_all(temp).unwrap(); } + + #[test] + fn ttd_not_found_message_includes_official_install_command() { + assert!( + ttd_not_found_message().contains("winget install --id Microsoft.TimeTravelDebugging") + ); + } } diff --git a/docs/cli.md b/docs/cli.md index 04e12e5..1a6d087 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -47,7 +47,7 @@ target\debug\windbg-tool.exe memory strings --session 1 --cursor 1 --address 0x1 `trace record` launches a target with the Microsoft `TTD.exe` command-line recorder, waits for it to exit, and emits the resulting `.run` path. It uses TTD's documented `-noUI -out -launch ` invocation; the target command line is passed directly, not through a shell. -Install the standalone TTD recorder so `ttd.exe` is on `PATH`, set `TTD_EXE`, or pass `--ttd-exe`. TTD generally requires an elevated console. Review the recorder EULA before using `--accept-eula`. +Install the standalone TTD recorder with `winget install --id Microsoft.TimeTravelDebugging`, then open a new elevated terminal so `ttd.exe` is on `PATH`. You can also set `TTD_EXE` or pass `--ttd-exe`. Review the recorder EULA before using `--accept-eula`. ```powershell target\debug\windbg-tool.exe --compact trace record ` From edde3be4b4fcb4175e6f83c43b5f7c019eec48a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 9 Jul 2026 14:53:21 -0400 Subject: [PATCH 03/14] Always accept TTD recorder EULA Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-tool/src/cli.rs | 5 ----- crates/windbg-tool/src/cli/platform.rs | 5 +---- docs/cli.md | 5 ++--- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 52d79f9..4502315 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -663,11 +663,6 @@ struct TraceRecordArgs { help = "TTD.exe path; defaults to TTD_EXE or ttd.exe found on PATH" )] ttd_exe: Option, - #[arg( - long, - help = "Pass -accepteula to TTD.exe after you have reviewed and accepted its EULA" - )] - accept_eula: bool, #[arg(long, help = "Record child processes created by the launch target")] children: bool, #[arg( diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 4fe8f96..a0fcc54 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -131,10 +131,8 @@ fn trace_record_plan(args: &TraceRecordArgs) -> anyhow::Result OsString::from("-noUI"), OsString::from("-out"), args.output.as_os_str().to_os_string(), + OsString::from("-accepteula"), ]; - if args.accept_eula { - recorder_args.push(OsString::from("-accepteula")); - } if args.children { recorder_args.push(OsString::from("-children")); } @@ -437,7 +435,6 @@ mod tests { output, command_line: r#""C:\Program Files\App\app.exe" --flag "two words""#.to_string(), ttd_exe: Some(env::current_exe().unwrap()), - accept_eula: true, children: true, max_file_mb: Some(128), ring: true, diff --git a/docs/cli.md b/docs/cli.md index 1a6d087..2ed7bf3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -47,13 +47,12 @@ target\debug\windbg-tool.exe memory strings --session 1 --cursor 1 --address 0x1 `trace record` launches a target with the Microsoft `TTD.exe` command-line recorder, waits for it to exit, and emits the resulting `.run` path. It uses TTD's documented `-noUI -out -launch ` invocation; the target command line is passed directly, not through a shell. -Install the standalone TTD recorder with `winget install --id Microsoft.TimeTravelDebugging`, then open a new elevated terminal so `ttd.exe` is on `PATH`. You can also set `TTD_EXE` or pass `--ttd-exe`. Review the recorder EULA before using `--accept-eula`. +Install the standalone TTD recorder with `winget install --id Microsoft.TimeTravelDebugging`, then open a new elevated terminal so `ttd.exe` is on `PATH`. You can also set `TTD_EXE` or pass `--ttd-exe`. `trace record` always passes TTD's `-accepteula` option. ```powershell target\debug\windbg-tool.exe --compact trace record ` --output C:\traces\rdm.run ` - --command-line '"C:\apps\RemoteDesktopManager.exe" /AutoCloseAfter:10' ` - --accept-eula + --command-line '"C:\apps\RemoteDesktopManager.exe" /AutoCloseAfter:10' ``` The output directory must already exist and the `.run` path must not already exist. `--max-file-mb` bounds trace growth; combine it with `--ring` to retain only the most recent recording window. TTD traces include process-memory data and should be handled as sensitive artifacts. From 58882201bee6103be05319cbf5fc3bdb591ee988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 9 Jul 2026 14:58:32 -0400 Subject: [PATCH 04/14] Elevate TTD recording with Windows sudo Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-tool/Cargo.toml | 7 ++ crates/windbg-tool/src/cli/platform.rs | 128 ++++++++++++++++++++++++- docs/cli.md | 2 +- 3 files changed, 133 insertions(+), 4 deletions(-) diff --git a/crates/windbg-tool/Cargo.toml b/crates/windbg-tool/Cargo.toml index 89d69a5..9d450f5 100644 --- a/crates/windbg-tool/Cargo.toml +++ b/crates/windbg-tool/Cargo.toml @@ -16,3 +16,10 @@ tracing-subscriber.workspace = true windbg-dbgeng = { path = "..\\windbg-dbgeng" } windbg-install = { path = "..\\windbg-install" } windbg-ttd = { path = "..\\windbg-ttd" } + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_Threading", +] } diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index a0fcc54..a32ec06 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -11,6 +11,9 @@ use windbg_dbgeng::{ ProcessDumpOptions, ProcessServerOptions, }; use windbg_install::WindbgManager; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY}; +use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; use super::output::{print_value, OutputOptions}; use super::{ @@ -57,7 +60,8 @@ pub(super) fn run_trace_record( output: &OutputOptions, ) -> anyhow::Result<()> { let plan = trace_record_plan(&args)?; - let status = command_from_trace_record_plan(&plan) + let launcher = trace_record_launcher()?; + let status = command_from_trace_record_plan(&plan, &launcher) .status() .with_context(|| format!("launching TTD recorder {}", plan.ttd_exe.display()))?; ensure!( @@ -80,6 +84,7 @@ pub(super) fn run_trace_record( "output": plan.output, "command_line": plan.command_line, "exit_code": status.code(), + "elevation": launcher.name(), "artifacts": artifact_paths, "notes": [ "TTD recording is invasive and can significantly slow the target process.", @@ -154,8 +159,59 @@ fn trace_record_plan(args: &TraceRecordArgs) -> anyhow::Result }) } -fn command_from_trace_record_plan(plan: &TraceRecordPlan) -> Command { - let mut command = Command::new(&plan.ttd_exe); +#[derive(Debug, Clone, PartialEq, Eq)] +enum TraceRecordLauncher { + Direct, + Sudo { + executable: PathBuf, + working_directory: PathBuf, + }, +} + +impl TraceRecordLauncher { + fn name(&self) -> &'static str { + match self { + Self::Direct => "already_elevated", + Self::Sudo { .. } => "windows_sudo", + } + } +} + +fn trace_record_launcher() -> anyhow::Result { + if current_process_is_elevated()? { + return Ok(TraceRecordLauncher::Direct); + } + + let sudo = find_enabled_sudo().context( + "TTD recording requires elevation, but Windows sudo is unavailable or disabled; run windbg-tool from an elevated terminal or enable sudo in Settings > System > Advanced", + )?; + let working_directory = + env::current_dir().context("resolving the current working directory")?; + Ok(TraceRecordLauncher::Sudo { + executable: sudo, + working_directory, + }) +} + +fn command_from_trace_record_plan( + plan: &TraceRecordPlan, + launcher: &TraceRecordLauncher, +) -> Command { + let mut command = match launcher { + TraceRecordLauncher::Direct => Command::new(&plan.ttd_exe), + TraceRecordLauncher::Sudo { + executable, + working_directory, + } => { + let mut command = Command::new(executable); + command + .arg("--preserve-env") + .arg("--chdir") + .arg(working_directory) + .arg(&plan.ttd_exe); + command + } + }; command.args(&plan.recorder_args); // TTD requires its target command line after -launch. raw_arg preserves the caller's // Windows command-line quoting without introducing a shell. @@ -163,6 +219,37 @@ fn command_from_trace_record_plan(plan: &TraceRecordPlan) -> Command { command } +fn current_process_is_elevated() -> anyhow::Result { + unsafe { + let mut token = HANDLE::default(); + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) + .context("opening the current process token")?; + + let mut elevation = TOKEN_ELEVATION::default(); + let mut returned = 0; + let result = GetTokenInformation( + token, + TokenElevation, + Some(&mut elevation as *mut _ as *mut _), + std::mem::size_of::() as u32, + &mut returned, + ); + CloseHandle(token).context("closing the current process token")?; + result.context("querying the current process elevation")?; + Ok(elevation.TokenIsElevated != 0) + } +} + +fn find_enabled_sudo() -> Option { + let sudo = find_executable_on_path("sudo.exe")?; + Command::new(&sudo) + .arg("config") + .status() + .ok() + .filter(|status| status.success()) + .map(|_| sudo) +} + fn resolve_ttd_exe(explicit: Option<&Path>) -> anyhow::Result { if let Some(path) = explicit { return validate_ttd_exe(path); @@ -494,6 +581,41 @@ mod tests { fs::remove_dir_all(temp).unwrap(); } + #[test] + fn sudo_launcher_wraps_ttd_and_preserves_working_directory() { + let temp = test_directory(); + fs::create_dir_all(&temp).unwrap(); + let plan = trace_record_plan(&record_args(temp.join("capture.run"))).unwrap(); + let launcher = TraceRecordLauncher::Sudo { + executable: PathBuf::from(r"C:\Windows\System32\sudo.exe"), + working_directory: PathBuf::from(r"D:\work"), + }; + + let command = command_from_trace_record_plan(&plan, &launcher); + let arguments = command + .get_args() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect::>(); + + assert_eq!( + command.get_program(), + Path::new(r"C:\Windows\System32\sudo.exe").as_os_str() + ); + assert_eq!( + arguments[..5], + [ + "--preserve-env", + "--chdir", + r"D:\work", + plan.ttd_exe.to_string_lossy().as_ref(), + "-noUI", + ] + ); + assert_eq!(launcher.name(), "windows_sudo"); + + fs::remove_dir_all(temp).unwrap(); + } + #[test] fn ttd_not_found_message_includes_official_install_command() { assert!( diff --git a/docs/cli.md b/docs/cli.md index 2ed7bf3..31c7131 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -45,7 +45,7 @@ target\debug\windbg-tool.exe memory strings --session 1 --cursor 1 --address 0x1 ## Record a trace -`trace record` launches a target with the Microsoft `TTD.exe` command-line recorder, waits for it to exit, and emits the resulting `.run` path. It uses TTD's documented `-noUI -out -launch ` invocation; the target command line is passed directly, not through a shell. +`trace record` launches a target with the Microsoft `TTD.exe` command-line recorder, waits for it to exit, and emits the resulting `.run` path. It uses TTD's documented `-noUI -out -launch ` invocation; the target command line is passed directly, not through a shell. Because TTD recording requires elevation, it runs TTD directly from an elevated terminal or automatically invokes enabled Windows 11 `sudo` from an unelevated terminal. Install the standalone TTD recorder with `winget install --id Microsoft.TimeTravelDebugging`, then open a new elevated terminal so `ttd.exe` is on `PATH`. You can also set `TTD_EXE` or pass `--ttd-exe`. `trace record` always passes TTD's `-accepteula` option. From 989fde07017a7644977f6e6cb446a1f7ee09f90c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 9 Jul 2026 14:58:37 -0400 Subject: [PATCH 05/14] Update lockfile for sudo support Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index e40905e..26b205d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1821,6 +1821,7 @@ dependencies = [ "windbg-dbgeng", "windbg-install", "windbg-ttd", + "windows", ] [[package]] From 1b1dbda17efebda28b7bf89e1adcc82d9a8e1406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 9 Jul 2026 15:06:09 -0400 Subject: [PATCH 06/14] Require synchronous sudo for TTD recording Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-tool/src/cli/platform.rs | 103 +++++++++++++++++++++---- docs/cli.md | 8 +- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index a32ec06..01c3cf7 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -165,6 +165,7 @@ enum TraceRecordLauncher { Sudo { executable: PathBuf, working_directory: PathBuf, + mode: WindowsSudoMode, }, } @@ -172,24 +173,48 @@ impl TraceRecordLauncher { fn name(&self) -> &'static str { match self { Self::Direct => "already_elevated", - Self::Sudo { .. } => "windows_sudo", + Self::Sudo { + mode: WindowsSudoMode::Inline, + .. + } => "windows_sudo_inline", + Self::Sudo { + mode: WindowsSudoMode::DisableInput, + .. + } => "windows_sudo_disable_input", + Self::Sudo { + mode: WindowsSudoMode::ForceNewWindow, + .. + } => "windows_sudo_force_new_window", } } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WindowsSudoMode { + DisableInput, + Inline, + ForceNewWindow, +} + fn trace_record_launcher() -> anyhow::Result { if current_process_is_elevated()? { return Ok(TraceRecordLauncher::Direct); } - let sudo = find_enabled_sudo().context( + let (sudo, mode) = find_enabled_sudo()?.context( "TTD recording requires elevation, but Windows sudo is unavailable or disabled; run windbg-tool from an elevated terminal or enable sudo in Settings > System > Advanced", )?; + if mode == WindowsSudoMode::ForceNewWindow { + bail!( + "Windows sudo is configured for Force New Window mode, which cannot synchronously wait for TTD recording; run windbg-tool from an elevated terminal or configure sudo for Input Closed or Inline mode in Settings > System > Advanced" + ); + } let working_directory = env::current_dir().context("resolving the current working directory")?; Ok(TraceRecordLauncher::Sudo { executable: sudo, working_directory, + mode, }) } @@ -202,13 +227,25 @@ fn command_from_trace_record_plan( TraceRecordLauncher::Sudo { executable, working_directory, + mode, } => { let mut command = Command::new(executable); command .arg("--preserve-env") .arg("--chdir") - .arg(working_directory) - .arg(&plan.ttd_exe); + .arg(working_directory); + match mode { + WindowsSudoMode::DisableInput => { + command.arg("--disable-input"); + } + WindowsSudoMode::Inline => { + command.arg("--inline"); + } + WindowsSudoMode::ForceNewWindow => { + unreachable!("asynchronous sudo mode is rejected") + } + } + command.arg(&plan.ttd_exe); command } }; @@ -240,14 +277,36 @@ fn current_process_is_elevated() -> anyhow::Result { } } -fn find_enabled_sudo() -> Option { - let sudo = find_executable_on_path("sudo.exe")?; - Command::new(&sudo) +fn find_enabled_sudo() -> anyhow::Result> { + let Some(sudo) = find_executable_on_path("sudo.exe") else { + return Ok(None); + }; + let output = Command::new(&sudo) .arg("config") - .status() - .ok() - .filter(|status| status.success()) - .map(|_| sudo) + .output() + .context("checking Windows sudo configuration")?; + ensure!( + output.status.success(), + "Windows sudo configuration check failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + let mode = parse_windows_sudo_mode(&String::from_utf8_lossy(&output.stdout)).context( + "Windows sudo returned an unrecognized configuration; run `sudo config` to inspect it", + )?; + Ok(Some((sudo, mode))) +} + +fn parse_windows_sudo_mode(output: &str) -> Option { + let normalized = output.to_ascii_lowercase().replace([' ', '-'], ""); + if normalized.contains("forcenewwindow") { + Some(WindowsSudoMode::ForceNewWindow) + } else if normalized.contains("disableinput") || normalized.contains("inputclosed") { + Some(WindowsSudoMode::DisableInput) + } else if normalized.contains("inline") || normalized.contains("normal") { + Some(WindowsSudoMode::Inline) + } else { + None + } } fn resolve_ttd_exe(explicit: Option<&Path>) -> anyhow::Result { @@ -589,6 +648,7 @@ mod tests { let launcher = TraceRecordLauncher::Sudo { executable: PathBuf::from(r"C:\Windows\System32\sudo.exe"), working_directory: PathBuf::from(r"D:\work"), + mode: WindowsSudoMode::DisableInput, }; let command = command_from_trace_record_plan(&plan, &launcher); @@ -602,20 +662,37 @@ mod tests { Path::new(r"C:\Windows\System32\sudo.exe").as_os_str() ); assert_eq!( - arguments[..5], + arguments[..6], [ "--preserve-env", "--chdir", r"D:\work", + "--disable-input", plan.ttd_exe.to_string_lossy().as_ref(), "-noUI", ] ); - assert_eq!(launcher.name(), "windows_sudo"); + assert_eq!(launcher.name(), "windows_sudo_disable_input"); fs::remove_dir_all(temp).unwrap(); } + #[test] + fn parses_windows_sudo_modes() { + assert_eq!( + parse_windows_sudo_mode("Sudo is currently in Force New Window mode"), + Some(WindowsSudoMode::ForceNewWindow) + ); + assert_eq!( + parse_windows_sudo_mode("Sudo is currently in Input Closed mode"), + Some(WindowsSudoMode::DisableInput) + ); + assert_eq!( + parse_windows_sudo_mode("Sudo is currently in Inline mode"), + Some(WindowsSudoMode::Inline) + ); + } + #[test] fn ttd_not_found_message_includes_official_install_command() { assert!( diff --git a/docs/cli.md b/docs/cli.md index 31c7131..e61ba29 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -45,7 +45,7 @@ target\debug\windbg-tool.exe memory strings --session 1 --cursor 1 --address 0x1 ## Record a trace -`trace record` launches a target with the Microsoft `TTD.exe` command-line recorder, waits for it to exit, and emits the resulting `.run` path. It uses TTD's documented `-noUI -out -launch ` invocation; the target command line is passed directly, not through a shell. Because TTD recording requires elevation, it runs TTD directly from an elevated terminal or automatically invokes enabled Windows 11 `sudo` from an unelevated terminal. +`trace record` launches a target with the Microsoft `TTD.exe` command-line recorder, waits for it to exit, and emits the resulting `.run` path. It uses TTD's documented `-noUI -out -launch ` invocation; the target command line is passed directly, not through a shell. Because TTD recording requires elevation, it runs TTD directly from an elevated terminal or automatically invokes Windows 11 `sudo` when it is configured for the synchronous **Input Closed** or **Inline** mode. Force New Window mode is rejected because it returns before the recorder completes. Install the standalone TTD recorder with `winget install --id Microsoft.TimeTravelDebugging`, then open a new elevated terminal so `ttd.exe` is on `PATH`. You can also set `TTD_EXE` or pass `--ttd-exe`. `trace record` always passes TTD's `-accepteula` option. @@ -57,6 +57,12 @@ target\debug\windbg-tool.exe --compact trace record ` The output directory must already exist and the `.run` path must not already exist. `--max-file-mb` bounds trace growth; combine it with `--ring` to retain only the most recent recording window. TTD traces include process-memory data and should be handled as sensitive artifacts. +To enable synchronous sudo recording, choose **Input Closed** or **Inline** in **Settings > System > Advanced > Enable sudo**, or from an elevated terminal run: + +```powershell +sudo config --enable disableInput +``` + ## Canonical agent debugging commands Use `debug capabilities` before choosing actions. With no subject it returns a backend matrix for TTD cursors, daemon-owned live/dump targets, and remote process-server plans. With `--session/--cursor` or `--target`, it includes selected-subject status/evidence where available. From 73be771c50cdea0786c6db5c38e4908c570b4174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 9 Jul 2026 15:10:15 -0400 Subject: [PATCH 07/14] Support CET-compatible TTD recording Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-tool/Cargo.toml | 1 + crates/windbg-tool/src/cli.rs | 5 + crates/windbg-tool/src/cli/platform.rs | 152 +++++++++++++++++++++++-- docs/cli.md | 2 + 4 files changed, 152 insertions(+), 8 deletions(-) diff --git a/crates/windbg-tool/Cargo.toml b/crates/windbg-tool/Cargo.toml index 9d450f5..b8dc6a1 100644 --- a/crates/windbg-tool/Cargo.toml +++ b/crates/windbg-tool/Cargo.toml @@ -21,5 +21,6 @@ windbg-ttd = { path = "..\\windbg-ttd" } windows = { workspace = true, features = [ "Win32_Foundation", "Win32_Security", + "Win32_System_Kernel", "Win32_System_Threading", ] } diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 4502315..8c6d550 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -677,6 +677,11 @@ struct TraceRecordArgs { help = "Use a fixed-size ring buffer; requires --max-file-mb" )] ring: bool, + #[arg( + long, + help = "Launch only this target with CET user shadow stacks disabled, then record by PID attach" + )] + disable_user_shadow_stack: bool, } #[derive(Debug, Args)] diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 01c3cf7..72d6d4b 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -2,6 +2,7 @@ use anyhow::{bail, ensure, Context}; use serde_json::{json, Value}; use std::env; use std::ffi::OsString; +use std::os::windows::ffi::OsStrExt; use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; @@ -11,9 +12,15 @@ use windbg_dbgeng::{ ProcessDumpOptions, ProcessServerOptions, }; use windbg_install::WindbgManager; +use windows::core::{PCWSTR, PWSTR}; use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY}; -use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; +use windows::Win32::System::Threading::{ + CreateProcessW, DeleteProcThreadAttributeList, GetCurrentProcess, + InitializeProcThreadAttributeList, OpenProcessToken, UpdateProcThreadAttribute, + EXTENDED_STARTUPINFO_PRESENT, LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_INFORMATION, + STARTUPINFOEXW, +}; use super::output::{print_value, OutputOptions}; use super::{ @@ -59,8 +66,13 @@ pub(super) fn run_trace_record( args: TraceRecordArgs, output: &OutputOptions, ) -> anyhow::Result<()> { - let plan = trace_record_plan(&args)?; + let mut plan = trace_record_plan(&args)?; let launcher = trace_record_launcher()?; + if args.disable_user_shadow_stack { + plan.target = TraceRecordTarget::Attach { + process_id: launch_target_with_shadow_stacks_disabled(&plan.command_line)?, + }; + } let status = command_from_trace_record_plan(&plan, &launcher) .status() .with_context(|| format!("launching TTD recorder {}", plan.ttd_exe.display()))?; @@ -83,6 +95,8 @@ pub(super) fn run_trace_record( "recorder": plan.ttd_exe, "output": plan.output, "command_line": plan.command_line, + "recording_mode": plan.target.name(), + "target_process_id": plan.target.process_id(), "exit_code": status.code(), "elevation": launcher.name(), "artifacts": artifact_paths, @@ -102,6 +116,29 @@ struct TraceRecordPlan { output: PathBuf, command_line: String, recorder_args: Vec, + target: TraceRecordTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TraceRecordTarget { + Launch, + Attach { process_id: u32 }, +} + +impl TraceRecordTarget { + fn name(&self) -> &'static str { + match self { + Self::Launch => "launch", + Self::Attach { .. } => "attach_after_cet_disabled_launch", + } + } + + fn process_id(&self) -> Option { + match self { + Self::Launch => None, + Self::Attach { process_id } => Some(*process_id), + } + } } fn trace_record_plan(args: &TraceRecordArgs) -> anyhow::Result { @@ -149,13 +186,12 @@ fn trace_record_plan(args: &TraceRecordArgs) -> anyhow::Result if args.ring { recorder_args.push(OsString::from("-ring")); } - recorder_args.push(OsString::from("-launch")); - Ok(TraceRecordPlan { ttd_exe, output: args.output.clone(), command_line: args.command_line.clone(), recorder_args, + target: TraceRecordTarget::Launch, }) } @@ -250,12 +286,92 @@ fn command_from_trace_record_plan( } }; command.args(&plan.recorder_args); - // TTD requires its target command line after -launch. raw_arg preserves the caller's - // Windows command-line quoting without introducing a shell. - command.raw_arg(&plan.command_line); + match plan.target { + TraceRecordTarget::Launch => { + command.arg("-launch"); + // TTD requires its target command line after -launch. raw_arg preserves the caller's + // Windows command-line quoting without introducing a shell. + command.raw_arg(&plan.command_line); + } + TraceRecordTarget::Attach { process_id } => { + command.arg("-attach").arg(process_id.to_string()); + } + } command } +const CET_USER_SHADOW_STACKS_ALWAYS_OFF: u64 = 0x0000_0000_2000_0000; +const PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY: usize = 0x0002_0007; + +fn launch_target_with_shadow_stacks_disabled(command_line: &str) -> anyhow::Result { + let mut attribute_list_size = 0usize; + unsafe { + let _ = InitializeProcThreadAttributeList( + LPPROC_THREAD_ATTRIBUTE_LIST::default(), + 1, + 0, + &mut attribute_list_size, + ); + } + ensure!( + attribute_list_size > 0, + "allocating the process mitigation attribute list" + ); + let mut attribute_list_storage = + vec![0usize; attribute_list_size.div_ceil(std::mem::size_of::())]; + let attribute_list = LPPROC_THREAD_ATTRIBUTE_LIST(attribute_list_storage.as_mut_ptr().cast()); + let mut mitigation_policy = [0u64, CET_USER_SHADOW_STACKS_ALWAYS_OFF]; + let mut startup = STARTUPINFOEXW::default(); + let mut process_information = PROCESS_INFORMATION::default(); + let mut command_line = wide_null(command_line); + let current_directory = + env::current_dir().context("resolving the current working directory")?; + let current_directory = wide_null(current_directory.as_os_str()); + + unsafe { + InitializeProcThreadAttributeList(attribute_list, 1, 0, &mut attribute_list_size) + .context("initializing the process mitigation attribute list")?; + let update = UpdateProcThreadAttribute( + attribute_list, + 0, + PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, + Some(mitigation_policy.as_mut_ptr().cast()), + std::mem::size_of_val(&mitigation_policy), + None, + None, + ); + if let Err(error) = update { + DeleteProcThreadAttributeList(attribute_list); + return Err(error).context("setting the per-process CET mitigation"); + } + + startup.StartupInfo.cb = std::mem::size_of::() as u32; + startup.lpAttributeList = attribute_list; + let created = CreateProcessW( + None, + PWSTR(command_line.as_mut_ptr()), + None, + None, + false, + EXTENDED_STARTUPINFO_PRESENT, + None, + PCWSTR(current_directory.as_ptr()), + &startup.StartupInfo, + &mut process_information, + ); + DeleteProcThreadAttributeList(attribute_list); + created.context("launching the target with CET shadow stacks disabled")?; + CloseHandle(process_information.hThread).context("closing the target thread handle")?; + CloseHandle(process_information.hProcess).context("closing the target process handle")?; + } + + Ok(process_information.dwProcessId) +} + +fn wide_null(value: impl AsRef) -> Vec { + value.as_ref().encode_wide().chain(Some(0)).collect() +} + fn current_process_is_elevated() -> anyhow::Result { unsafe { let mut token = HANDLE::default(); @@ -584,6 +700,7 @@ mod tests { children: true, max_file_mb: Some(128), ring: true, + disable_user_shadow_stack: false, } } @@ -618,7 +735,6 @@ mod tests { "-maxFile", "128", "-ring", - "-launch", ] .into_iter() .map(OsString::from) @@ -677,6 +793,26 @@ mod tests { fs::remove_dir_all(temp).unwrap(); } + #[test] + fn cet_compatibility_mode_records_by_target_attach() { + let temp = test_directory(); + fs::create_dir_all(&temp).unwrap(); + let mut plan = trace_record_plan(&record_args(temp.join("capture.run"))).unwrap(); + plan.target = TraceRecordTarget::Attach { process_id: 4242 }; + + let command = command_from_trace_record_plan(&plan, &TraceRecordLauncher::Direct); + let arguments = command + .get_args() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect::>(); + + assert_eq!(arguments[arguments.len() - 2..], ["-attach", "4242"]); + assert_eq!(plan.target.name(), "attach_after_cet_disabled_launch"); + assert_eq!(plan.target.process_id(), Some(4242)); + + fs::remove_dir_all(temp).unwrap(); + } + #[test] fn parses_windows_sudo_modes() { assert_eq!( diff --git a/docs/cli.md b/docs/cli.md index e61ba29..de72617 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -57,6 +57,8 @@ target\debug\windbg-tool.exe --compact trace record ` The output directory must already exist and the `.run` path must not already exist. `--max-file-mb` bounds trace growth; combine it with `--ring` to retain only the most recent recording window. TTD traces include process-memory data and should be handled as sensitive artifacts. +If TTD reports that the target has CET shadow stacks enabled, use `--disable-user-shadow-stack` to launch only that new target process with the official Windows process-creation mitigation set to **always off**, then have TTD attach by PID. This is an explicit, per-process compatibility override; it does not change system policy or the target binary. Attaching begins after process creation, so it can miss the earliest startup instructions. + To enable synchronous sudo recording, choose **Input Closed** or **Inline** in **Settings > System > Advanced > Enable sudo**, or from an elevated terminal run: ```powershell From d276cbdb92096098c454ef613dbf895a6ba191f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 9 Jul 2026 15:11:33 -0400 Subject: [PATCH 08/14] Wait for CET-compatible targets before recording Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-tool/src/cli/platform.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 72d6d4b..114afe7 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -18,8 +18,8 @@ use windows::Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVAT use windows::Win32::System::Threading::{ CreateProcessW, DeleteProcThreadAttributeList, GetCurrentProcess, InitializeProcThreadAttributeList, OpenProcessToken, UpdateProcThreadAttribute, - EXTENDED_STARTUPINFO_PRESENT, LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_INFORMATION, - STARTUPINFOEXW, + WaitForInputIdle, EXTENDED_STARTUPINFO_PRESENT, LPPROC_THREAD_ATTRIBUTE_LIST, + PROCESS_INFORMATION, STARTUPINFOEXW, }; use super::output::{print_value, OutputOptions}; @@ -302,6 +302,7 @@ fn command_from_trace_record_plan( const CET_USER_SHADOW_STACKS_ALWAYS_OFF: u64 = 0x0000_0000_2000_0000; const PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY: usize = 0x0002_0007; +const WAIT_FAILED: u32 = u32::MAX; fn launch_target_with_shadow_stacks_disabled(command_line: &str) -> anyhow::Result { let mut attribute_list_size = 0usize; @@ -361,8 +362,13 @@ fn launch_target_with_shadow_stacks_disabled(command_line: &str) -> anyhow::Resu ); DeleteProcThreadAttributeList(attribute_list); created.context("launching the target with CET shadow stacks disabled")?; + let input_idle = WaitForInputIdle(process_information.hProcess, 10_000); CloseHandle(process_information.hThread).context("closing the target thread handle")?; CloseHandle(process_information.hProcess).context("closing the target process handle")?; + ensure!( + input_idle != WAIT_FAILED, + "waiting for the target process to initialize" + ); } Ok(process_information.dwProcessId) From 70ea968214f74845058c7252fc3c6c30d9c16e90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 9 Jul 2026 16:36:00 -0400 Subject: [PATCH 09/14] Improve TTD exception analysis Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-tool/src/cli.rs | 215 ++++++++++++++++++++++++- crates/windbg-tool/src/cli/dispatch.rs | 3 + docs/cli.md | 3 + 3 files changed, 215 insertions(+), 6 deletions(-) diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 8c6d550..3858582 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -165,6 +165,10 @@ enum Commands { Keyframes(SessionArgs), #[command(about = "List trace exception events")] Exceptions(SessionArgs), + Exception { + #[command(subcommand)] + command: ExceptionCommand, + }, Events { #[command(subcommand)] command: EventsCommand, @@ -512,6 +516,12 @@ enum TimelineCommand { Events(TimelineEventsArgs), } +#[derive(Debug, Subcommand)] +enum ExceptionCommand { + #[command(about = "Seek a cursor to an indexed exception event on its owning thread")] + Focus(ExceptionFocusArgs), +} + #[derive(Debug, Subcommand)] enum ModuleCommand { Info(ModuleInfoArgs), @@ -1240,6 +1250,20 @@ struct TimelineEventsArgs { max_events: usize, } +#[derive(Debug, Args)] +struct ExceptionFocusArgs { + #[arg(short = 's', long)] + session: u64, + #[arg(short = 'c', long)] + cursor: u64, + #[arg( + long, + default_value_t = 0, + help = "Zero-based index from `exceptions --session `" + )] + index: usize, +} + #[derive(Debug, Args)] struct ModuleAuditArgs { #[arg(short = 's', long)] @@ -2731,7 +2755,7 @@ fn push_suspicious_module( } fn collect_timeline_events(events: &mut Vec, kind: &str, source: &Value, array_key: &str) { - let Some(items) = source["value"][array_key].as_array() else { + let Some(items) = timeline_source_items(source, array_key) else { return; }; for item in items { @@ -2755,7 +2779,7 @@ fn collect_timeline_events(events: &mut Vec, kind: &str, source: &Value, } fn collect_keyframe_events(events: &mut Vec, source: &Value) { - let Some(items) = source["value"]["keyframes"].as_array() else { + let Some(items) = timeline_source_items(source, "keyframes") else { return; }; for position in items { @@ -2769,6 +2793,27 @@ fn collect_keyframe_events(events: &mut Vec, source: &Value) { } } +fn timeline_source_items<'a>(source: &'a Value, array_key: &str) -> Option<&'a Vec> { + let value = source.get("value")?; + value + .get(array_key) + .and_then(Value::as_array) + .or_else(|| value.as_array()) +} + +fn timeline_source_summary(source: &Value, array_key: &str) -> Value { + if source["ok"].as_bool() != Some(true) { + return source.clone(); + } + + let item_count = timeline_source_items(source, array_key).map_or(0, Vec::len); + json!({ + "ok": true, + "item_count": item_count, + "items_omitted": true + }) +} + fn timeline_sequence(event: &Value) -> u64 { event["sequence"].as_u64().unwrap_or(u64::MAX) } @@ -5484,7 +5529,10 @@ async fn timeline_events_value( .await, ); collect_timeline_events(&mut events, "module", &value, "events"); - sources.insert("modules".to_string(), value); + sources.insert( + "modules".to_string(), + timeline_source_summary(&value, "events"), + ); } if include("threads") { let value = call_status_value( @@ -5498,7 +5546,10 @@ async fn timeline_events_value( .await, ); collect_timeline_events(&mut events, "thread", &value, "events"); - sources.insert("threads".to_string(), value); + sources.insert( + "threads".to_string(), + timeline_source_summary(&value, "events"), + ); } if include("exceptions") { let value = call_status_value( @@ -5512,7 +5563,10 @@ async fn timeline_events_value( .await, ); collect_timeline_events(&mut events, "exception", &value, "exceptions"); - sources.insert("exceptions".to_string(), value); + sources.insert( + "exceptions".to_string(), + timeline_source_summary(&value, "exceptions"), + ); } if include("keyframes") { let value = call_status_value( @@ -5526,7 +5580,10 @@ async fn timeline_events_value( .await, ); collect_keyframe_events(&mut events, &value); - sources.insert("keyframes".to_string(), value); + sources.insert( + "keyframes".to_string(), + timeline_source_summary(&value, "keyframes"), + ); } events.sort_by(|left, right| { @@ -5535,6 +5592,15 @@ async fn timeline_events_value( .then_with(|| left["kind"].as_str().cmp(&right["kind"].as_str())) }); let total_events = events.len(); + let mut event_counts = Map::new(); + for event in &events { + if let Some(kind) = event["kind"].as_str() { + let count = event_counts + .entry(kind.to_string()) + .or_insert_with(|| Value::from(0_u64)); + *count = Value::from(count.as_u64().unwrap_or(0) + 1); + } + } if events.len() > args.max_events { events.truncate(args.max_events); } @@ -5542,6 +5608,7 @@ async fn timeline_events_value( "session_id": args.session, "kind": args.kind, "total_events": total_events, + "event_counts": event_counts, "max_events": args.max_events, "returned": events.len(), "limit": args.max_events, @@ -5557,6 +5624,7 @@ async fn timeline_events_value( ], "notes": [ "This timeline merges currently exposed trace metadata.", + "Sources include only status and item counts; use the corresponding metadata command for full source data.", "Recording-client/custom-event/activity/island metadata requires additional native TTD bridge coverage." ] })) @@ -5777,6 +5845,91 @@ async fn replay_to_and_print( ) } +async fn exception_focus_and_print( + pipe: String, + args: ExceptionFocusArgs, + output: &OutputOptions, +) -> anyhow::Result<()> { + let client = DaemonClient::new(pipe); + let exceptions = client + .call_tool(session_call( + "ttd_list_exceptions", + SessionArgs { + session: args.session, + }, + )) + .await?; + let exception_items = exceptions + .as_array() + .context("ttd_list_exceptions response did not include an exception array")?; + let exception = exception_items.get(args.index).cloned().with_context(|| { + format!( + "exception index {} is outside the {} recorded exception events", + args.index, + exception_items.len() + ) + })?; + let requested_position = exception["position"].clone(); + let requested_position_hex = position_hex_text(&requested_position)?; + let thread_unique_id = exception["thread_unique_id"].as_u64(); + let after = client + .call_tool(exception_focus_call(args.session, args.cursor, &exception)?) + .await?; + let exception_code_hex = exception["code"] + .as_u64() + .map(|code| format!("0x{code:08X}")); + + print_value( + json!({ + "session_id": args.session, + "cursor_id": args.cursor, + "exception_index": args.index, + "exception": exception, + "exception_code_hex": exception_code_hex, + "requested_position": requested_position, + "requested_position_hex": requested_position_hex, + "thread_unique_id": thread_unique_id, + "position": after, + "notes": [ + "This command uses the exception's JSON position directly, avoiding decimal/hexadecimal transcription errors.", + "When the trace records an owning thread, the cursor seeks on that TTD thread." + ], + "next_recommended_safe_commands": [ + format!("windbg-tool registers --session {} --cursor {}", args.session, args.cursor), + format!("windbg-tool stack backtrace --session {} --cursor {}", args.session, args.cursor), + format!("windbg-tool disasm --session {} --cursor {}", args.session, args.cursor) + ] + }), + output, + ) +} + +fn exception_focus_call(session: u64, cursor: u64, exception: &Value) -> anyhow::Result { + let position = exception["position"].clone(); + position_hex_text(&position)?; + let mut arguments = cursor_object(session, cursor); + arguments.insert("position".to_string(), position); + insert_option( + &mut arguments, + "thread_unique_id", + exception["thread_unique_id"].as_u64().map(Value::from), + ); + Ok(ToolCall { + name: "ttd_position_set".to_string(), + arguments: Value::Object(arguments), + }) +} + +fn position_hex_text(position: &Value) -> anyhow::Result { + let sequence = position["sequence"] + .as_u64() + .context("position did not include a numeric sequence")?; + let steps = position["steps"] + .as_u64() + .context("position did not include numeric steps")?; + Ok(format!("{sequence:X}:{steps:X}")) +} + async fn sweep_watch_memory_and_print( pipe: String, args: SweepWatchMemoryArgs, @@ -7208,6 +7361,56 @@ mod tests { Ok(()) } + #[test] + fn exception_focus_uses_json_position_and_owning_thread() -> anyhow::Result<()> { + let exception = json!({ + "position": { "sequence": 479966, "steps": 0 }, + "thread_unique_id": 13, + "code": 0xE06D7363u64 + }); + let call = exception_focus_call(7, 9, &exception)?; + + assert_eq!(call.name, "ttd_position_set"); + assert_eq!(call.arguments["session_id"], 7); + assert_eq!(call.arguments["cursor_id"], 9); + assert_eq!(call.arguments["position"]["sequence"], 479966); + assert_eq!(call.arguments["thread_unique_id"], 13); + assert_eq!(position_hex_text(&exception["position"])?, "752DE:0"); + Ok(()) + } + + #[test] + fn timeline_source_summary_omits_unbounded_items() { + let source = json!({ + "ok": true, + "value": [{ "sequence": 1 }, { "sequence": 2 }] + }); + let summary = timeline_source_summary(&source, "keyframes"); + + assert_eq!(summary["ok"], true); + assert_eq!(summary["item_count"], 2); + assert_eq!(summary["items_omitted"], true); + assert!(summary.get("value").is_none()); + } + + #[test] + fn timeline_collects_top_level_exception_arrays() { + let source = json!({ + "ok": true, + "value": [{ + "position": { "sequence": 479966, "steps": 0 }, + "code": 0xE06D7363u64 + }] + }); + let mut events = Vec::new(); + + collect_timeline_events(&mut events, "exception", &source, "exceptions"); + + assert_eq!(events.len(), 1); + assert_eq!(events[0]["kind"], "exception"); + assert_eq!(events[0]["sequence"], 479966); + } + #[test] fn action_log_command_path_redacts_option_values() { let path = command_path_from_args( diff --git a/crates/windbg-tool/src/cli/dispatch.rs b/crates/windbg-tool/src/cli/dispatch.rs index a300f06..6b0b8de 100644 --- a/crates/windbg-tool/src/cli/dispatch.rs +++ b/crates/windbg-tool/src/cli/dispatch.rs @@ -147,6 +147,9 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { Some(Commands::Exceptions(args)) => { call_and_print(pipe, session_call("ttd_list_exceptions", args), &output).await } + Some(Commands::Exception { command }) => match command { + ExceptionCommand::Focus(args) => exception_focus_and_print(pipe, args, &output).await, + }, Some(Commands::Events { command }) => match command { EventsCommand::Modules(args) => { call_and_print(pipe, session_call("ttd_module_events", args), &output).await diff --git a/docs/cli.md b/docs/cli.md index de72617..34023a4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -36,6 +36,7 @@ Use the returned handles with analysis commands: target\debug\windbg-tool.exe info --session 1 target\debug\windbg-tool.exe debug snapshot --session 1 --cursor 1 target\debug\windbg-tool.exe position set --session 1 --cursor 1 --position 50 +target\debug\windbg-tool.exe exception focus --session 1 --cursor 1 --index 0 target\debug\windbg-tool.exe registers --session 1 --cursor 1 target\debug\windbg-tool.exe disasm --session 1 --cursor 1 target\debug\windbg-tool.exe memory strings --session 1 --cursor 1 --address 0x12345678 --size 256 --encoding both @@ -123,6 +124,8 @@ target\debug\windbg-tool.exe --compact debug log summarize --path D:\logs\windbg - `cursor_id` identifies a replay cursor inside that trace - Many commands accept `-s` for `--session` and `-c` for `--cursor` - `position set` accepts either a structured position, a WinDbg-style `HEX:HEX` string, or a percentage from `0` to `100` +- `exception focus` accepts a zero-based exception index, seeks with the recorded JSON position and owning TTD thread, and reports a pasteable `requested_position_hex`; use it instead of manually transcribing decimal positions from `exceptions`. +- `timeline events` reports `event_counts` before applying its event limit, and limits source payloads. Its `sources` entries report source status and counts; run `modules`, `events modules`, `events threads`, `exceptions`, or `keyframes` for the full metadata list. ## Output shaping From d7018ccda0d82046c80799f860d9751ae52f0d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 9 Jul 2026 16:48:24 -0400 Subject: [PATCH 10/14] Expand TTD recording controls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-tool/src/cli.rs | 68 ++++ crates/windbg-tool/src/cli/platform.rs | 469 +++++++++++++++++++++++-- docs/cli.md | 24 +- 3 files changed, 539 insertions(+), 22 deletions(-) diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 3858582..3853bdb 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -675,6 +675,12 @@ struct TraceRecordArgs { ttd_exe: Option, #[arg(long, help = "Record child processes created by the launch target")] children: bool, + #[arg( + long = "module", + value_name = "MODULE", + help = "Restrict recording to a native module basename; repeat for each module" + )] + modules: Vec, #[arg( long, value_name = "MEGABYTES", @@ -687,6 +693,32 @@ struct TraceRecordArgs { help = "Use a fixed-size ring buffer; requires --max-file-mb" )] ring: bool, + #[arg( + long, + value_enum, + help = "TTD replay CPU compatibility contract; defaults to TTD's Default mode" + )] + replay_cpu_support: Option, + #[arg( + long, + value_name = "COUNT", + help = "Reserve this many TTD virtual CPUs; lower values reduce memory pressure but can slow recording" + )] + num_vcpu: Option, + #[arg( + long, + value_enum, + conflicts_with_all = ["max_file_mb", "ring"], + help = "Apply a bounded capture preset: startup retains early trace data; recent retains the newest window" + )] + profile: Option, + #[arg( + long, + value_name = "SECONDS", + requires = "disable_user_shadow_stack", + help = "Stop recording after this duration without terminating the CET-compatible launch target" + )] + record_for_seconds: Option, #[arg( long, help = "Launch only this target with CET user shadow stacks disabled, then record by PID attach" @@ -694,6 +726,42 @@ struct TraceRecordArgs { disable_user_shadow_stack: bool, } +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +enum TraceRecordProfile { + Startup, + Recent, +} + +impl TraceRecordProfile { + fn name(self) -> &'static str { + match self { + Self::Startup => "startup", + Self::Recent => "recent", + } + } +} + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +enum TraceReplayCpuSupport { + Default, + MostConservative, + MostAggressive, + IntelAvxRequired, + IntelAvx2Required, +} + +impl TraceReplayCpuSupport { + fn ttd_value(self) -> &'static str { + match self { + Self::Default => "Default", + Self::MostConservative => "MostConservative", + Self::MostAggressive => "MostAggressive", + Self::IntelAvxRequired => "IntelAvxRequired", + Self::IntelAvx2Required => "IntelAvx2Required", + } + } +} + #[derive(Debug, Args)] struct LiveSessionStartArgs { #[arg(long, help = "Full command line to launch under DbgEng")] diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 114afe7..7cb2c4e 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -5,7 +5,10 @@ use std::ffi::OsString; use std::os::windows::ffi::OsStrExt; use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, ExitStatus}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; use windbg_dbgeng::{ live_launch_initial_break, open_dump_session, start_process_server, write_process_dump, DumpKind, DumpOpenOptions, DumpWriteOptions, LiveLaunchEnd, LiveLaunchOptions, @@ -25,7 +28,7 @@ use windows::Win32::System::Threading::{ use super::output::{print_value, OutputOptions}; use super::{ CliDumpKind, DbgEngServerArgs, DumpCreateArgs, DumpInspectArgs, LiveLaunchArgs, - TraceRecordArgs, WindbgCommand, + TraceRecordArgs, TraceRecordProfile, TraceReplayCpuSupport, WindbgCommand, }; pub(super) fn run_dbgeng_server( @@ -73,9 +76,9 @@ pub(super) fn run_trace_record( process_id: launch_target_with_shadow_stacks_disabled(&plan.command_line)?, }; } - let status = command_from_trace_record_plan(&plan, &launcher) - .status() - .with_context(|| format!("launching TTD recorder {}", plan.ttd_exe.display()))?; + let started = Instant::now(); + let execution = execute_trace_recording(&plan, &launcher)?; + let status = execution.status; ensure!( status.success(), "TTD recorder exited with {}", @@ -90,6 +93,12 @@ pub(super) fn run_trace_record( ); let artifact_paths = trace_artifact_paths(&plan.output); + let diagnostics = trace_record_diagnostics(&plan, started.elapsed())?; + let completion_note = if plan.capture.record_for_seconds.is_some() { + "The bounded stop finalizes TTD recording without terminating the target process." + } else { + "The trace is finalized after the launched target exits." + }; print_value( json!({ "recorder": plan.ttd_exe, @@ -99,10 +108,17 @@ pub(super) fn run_trace_record( "target_process_id": plan.target.process_id(), "exit_code": status.code(), "elevation": launcher.name(), + "capture": trace_capture_value(&plan.capture), + "lifecycle": { + "record_for_seconds": plan.capture.record_for_seconds, + "stopped_after_limit": execution.stopped_after_limit, + "stop_exit_code": execution.stop_exit_code, + }, "artifacts": artifact_paths, + "diagnostics": diagnostics, "notes": [ "TTD recording is invasive and can significantly slow the target process.", - "The trace is finalized after the launched target exits.", + completion_note, "TTD traces can contain sensitive process-memory data." ] }), @@ -117,6 +133,24 @@ struct TraceRecordPlan { command_line: String, recorder_args: Vec, target: TraceRecordTarget, + capture: TraceCaptureSettings, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TraceCaptureSettings { + profile: Option, + modules: Vec, + max_file_mb: Option, + ring: bool, + replay_cpu_support: Option, + num_vcpu: Option, + record_for_seconds: Option, +} + +struct TraceRecordExecution { + status: ExitStatus, + stopped_after_limit: bool, + stop_exit_code: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -167,6 +201,20 @@ fn trace_record_plan(args: &TraceRecordArgs) -> anyhow::Result "trace output already exists: {}", args.output.display() ); + ensure!( + args.profile.is_none() || (args.max_file_mb.is_none() && !args.ring), + "--profile cannot be combined with --max-file-mb or --ring" + ); + if let Some(record_for_seconds) = args.record_for_seconds { + ensure!( + record_for_seconds > 0, + "--record-for-seconds must be greater than zero" + ); + ensure!( + args.disable_user_shadow_stack, + "--record-for-seconds requires --disable-user-shadow-stack so windbg-tool knows the target PID" + ); + } let ttd_exe = resolve_ttd_exe(args.ttd_exe.as_deref())?; let mut recorder_args = vec![ @@ -175,26 +223,197 @@ fn trace_record_plan(args: &TraceRecordArgs) -> anyhow::Result args.output.as_os_str().to_os_string(), OsString::from("-accepteula"), ]; + let (max_file_mb, ring) = trace_size_settings(args)?; + let modules = args + .modules + .iter() + .map(|module| validate_ttd_module_name(module)) + .collect::>>()?; + if args.children { recorder_args.push(OsString::from("-children")); } - if let Some(max_file_mb) = args.max_file_mb { - ensure!(max_file_mb > 0, "--max-file-mb must be greater than zero"); + for module in &modules { + recorder_args.push(OsString::from("-module")); + recorder_args.push(OsString::from(module)); + } + if let Some(max_file_mb) = max_file_mb { recorder_args.push(OsString::from("-maxFile")); recorder_args.push(OsString::from(max_file_mb.to_string())); } - if args.ring { + if ring { recorder_args.push(OsString::from("-ring")); } + if let Some(replay_cpu_support) = args.replay_cpu_support { + recorder_args.push(OsString::from("-replayCpuSupport")); + recorder_args.push(OsString::from(replay_cpu_support.ttd_value())); + } + if let Some(num_vcpu) = args.num_vcpu { + ensure!(num_vcpu > 0, "--num-vcpu must be greater than zero"); + recorder_args.push(OsString::from("-numVCpu")); + recorder_args.push(OsString::from(num_vcpu.to_string())); + } Ok(TraceRecordPlan { ttd_exe, output: args.output.clone(), command_line: args.command_line.clone(), recorder_args, target: TraceRecordTarget::Launch, + capture: TraceCaptureSettings { + profile: args.profile, + modules, + max_file_mb, + ring, + replay_cpu_support: args.replay_cpu_support, + num_vcpu: args.num_vcpu, + record_for_seconds: args.record_for_seconds, + }, + }) +} + +fn trace_size_settings(args: &TraceRecordArgs) -> anyhow::Result<(Option, bool)> { + if let Some(profile) = args.profile { + return Ok(match profile { + TraceRecordProfile::Startup => (Some(1024), false), + TraceRecordProfile::Recent => (Some(2048), true), + }); + } + + if let Some(max_file_mb) = args.max_file_mb { + ensure!(max_file_mb > 0, "--max-file-mb must be greater than zero"); + } + ensure!( + !args.ring || args.max_file_mb.is_some(), + "--ring requires --max-file-mb" + ); + Ok((args.max_file_mb, args.ring)) +} + +fn validate_ttd_module_name(module: &str) -> anyhow::Result { + let module = module.trim(); + ensure!(!module.is_empty(), "--module must not be empty"); + ensure!( + !module.contains(['\\', '/']), + "--module must be a native module basename, not a path: {module}" + ); + Ok(module.to_string()) +} + +fn trace_capture_value(capture: &TraceCaptureSettings) -> Value { + json!({ + "profile": capture.profile.map(TraceRecordProfile::name), + "modules": capture.modules, + "max_file_mb": capture.max_file_mb, + "ring": capture.ring, + "replay_cpu_support": capture.replay_cpu_support.map(TraceReplayCpuSupport::ttd_value), + "num_vcpu": capture.num_vcpu, + "record_for_seconds": capture.record_for_seconds, }) } +#[derive(Debug, Default, PartialEq, Eq)] +struct TtdSidecarSummary { + allocated_vcpus: Option, + running_threads: Option, + simulation_duration_ms: Option, + recording_engine_initialized: bool, + tracing_started: bool, + tracing_completed: bool, + trace_dumped: bool, +} + +fn trace_record_diagnostics(plan: &TraceRecordPlan, elapsed: Duration) -> anyhow::Result { + let metadata = std::fs::metadata(&plan.output) + .with_context(|| format!("reading trace metadata from {}", plan.output.display()))?; + let trace_size_bytes = metadata.len(); + let elapsed_ms = elapsed.as_millis() as u64; + let write_rate_mib_per_second = if elapsed.is_zero() { + None + } else { + Some((trace_size_bytes as f64 / 1024.0 / 1024.0) / elapsed.as_secs_f64()) + }; + let sidecar_path = plan.output.with_extension("out"); + let (sidecar, sidecar_read_error) = match std::fs::read_to_string(&sidecar_path) { + Ok(contents) => (Some(parse_ttd_sidecar(&contents)), None), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (None, None), + Err(error) => (None, Some(error.to_string())), + }; + let mut warnings = Vec::new(); + if let Some(rate) = write_rate_mib_per_second { + if rate >= 32.0 { + warnings.push(format!( + "Trace growth is high at {rate:.1} MiB/s; use --max-file-mb, --ring, --record-for-seconds, or --module to bound future captures." + )); + } + } + if trace_size_bytes >= 1024 * 1024 * 1024 { + warnings.push( + "The trace exceeds 1 GiB. Keep it local and use bounded capture settings for exploratory recordings." + .to_string(), + ); + } + if plan.capture.ring { + warnings.push( + "Ring mode retains only the newest portion of the recording once the size limit is reached." + .to_string(), + ); + } + if let Some(sidecar) = sidecar.as_ref() { + if !sidecar.recording_engine_initialized || !sidecar.trace_dumped { + warnings.push( + "TTD sidecar did not confirm both recording-engine initialization and trace finalization; inspect the .out file before relying on this capture." + .to_string(), + ); + } + } + + Ok(json!({ + "trace_size_bytes": trace_size_bytes, + "trace_size_mib": trace_size_bytes as f64 / 1024.0 / 1024.0, + "elapsed_ms": elapsed_ms, + "write_rate_mib_per_second": write_rate_mib_per_second, + "sidecar": { + "path": sidecar_path, + "available": sidecar.is_some(), + "read_error": sidecar_read_error, + "allocated_vcpus": sidecar.as_ref().and_then(|summary| summary.allocated_vcpus), + "running_threads": sidecar.as_ref().and_then(|summary| summary.running_threads), + "simulation_duration_ms": sidecar.as_ref().and_then(|summary| summary.simulation_duration_ms), + "recording_engine_initialized": sidecar.as_ref().map(|summary| summary.recording_engine_initialized), + "tracing_started": sidecar.as_ref().map(|summary| summary.tracing_started), + "tracing_completed": sidecar.as_ref().map(|summary| summary.tracing_completed), + "trace_dumped": sidecar.as_ref().map(|summary| summary.trace_dumped), + }, + "warnings": warnings, + })) +} + +fn parse_ttd_sidecar(contents: &str) -> TtdSidecarSummary { + let mut summary = TtdSidecarSummary { + recording_engine_initialized: contents + .contains("RecordingEngine initialization successful."), + tracing_started: contents.contains("Tracing started at:"), + tracing_completed: contents.contains("Tracing completed at:"), + trace_dumped: contents.contains("Trace dumped to "), + ..Default::default() + }; + for line in contents.lines() { + if let Some(values) = line.strip_prefix("Allocated processors:") { + if let Some((vcpus, threads)) = values.split_once(", running threads:") { + summary.allocated_vcpus = vcpus.trim().parse().ok(); + summary.running_threads = threads.trim_end_matches('.').trim().parse().ok(); + } + } + if line.starts_with("Simulation time of ") { + summary.simulation_duration_ms = line + .rsplit_once(':') + .and_then(|(_, duration)| duration.trim().strip_suffix("ms.")) + .and_then(|duration| duration.parse().ok()); + } + } + summary +} + #[derive(Debug, Clone, PartialEq, Eq)] enum TraceRecordLauncher { Direct, @@ -258,8 +477,25 @@ fn command_from_trace_record_plan( plan: &TraceRecordPlan, launcher: &TraceRecordLauncher, ) -> Command { - let mut command = match launcher { - TraceRecordLauncher::Direct => Command::new(&plan.ttd_exe), + let mut command = ttd_command(&plan.ttd_exe, launcher); + command.args(&plan.recorder_args); + match plan.target { + TraceRecordTarget::Launch => { + command.arg("-launch"); + // TTD requires its target command line after -launch. raw_arg preserves the caller's + // Windows command-line quoting without introducing a shell. + command.raw_arg(&plan.command_line); + } + TraceRecordTarget::Attach { process_id } => { + command.arg("-attach").arg(process_id.to_string()); + } + } + command +} + +fn ttd_command(ttd_exe: &Path, launcher: &TraceRecordLauncher) -> Command { + match launcher { + TraceRecordLauncher::Direct => Command::new(ttd_exe), TraceRecordLauncher::Sudo { executable, working_directory, @@ -281,22 +517,79 @@ fn command_from_trace_record_plan( unreachable!("asynchronous sudo mode is rejected") } } - command.arg(&plan.ttd_exe); + command.arg(ttd_exe); command } + } +} + +fn execute_trace_recording( + plan: &TraceRecordPlan, + launcher: &TraceRecordLauncher, +) -> anyhow::Result { + let mut child = command_from_trace_record_plan(plan, launcher) + .spawn() + .with_context(|| format!("launching TTD recorder {}", plan.ttd_exe.display()))?; + let (sender, receiver) = mpsc::sync_channel(1); + thread::spawn(move || { + let _ = sender.send(child.wait()); + }); + + let Some(record_for_seconds) = plan.capture.record_for_seconds else { + return Ok(TraceRecordExecution { + status: receiver + .recv() + .context("waiting for the TTD recorder process")??, + stopped_after_limit: false, + stop_exit_code: None, + }); }; - command.args(&plan.recorder_args); - match plan.target { - TraceRecordTarget::Launch => { - command.arg("-launch"); - // TTD requires its target command line after -launch. raw_arg preserves the caller's - // Windows command-line quoting without introducing a shell. - command.raw_arg(&plan.command_line); + + match receiver.recv_timeout(Duration::from_secs(record_for_seconds.into())) { + Ok(status) => Ok(TraceRecordExecution { + status: status.context("waiting for the TTD recorder process")?, + stopped_after_limit: false, + stop_exit_code: None, + }), + Err(mpsc::RecvTimeoutError::Timeout) => { + let process_id = plan + .target + .process_id() + .context("--record-for-seconds requires a trace target with a known process id")?; + let stop_status = command_from_ttd_stop(&plan.ttd_exe, launcher, process_id) + .status() + .context("stopping the bounded TTD recording")?; + ensure!( + stop_status.success(), + "TTD recorder stop request exited with {}; the recording may still be active", + stop_status + .code() + .map_or_else(|| "no exit code".to_string(), |code| code.to_string()) + ); + Ok(TraceRecordExecution { + status: receiver + .recv() + .context("waiting for TTD trace finalization after the stop request")??, + stopped_after_limit: true, + stop_exit_code: stop_status.code(), + }) } - TraceRecordTarget::Attach { process_id } => { - command.arg("-attach").arg(process_id.to_string()); + Err(mpsc::RecvTimeoutError::Disconnected) => { + bail!("TTD recorder wait worker disconnected unexpectedly") } } +} + +fn command_from_ttd_stop( + ttd_exe: &Path, + launcher: &TraceRecordLauncher, + process_id: u32, +) -> Command { + let mut command = ttd_command(ttd_exe, launcher); + command + .arg("-accepteula") + .arg("-stop") + .arg(process_id.to_string()); command } @@ -464,6 +757,10 @@ fn find_executable_on_path(name: &str) -> Option { fn trace_artifact_paths(output: &Path) -> Vec { let mut artifacts = vec![output.to_path_buf()]; + let sidecar = output.with_extension("out"); + if sidecar.exists() { + artifacts.push(sidecar); + } let index = output.with_extension("idx"); if index.exists() { artifacts.push(index); @@ -704,8 +1001,16 @@ mod tests { command_line: r#""C:\Program Files\App\app.exe" --flag "two words""#.to_string(), ttd_exe: Some(env::current_exe().unwrap()), children: true, + modules: vec![ + "RemoteDesktopManager_x64.exe".to_string(), + "coreclr.dll".to_string(), + ], max_file_mb: Some(128), ring: true, + replay_cpu_support: Some(TraceReplayCpuSupport::MostAggressive), + num_vcpu: Some(16), + profile: None, + record_for_seconds: None, disable_user_shadow_stack: false, } } @@ -738,9 +1043,17 @@ mod tests { output.to_string_lossy().as_ref(), "-accepteula", "-children", + "-module", + "RemoteDesktopManager_x64.exe", + "-module", + "coreclr.dll", "-maxFile", "128", "-ring", + "-replayCpuSupport", + "MostAggressive", + "-numVCpu", + "16", ] .into_iter() .map(OsString::from) @@ -762,6 +1075,120 @@ mod tests { fs::remove_dir_all(temp).unwrap(); } + #[test] + fn trace_record_profiles_resolve_bounded_capture_settings() { + let temp = test_directory(); + fs::create_dir_all(&temp).unwrap(); + let output = temp.join("capture.run"); + let mut args = record_args(output); + args.children = false; + args.modules.clear(); + args.max_file_mb = None; + args.ring = false; + args.replay_cpu_support = None; + args.num_vcpu = None; + args.profile = Some(TraceRecordProfile::Startup); + + let startup = trace_record_plan(&args).unwrap(); + assert_eq!(startup.capture.max_file_mb, Some(1024)); + assert!(!startup.capture.ring); + assert_eq!(startup.capture.profile, Some(TraceRecordProfile::Startup)); + + args.profile = Some(TraceRecordProfile::Recent); + let recent = trace_record_plan(&args).unwrap(); + assert_eq!(recent.capture.max_file_mb, Some(2048)); + assert!(recent.capture.ring); + + fs::remove_dir_all(temp).unwrap(); + } + + #[test] + fn trace_record_plan_rejects_module_paths_and_zero_vcpus() { + let temp = test_directory(); + fs::create_dir_all(&temp).unwrap(); + let output = temp.join("capture.run"); + let mut args = record_args(output); + args.modules = vec![r"C:\Windows\System32\kernel32.dll".to_string()]; + assert!(trace_record_plan(&args).is_err()); + + args.modules = vec!["kernel32.dll".to_string()]; + args.num_vcpu = Some(0); + assert!(trace_record_plan(&args).is_err()); + + fs::remove_dir_all(temp).unwrap(); + } + + #[test] + fn trace_record_plan_rejects_unbounded_profile_conflicts_and_invalid_duration() { + let temp = test_directory(); + fs::create_dir_all(&temp).unwrap(); + let output = temp.join("capture.run"); + let mut args = record_args(output); + args.profile = Some(TraceRecordProfile::Startup); + assert!(trace_record_plan(&args).is_err()); + + args.profile = None; + args.max_file_mb = None; + args.ring = false; + args.record_for_seconds = Some(0); + assert!(trace_record_plan(&args).is_err()); + + args.record_for_seconds = Some(10); + assert!(trace_record_plan(&args).is_err()); + + args.disable_user_shadow_stack = true; + assert_eq!( + trace_record_plan(&args).unwrap().capture.record_for_seconds, + Some(10) + ); + + fs::remove_dir_all(temp).unwrap(); + } + + #[test] + fn ttd_sidecar_parser_extracts_recording_summary() { + let summary = parse_ttd_sidecar( + "Allocated processors:55, running threads:16.\n\ + RecordingEngine initialization successful.\n\ + Tracing started at: Thu Jul 9 20:04:35 2026 (UTC)\n\ + Simulation time of '' (x64): 188031ms.\n\ + Tracing completed at: Thu Jul 9 20:07:43 2026 (UTC)\n\ + Trace dumped to C:\\trace\\capture.run\n", + ); + + assert_eq!( + summary, + TtdSidecarSummary { + allocated_vcpus: Some(55), + running_threads: Some(16), + simulation_duration_ms: Some(188031), + recording_engine_initialized: true, + tracing_started: true, + tracing_completed: true, + trace_dumped: true, + } + ); + } + + #[test] + fn ttd_stop_command_uses_the_target_process_id() { + let command = command_from_ttd_stop( + Path::new(r"C:\tools\TTD.exe"), + &TraceRecordLauncher::Direct, + 4242, + ); + let arguments = command + .get_args() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect::>(); + + assert_eq!( + command.get_program(), + Path::new(r"C:\tools\TTD.exe").as_os_str() + ); + assert_eq!(arguments, ["-accepteula", "-stop", "4242"]); + } + #[test] fn sudo_launcher_wraps_ttd_and_preserves_working_directory() { let temp = test_directory(); diff --git a/docs/cli.md b/docs/cli.md index 34023a4..d57fe03 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -56,10 +56,32 @@ target\debug\windbg-tool.exe --compact trace record ` --command-line '"C:\apps\RemoteDesktopManager.exe" /AutoCloseAfter:10' ``` -The output directory must already exist and the `.run` path must not already exist. `--max-file-mb` bounds trace growth; combine it with `--ring` to retain only the most recent recording window. TTD traces include process-memory data and should be handled as sensitive artifacts. +The output directory must already exist and the `.run` path must not already exist. TTD traces include process-memory data and should be handled as sensitive artifacts. + +For high-overhead startup captures, first choose a bounded capture strategy: + +```powershell +# Preserve the earliest startup data, up to 1 GiB. +target\debug\windbg-tool.exe trace record --profile startup ` + --output C:\traces\rdm-startup.run ` + --command-line '"C:\apps\RemoteDesktopManager.exe" /AutoCloseAfter:10' + +# Retain only the newest data in a 2 GiB rolling window. +target\debug\windbg-tool.exe trace record --profile recent ` + --output C:\traces\rdm-recent.run ` + --command-line '"C:\apps\RemoteDesktopManager.exe" /AutoCloseAfter:10' +``` + +`--max-file-mb ` provides a custom cap; add `--ring` to retain the newest data after the cap is reached. `--profile` is a convenience preset and cannot be combined with either custom size option. + +Use repeatable `--module ` to ask TTD to record only selected native modules, for example `--module RemoteDesktopManager_x64.exe --module coreclr.dll`. The values must be basenames, not paths. Module filtering is an experiment: managed code often executes as JIT-generated code associated with `coreclr.dll`, so filtering only the application executable can omit needed behavior. + +`--replay-cpu-support ` forwards TTD's replay CPU compatibility setting. `--num-vcpu ` can reduce recorder memory pressure, but TTD warns that lowering it can severely slow recording; do not use it as a performance optimization. If TTD reports that the target has CET shadow stacks enabled, use `--disable-user-shadow-stack` to launch only that new target process with the official Windows process-creation mitigation set to **always off**, then have TTD attach by PID. This is an explicit, per-process compatibility override; it does not change system policy or the target binary. Attaching begins after process creation, so it can miss the earliest startup instructions. +When using that CET-compatible attach mode, `--record-for-seconds ` asks TTD to stop recording after the requested window without terminating the target. This bounds a hung or slowed startup capture but necessarily retains the attach-mode earliest-startup limitation. After a successful recording, the result includes trace size, elapsed time, output rate, and parsed `.out` sidecar data such as allocated vCPUs, active threads, simulation duration, and finalization state. + To enable synchronous sudo recording, choose **Input Closed** or **Inline** in **Settings > System > Advanced > Enable sudo**, or from an elevated terminal run: ```powershell From bd6f1a42420a49b039879abe2dd2452a35d764e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 9 Jul 2026 21:51:49 -0400 Subject: [PATCH 11/14] Honor standard symbol environment paths Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-dbgeng/src/lib.rs | 174 +++++++++++++++++++- crates/windbg-ttd/src/ttd_replay/symbols.rs | 86 ++++++---- docs/cli.md | 6 + docs/development.md | 4 +- docs/mcp.md | 10 +- 5 files changed, 240 insertions(+), 40 deletions(-) diff --git a/crates/windbg-dbgeng/src/lib.rs b/crates/windbg-dbgeng/src/lib.rs index 177fe25..ddd0a9e 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -1,6 +1,92 @@ use anyhow::{bail, Context}; use serde::Serialize; -use std::path::PathBuf; +use std::{ + env, + path::{Path, PathBuf}, +}; + +pub const MICROSOFT_SYMBOL_SERVER: &str = "https://msdl.microsoft.com/download/symbols"; +pub const NT_SYMBOL_PATH_ENV: &str = "_NT_SYMBOL_PATH"; +pub const NT_ALT_SYMBOL_PATH_ENV: &str = "_NT_ALT_SYMBOL_PATH"; +pub const NT_SYMCACHE_PATH_ENV: &str = "_NT_SYMCACHE_PATH"; +const DEFAULT_DBGENG_SYMBOL_CACHE: &str = ".windbg-symbol-cache"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StandardSymbolEnvironment { + pub symbol_path: Option, + pub symcache_dir: Option, +} + +impl StandardSymbolEnvironment { + pub fn from_process() -> Self { + Self::from_values( + env_path(NT_SYMBOL_PATH_ENV), + env_path(NT_ALT_SYMBOL_PATH_ENV), + env::var_os(NT_SYMCACHE_PATH_ENV).map(PathBuf::from), + ) + } + + pub fn from_values( + symbol_path: Option, + alternate_symbol_path: Option, + symcache_dir: Option, + ) -> Self { + let symbol_path = [symbol_path, alternate_symbol_path] + .into_iter() + .flatten() + .filter(|path| !path.is_empty()) + .collect::>() + .join(";"); + Self { + symbol_path: (!symbol_path.is_empty()).then_some(symbol_path), + symcache_dir: symcache_dir.filter(|path| !path.as_os_str().is_empty()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedDbgEngSymbolPath { + pub symbol_path: String, + pub symbol_cache_dir: PathBuf, +} + +pub fn resolve_dbgeng_symbol_path() -> ResolvedDbgEngSymbolPath { + resolve_dbgeng_symbol_path_with_environment( + StandardSymbolEnvironment::from_process(), + Path::new(DEFAULT_DBGENG_SYMBOL_CACHE), + ) +} + +pub fn resolve_dbgeng_symbol_path_with_environment( + environment: StandardSymbolEnvironment, + default_cache_dir: &Path, +) -> ResolvedDbgEngSymbolPath { + let symbol_cache_dir = environment + .symcache_dir + .unwrap_or_else(|| default_cache_dir.to_path_buf()); + let mut paths = environment.symbol_path.into_iter().collect::>(); + if !paths + .iter() + .any(|path| path.contains(MICROSOFT_SYMBOL_SERVER)) + { + paths.push(format!( + "srv*{}*{}", + symbol_cache_dir.to_string_lossy(), + MICROSOFT_SYMBOL_SERVER + )); + } + ResolvedDbgEngSymbolPath { + symbol_path: paths.join(";"), + symbol_cache_dir, + } +} + +fn env_path(name: &str) -> Option { + env::var_os(name).and_then(|value| { + let value = value.to_string_lossy().trim().to_string(); + (!value.is_empty()).then_some(value) + }) +} #[derive(Debug, Clone)] pub struct ProcessServerOptions { @@ -79,6 +165,7 @@ pub struct LiveLaunchResult { pub wait_succeeded: bool, pub execution_status: Option, pub execution_status_name: Option, + pub symbol_path: String, pub end: LiveLaunchEnd, } @@ -109,6 +196,7 @@ pub struct DebuggerSessionSummary { pub processor_type: Option, pub processor_name: Option, pub execution_status: DebuggerExecutionStatus, + pub symbol_path: String, } #[derive(Debug, Clone, Serialize)] @@ -245,6 +333,7 @@ pub struct DebuggerSession { registers: windows::Win32::System::Diagnostics::Debug::Extensions::IDebugRegisters, symbols: windows::Win32::System::Diagnostics::Debug::Extensions::IDebugSymbols5, system_objects: windows::Win32::System::Diagnostics::Debug::Extensions::IDebugSystemObjects, + symbol_path: String, } #[cfg(windows)] @@ -264,6 +353,7 @@ impl DebuggerSession { processor_type: self.processor_type().ok(), processor_name: self.processor_name().ok(), execution_status: self.execution_status(), + symbol_path: self.symbol_path.clone(), } } @@ -714,6 +804,7 @@ impl DebuggerSession { raw: None, name: None, }, + symbol_path: resolve_dbgeng_symbol_path().symbol_path, } } @@ -877,6 +968,7 @@ fn live_launch_initial_break_impl(options: LiveLaunchOptions) -> anyhow::Result< initial_break_timeout_ms: options.initial_break_timeout_ms, })?; let execution_status = session.execution_status(); + let symbol_path = session.symbol_path.clone(); match options.end { LiveLaunchEnd::Detach => session.detach()?, LiveLaunchEnd::Terminate => session.terminate()?, @@ -888,6 +980,7 @@ fn live_launch_initial_break_impl(options: LiveLaunchOptions) -> anyhow::Result< wait_succeeded: true, execution_status: execution_status.raw, execution_status_name: execution_status.name, + symbol_path, end: options.end, }) } @@ -909,6 +1002,7 @@ fn launch_live_session_impl(options: LiveLaunchSessionOptions) -> anyhow::Result let registers: IDebugRegisters = client.cast()?; let symbols: IDebugSymbols5 = client.cast()?; let system_objects: IDebugSystemObjects = client.cast()?; + let symbol_path = configure_dbgeng_symbol_path(&symbols)?; unsafe { client.CreateProcessWide( 0, @@ -932,6 +1026,7 @@ fn launch_live_session_impl(options: LiveLaunchSessionOptions) -> anyhow::Result registers, symbols, system_objects, + symbol_path, }) } @@ -949,6 +1044,7 @@ fn attach_live_session_impl(options: LiveAttachOptions) -> anyhow::Result anyhow::Result anyhow::Result anyhow::Result anyhow::Result { + use windows::core::PCWSTR; + + let symbol_config = resolve_dbgeng_symbol_path(); + let mut symbol_path = symbol_config.symbol_path.encode_utf16().collect::>(); + symbol_path.push(0); + unsafe { + symbols + .SetSymbolPathWide(PCWSTR(symbol_path.as_ptr())) + .context("setting the DbgEng symbol path")?; + } + Ok(symbol_config.symbol_path) +} + #[cfg(windows)] fn write_process_dump_impl(options: ProcessDumpOptions) -> anyhow::Result { let _ = options.initial_break_timeout_ms; @@ -1229,4 +1345,60 @@ mod tests { assert_eq!(dump_format_flags(false), 0x8000_0000); assert_eq!(dump_format_flags(true), 0); } + + #[test] + fn standard_symbol_environment_preserves_windows_search_order() { + let environment = StandardSymbolEnvironment::from_values( + Some("srv*C:\\primary*https://symbols.example.test".to_string()), + Some("C:\\alternate-symbols".to_string()), + Some(PathBuf::from("C:\\symbol-cache")), + ); + + assert_eq!( + environment.symbol_path.as_deref(), + Some("srv*C:\\primary*https://symbols.example.test;C:\\alternate-symbols") + ); + assert_eq!( + environment.symcache_dir, + Some(PathBuf::from("C:\\symbol-cache")) + ); + } + + #[test] + fn dbgeng_symbol_path_appends_public_server_using_environment_cache() { + let resolved = resolve_dbgeng_symbol_path_with_environment( + StandardSymbolEnvironment::from_values( + Some("C:\\private-symbols".to_string()), + Some("C:\\alternate-symbols".to_string()), + Some(PathBuf::from("C:\\symbol-cache")), + ), + Path::new("unused-cache"), + ); + + assert_eq!( + resolved.symbol_path, + "C:\\private-symbols;C:\\alternate-symbols;srv*C:\\symbol-cache*https://msdl.microsoft.com/download/symbols" + ); + assert_eq!(resolved.symbol_cache_dir, PathBuf::from("C:\\symbol-cache")); + } + + #[test] + fn dbgeng_symbol_path_does_not_duplicate_public_server() { + let resolved = resolve_dbgeng_symbol_path_with_environment( + StandardSymbolEnvironment::from_values( + Some(format!("srv*C:\\cache*{MICROSOFT_SYMBOL_SERVER}")), + None, + Some(PathBuf::from("C:\\unused-cache")), + ), + Path::new("unused-cache"), + ); + + assert_eq!( + resolved + .symbol_path + .matches(MICROSOFT_SYMBOL_SERVER) + .count(), + 1 + ); + } } diff --git a/crates/windbg-ttd/src/ttd_replay/symbols.rs b/crates/windbg-ttd/src/ttd_replay/symbols.rs index 33d4984..78c1baf 100644 --- a/crates/windbg-ttd/src/ttd_replay/symbols.rs +++ b/crates/windbg-ttd/src/ttd_replay/symbols.rs @@ -1,13 +1,12 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::{env, path::PathBuf}; +use std::path::PathBuf; +use windbg_dbgeng::{StandardSymbolEnvironment, MICROSOFT_SYMBOL_SERVER}; -pub const MICROSOFT_SYMBOL_SERVER: &str = "https://msdl.microsoft.com/download/symbols"; -const NT_SYMBOL_PATH_ENV: &str = "_NT_SYMBOL_PATH"; const DEFAULT_SYMBOL_CACHE: &str = ".ttd-symbol-cache"; const DEFAULT_SYMBOL_RUNTIME_DIR: &str = "target/symbol-runtime"; -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] pub struct SymbolSettings { #[serde(default)] pub binary_paths: Vec, @@ -16,16 +15,6 @@ pub struct SymbolSettings { pub symcache_dir: Option, } -impl Default for SymbolSettings { - fn default() -> Self { - Self { - binary_paths: Vec::new(), - symbol_paths: Vec::new(), - symcache_dir: Some(PathBuf::from(".ttd-symbol-cache")), - } - } -} - impl SymbolSettings { pub fn effective_symbol_path(&self) -> String { self.resolve(None).symbol_path @@ -36,16 +25,22 @@ impl SymbolSettings { } pub fn resolve(&self, symbol_runtime_dir: Option) -> ResolvedSymbolConfig { - self.resolve_with_symbol_path_env(symbol_runtime_dir, env_symbol_path()) + self.resolve_with_standard_symbol_environment( + symbol_runtime_dir, + StandardSymbolEnvironment::from_process(), + ) } - fn resolve_with_symbol_path_env( + fn resolve_with_standard_symbol_environment( &self, symbol_runtime_dir: Option, - env_symbol_path: Option, + environment: StandardSymbolEnvironment, ) -> ResolvedSymbolConfig { let mut paths = if self.symbol_paths.is_empty() { - env_symbol_path.map(|path| vec![path]).unwrap_or_default() + environment + .symbol_path + .map(|path| vec![path]) + .unwrap_or_default() } else { self.symbol_paths.clone() }; @@ -56,6 +51,7 @@ impl SymbolSettings { let symbol_cache_dir = self .symcache_dir .clone() + .or(environment.symcache_dir) .unwrap_or_else(|| PathBuf::from(DEFAULT_SYMBOL_CACHE)); if !microsoft_public_symbols { @@ -93,18 +89,11 @@ pub struct ResolvedSymbolConfig { } pub fn default_symbol_runtime_dir() -> PathBuf { - env::var_os("TTD_SYMBOL_RUNTIME_DIR") + std::env::var_os("TTD_SYMBOL_RUNTIME_DIR") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(DEFAULT_SYMBOL_RUNTIME_DIR)) } -fn env_symbol_path() -> Option { - env::var_os(NT_SYMBOL_PATH_ENV).and_then(|value| { - let value = value.to_string_lossy().trim().to_string(); - (!value.is_empty()).then_some(value) - }) -} - impl ResolvedSymbolConfig { pub fn has_image_path(&self) -> bool { !self.image_path.is_empty() @@ -119,7 +108,10 @@ mod tests { fn builds_default_symbol_path() { let settings = SymbolSettings::default(); assert!(settings - .resolve_with_symbol_path_env(None, None) + .resolve_with_standard_symbol_environment( + None, + StandardSymbolEnvironment::from_values(None, None, None), + ) .symbol_path .contains(MICROSOFT_SYMBOL_SERVER)); } @@ -131,7 +123,14 @@ mod tests { ..SymbolSettings::default() }; - let resolved = settings.resolve_with_symbol_path_env(None, None); + let resolved = settings.resolve_with_standard_symbol_environment( + None, + StandardSymbolEnvironment::from_values( + None, + None, + Some(PathBuf::from("target/environment-symbol-cache")), + ), + ); assert_eq!( resolved.symbol_cache_dir, PathBuf::from("target/test-symbol-cache") @@ -148,7 +147,10 @@ mod tests { ..SymbolSettings::default() }; - let resolved = settings.resolve_with_symbol_path_env(None, None); + let resolved = settings.resolve_with_standard_symbol_environment( + None, + StandardSymbolEnvironment::from_values(None, None, None), + ); assert_eq!( resolved .symbol_path @@ -159,15 +161,21 @@ mod tests { } #[test] - fn uses_nt_symbol_path_when_symbol_paths_are_empty() { - let resolved = SymbolSettings::default().resolve_with_symbol_path_env( + fn uses_standard_symbol_environment_when_symbol_paths_are_empty() { + let resolved = SymbolSettings::default().resolve_with_standard_symbol_environment( None, - Some("srv*C:/env-symbols*https://example.invalid/symbols".to_string()), + StandardSymbolEnvironment::from_values( + Some("srv*C:/env-symbols*https://example.invalid/symbols".to_string()), + Some("C:/alternate-symbols".to_string()), + Some(PathBuf::from("C:/symbol-cache")), + ), ); assert!(resolved .symbol_path .contains("srv*C:/env-symbols*https://example.invalid/symbols")); assert!(resolved.symbol_path.contains(MICROSOFT_SYMBOL_SERVER)); + assert!(resolved.symbol_path.contains("C:/alternate-symbols")); + assert_eq!(resolved.symbol_cache_dir, PathBuf::from("C:/symbol-cache")); } #[test] @@ -179,9 +187,13 @@ mod tests { ..SymbolSettings::default() }; - let resolved = settings.resolve_with_symbol_path_env( + let resolved = settings.resolve_with_standard_symbol_environment( None, - Some("srv*C:/env-symbols*https://example.invalid/symbols".to_string()), + StandardSymbolEnvironment::from_values( + Some("srv*C:/env-symbols*https://example.invalid/symbols".to_string()), + Some("C:/alternate-symbols".to_string()), + Some(PathBuf::from("C:/symbol-cache")), + ), ); assert!(resolved @@ -200,8 +212,10 @@ mod tests { ..SymbolSettings::default() }; - let resolved = settings - .resolve_with_symbol_path_env(Some(PathBuf::from("target/symbol-runtime")), None); + let resolved = settings.resolve_with_standard_symbol_environment( + Some(PathBuf::from("target/symbol-runtime")), + StandardSymbolEnvironment::from_values(None, None, None), + ); assert_eq!(resolved.binary_path_count, 2); assert_eq!( resolved.symbol_runtime_dir, diff --git a/docs/cli.md b/docs/cli.md index d57fe03..954c1e5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -168,6 +168,12 @@ target\debug\windbg-tool.exe --compact --envelope sessions In envelope mode, successful commands return `{ "schema_version": 1, "ok": true, "data": ... }`. `--field` selects from `data`, so `--envelope --field session_id --raw open ...` still prints the raw session id. Failures return `{ "schema_version": 1, "ok": false, "error": ... }` and ignore `--field`/`--raw` so agents always receive the full error reason. +## Symbol path environment + +TTD replay and every DbgEng live-launch, live-attach, and dump session honor the standard Windows symbol environment. Explicit TTD `--symbol-path` values take precedence; otherwise `_NT_SYMBOL_PATH` is searched first, then `_NT_ALT_SYMBOL_PATH`. TTD `--symcache-dir` takes precedence over `_NT_SYMCACHE_PATH`. + +If no selected path includes the Microsoft public symbol server, windbg-tool appends `srv**https://msdl.microsoft.com/download/symbols`. The default cache directories are `.ttd-symbol-cache` for TTD and `.windbg-symbol-cache` for DbgEng. DbgEng target summaries and `live launch` results expose the final `symbol_path`. + Structured error codes use stable exit codes: | Code | Exit | Retryable | diff --git a/docs/development.md b/docs/development.md index fd8e464..852682d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -84,13 +84,13 @@ Runtime replay still depends on `TTDReplay.dll` and `TTDReplayCPU.dll` from the ## Symbols -The default symbol path is equivalent to: +When neither `_NT_SYMBOL_PATH` nor `_NT_ALT_SYMBOL_PATH` is set, the TTD replay default symbol path is equivalent to: ```text srv*.ttd-symbol-cache*https://msdl.microsoft.com/download/symbols ``` -The project keeps symbol/runtime setup repo-local and process-local. It does not need to write debugger registry keys or machine-wide `_NT_SYMBOL_PATH` values as part of normal operation. +The project keeps symbol/runtime setup repo-local and process-local. It does not write debugger registry keys or machine-wide symbol environment values. For TTD and DbgEng sessions, explicit paths take precedence over `_NT_SYMBOL_PATH`; `_NT_SYMBOL_PATH` is searched before `_NT_ALT_SYMBOL_PATH`; and explicit cache settings take precedence over `_NT_SYMCACHE_PATH`. If none sets a cache, TTD uses `.ttd-symbol-cache` and DbgEng uses `.windbg-symbol-cache`. ## Sample trace fixture diff --git a/docs/mcp.md b/docs/mcp.md index 0c39e10..79da12d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -82,7 +82,15 @@ Then create a cursor: } ``` -If `symbol_paths` is empty, the server can fall back to `_NT_SYMBOL_PATH`. If the resulting path does not already include the Microsoft public symbol server, it appends the usual public symbol server form automatically. +Symbol settings use this precedence: + +1. Explicit `symbols.symbol_paths` from the request. +2. `_NT_SYMBOL_PATH`, then `_NT_ALT_SYMBOL_PATH`, when no explicit paths are supplied. +3. The module directory, as provided by the Windows symbol handler. + +`symbols.symcache_dir` overrides `_NT_SYMCACHE_PATH`; otherwise `_NT_SYMCACHE_PATH` supplies the cache directory. If the resulting path does not already include the Microsoft public symbol server, the server appends `srv**https://msdl.microsoft.com/download/symbols`. + +The same environment resolution is applied to DbgEng live-launch, live-attach, and dump sessions. Their active resolved path is included in the target session summary as `symbol_path`. ## Example prompts From 55802ed8307a6cafc423f1616f7d4cff184e0a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 9 Jul 2026 21:58:22 -0400 Subject: [PATCH 12/14] Add live startup breakpoint workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-dbgeng/src/lib.rs | 92 ++++++++- crates/windbg-tool/src/cli.rs | 46 +++++ crates/windbg-tool/src/cli/dispatch.rs | 1 + crates/windbg-tool/src/cli/platform.rs | 275 ++++++++++++++++++++++++- docs/cli.md | 21 +- 5 files changed, 426 insertions(+), 9 deletions(-) diff --git a/crates/windbg-dbgeng/src/lib.rs b/crates/windbg-dbgeng/src/lib.rs index ddd0a9e..4075cd0 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -370,10 +370,17 @@ impl DebuggerSession { } pub fn wait_for_event(&self, timeout_ms: u32) -> anyhow::Result { - use windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_WAIT_DEFAULT; + use windows::Win32::System::Diagnostics::Debug::Extensions::{ + DEBUG_STATUS_TIMEOUT, DEBUG_WAIT_DEFAULT, + }; - unsafe { self.control.WaitForEvent(DEBUG_WAIT_DEFAULT, timeout_ms)? }; - Ok(self.execution_status()) + let wait = unsafe { self.control.WaitForEvent(DEBUG_WAIT_DEFAULT, timeout_ms) }; + let status = self.execution_status(); + match wait { + Ok(()) => Ok(status), + Err(_) if status.raw == Some(DEBUG_STATUS_TIMEOUT) => Ok(status), + Err(error) => Err(error.into()), + } } pub fn continue_execution(&self) -> anyhow::Result { @@ -512,6 +519,26 @@ impl DebuggerSession { Ok(modules) } + pub fn module_by_offset(&self, address: u64) -> anyhow::Result> { + use windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_GETMOD_NO_UNLOADED_MODULES; + + let mut index = 0u32; + let mut base_address = 0u64; + let result = unsafe { + self.symbols.GetModuleByOffset2( + address, + 0, + DEBUG_GETMOD_NO_UNLOADED_MODULES, + Some(&mut index), + Some(&mut base_address), + ) + }; + if result.is_err() { + return Ok(None); + } + Ok(Some(self.module_info(index, base_address))) + } + pub fn symbol_by_offset(&self, address: u64) -> anyhow::Result> { self.try_symbol_by_offset(address) } @@ -644,6 +671,28 @@ impl DebuggerSession { self.breakpoint_info(&breakpoint) } + pub fn add_code_breakpoint_expression( + &self, + expression: &str, + ) -> anyhow::Result { + use windows::core::PCWSTR; + use windows::Win32::System::Diagnostics::Debug::Extensions::{ + DEBUG_ANY_ID, DEBUG_BREAKPOINT_CODE, DEBUG_BREAKPOINT_ENABLED, + }; + + let mut expression_wide = expression.encode_utf16().collect::>(); + expression_wide.push(0); + let breakpoint = unsafe { + self.control + .AddBreakpoint2(DEBUG_BREAKPOINT_CODE, DEBUG_ANY_ID)? + }; + unsafe { + breakpoint.SetOffsetExpressionWide(PCWSTR(expression_wide.as_ptr()))?; + breakpoint.AddFlags(DEBUG_BREAKPOINT_ENABLED)?; + } + self.breakpoint_info(&breakpoint) + } + pub fn add_data_breakpoint( &self, address: u64, @@ -740,6 +789,32 @@ impl DebuggerSession { .ok() } + fn module_info(&self, index: u32, base_address: u64) -> ModuleInfo { + ModuleInfo { + base_address, + module_name: self.module_name_string( + windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_MODNAME_MODULE, + index, + base_address, + ), + image_name: self.module_name_string( + windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_MODNAME_IMAGE, + index, + base_address, + ), + loaded_image_name: self.module_name_string( + windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_MODNAME_LOADED_IMAGE, + index, + base_address, + ), + symbol_file: self.module_name_string( + windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_MODNAME_SYMBOL_FILE, + index, + base_address, + ), + } + } + fn try_symbol_by_offset(&self, address: u64) -> anyhow::Result> { let mut displacement = 0u64; let name = match read_wide_string(|buffer, size| unsafe { @@ -859,6 +934,10 @@ impl DebuggerSession { anyhow::bail!("DbgEng sessions are only supported on Windows") } + pub fn module_by_offset(&self, _address: u64) -> anyhow::Result> { + anyhow::bail!("DbgEng sessions are only supported on Windows") + } + pub fn symbol_by_offset(&self, _address: u64) -> anyhow::Result> { anyhow::bail!("DbgEng sessions are only supported on Windows") } @@ -887,6 +966,13 @@ impl DebuggerSession { anyhow::bail!("DbgEng sessions are only supported on Windows") } + pub fn add_code_breakpoint_expression( + &self, + _expression: &str, + ) -> anyhow::Result { + anyhow::bail!("DbgEng sessions are only supported on Windows") + } + pub fn add_data_breakpoint( &self, _address: u64, diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 3853bdb..386d100 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -273,6 +273,10 @@ enum LiveCommand { about = "Launch a process under DbgEng, wait for the initial event, then detach or terminate" )] Launch(LiveLaunchArgs), + #[command( + about = "Launch under DbgEng, set an address/module-RVA/symbol breakpoint, and emit bounded stop context" + )] + StartupBreak(LiveStartupBreakArgs), #[command(about = "Launch a process under DbgEng and keep it as a daemon-owned live target")] Start(LiveSessionStartArgs), #[command(about = "Attach DbgEng to a process and keep it as a daemon-owned live target")] @@ -654,6 +658,38 @@ struct LiveLaunchArgs { end: String, } +#[derive(Debug, Args)] +struct LiveStartupBreakArgs { + #[arg(long, help = "Full command line to launch under DbgEng")] + command_line: String, + #[arg(long, help = "Absolute code address for the breakpoint")] + address: Option, + #[arg( + long, + help = "Loaded module basename or image path for an RVA breakpoint" + )] + module: Option, + #[arg( + long, + requires = "module", + help = "RVA added to --module's loaded base address" + )] + module_offset: Option, + #[arg( + long, + help = "DbgEng symbol expression; remains deferred until its module and symbol resolve" + )] + symbol: Option, + #[arg(long, default_value_t = 5000)] + initial_break_timeout_ms: u32, + #[arg(long, default_value_t = 10000)] + wait_timeout_ms: u32, + #[arg(long, default_value_t = 16)] + max_frames: u32, + #[arg(long, default_value = "terminate", value_parser = ["detach", "terminate"])] + end: String, +} + #[derive(Debug, Args, Clone)] struct TraceRecordArgs { #[arg( @@ -4333,6 +4369,7 @@ fn inferred_command_metadata(command: &Command, path: &[String]) -> Value { | "dump create" | "live capabilities" | "live launch" + | "live startup-break" | "breakpoint capabilities" | "datamodel capabilities" ); @@ -5058,6 +5095,15 @@ fn command_metadata() -> Value { "safety": "live_debugging_changes_target_execution_state", "bounds": ["--initial-break-timeout-ms", "--end detach|terminate"] }, + { + "command": "live startup-break", + "requires_daemon": false, + "requires_native_ttd": false, + "session_required": false, + "cost": "launches_process_and_waits_for_bounded_debug_event", + "safety": "live_debugging_changes_target_execution_state", + "bounds": ["--initial-break-timeout-ms", "--wait-timeout-ms", "--max-frames", "--end detach|terminate"] + }, { "command": "live start", "requires_daemon": true, diff --git a/crates/windbg-tool/src/cli/dispatch.rs b/crates/windbg-tool/src/cli/dispatch.rs index 6b0b8de..c0c5411 100644 --- a/crates/windbg-tool/src/cli/dispatch.rs +++ b/crates/windbg-tool/src/cli/dispatch.rs @@ -45,6 +45,7 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { }, Some(Commands::Live { command }) => match command { LiveCommand::Launch(args) => platform::run_live_launch(args, &output), + LiveCommand::StartupBreak(args) => platform::run_live_startup_break(args, &output), LiveCommand::Start(args) => live_start_and_print(pipe, args, &output).await, LiveCommand::Attach(args) => live_attach_and_print(pipe, args, &output).await, LiveCommand::Capabilities => print_value(platform::live_capabilities(), &output), diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 7cb2c4e..658f3b5 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -10,8 +10,9 @@ use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; use windbg_dbgeng::{ - live_launch_initial_break, open_dump_session, start_process_server, write_process_dump, - DumpKind, DumpOpenOptions, DumpWriteOptions, LiveLaunchEnd, LiveLaunchOptions, + launch_live_session, live_launch_initial_break, open_dump_session, start_process_server, + write_process_dump, BreakpointInfo, DebuggerSession, DumpKind, DumpOpenOptions, + DumpWriteOptions, LiveLaunchEnd, LiveLaunchOptions, LiveLaunchSessionOptions, ModuleInfo, ProcessDumpOptions, ProcessServerOptions, }; use windbg_install::WindbgManager; @@ -28,7 +29,8 @@ use windows::Win32::System::Threading::{ use super::output::{print_value, OutputOptions}; use super::{ CliDumpKind, DbgEngServerArgs, DumpCreateArgs, DumpInspectArgs, LiveLaunchArgs, - TraceRecordArgs, TraceRecordProfile, TraceReplayCpuSupport, WindbgCommand, + LiveStartupBreakArgs, TraceRecordArgs, TraceRecordProfile, TraceReplayCpuSupport, + WindbgCommand, }; pub(super) fn run_dbgeng_server( @@ -65,6 +67,198 @@ pub(super) fn run_live_launch(args: LiveLaunchArgs, output: &OutputOptions) -> a ) } +pub(super) fn run_live_startup_break( + args: LiveStartupBreakArgs, + output: &OutputOptions, +) -> anyhow::Result<()> { + let end = parse_live_launch_end(&args.end)?; + let breakpoint_spec = startup_breakpoint_spec(&args)?; + let session = launch_live_session(LiveLaunchSessionOptions { + command_line: args.command_line.clone(), + initial_break_timeout_ms: args.initial_break_timeout_ms, + })?; + + let result = (|| { + let initial_event = session.summary(); + let requested_breakpoint = set_startup_breakpoint(&session, &breakpoint_spec)?; + let continued = session.continue_execution()?; + let event = session.wait_for_event(args.wait_timeout_ms)?; + let registers = session.core_registers()?; + let instruction_offset = registers.instruction_offset; + let current_module = instruction_offset + .map(|address| session.module_by_offset(address)) + .transpose()? + .flatten(); + let current_symbol = instruction_offset + .map(|address| session.symbol_by_offset(address)) + .transpose()? + .flatten(); + let configured_breakpoint = session + .list_breakpoints()? + .into_iter() + .find(|breakpoint| breakpoint.id == requested_breakpoint.id); + let breakpoint_hit = instruction_offset + .zip( + configured_breakpoint + .as_ref() + .map(|breakpoint| breakpoint.offset), + ) + .is_some_and(|(instruction_offset, breakpoint_offset)| { + event.name.as_deref() == Some("break") && instruction_offset == breakpoint_offset + }); + + Ok(json!({ + "workflow": "live_startup_break", + "command_line": args.command_line, + "breakpoint_spec": breakpoint_spec, + "initial_event": initial_event, + "continued": continued, + "event": event, + "breakpoint": { + "requested": requested_breakpoint, + "configured": configured_breakpoint, + "hit": breakpoint_hit, + "hit_evidence": if breakpoint_hit { + "current instruction pointer equals the configured breakpoint offset" + } else { + "DbgEng stopped, but its current instruction pointer did not match the configured breakpoint offset" + } + }, + "context": { + "target": session.summary(), + "registers": registers, + "instruction_pointer": instruction_offset, + "current_module": current_module, + "current_symbol": current_symbol, + "stack": session.stack_trace(args.max_frames)?, + "stack_frame_limit": args.max_frames + }, + "end": end + })) + })(); + + let cleanup = match end { + LiveLaunchEnd::Detach => session.detach(), + LiveLaunchEnd::Terminate => session.terminate(), + }; + match (result, cleanup) { + (Ok(result), Ok(())) => print_value(result, output), + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error).context("failed to end the live debug session"), + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum StartupBreakpointSpec { + Address { address: u64 }, + ModuleOffset { module: String, offset: u64 }, + Symbol { expression: String }, +} + +fn startup_breakpoint_spec(args: &LiveStartupBreakArgs) -> anyhow::Result { + let selections = usize::from(args.address.is_some()) + + usize::from(args.module_offset.is_some()) + + usize::from(args.symbol.is_some()); + ensure!( + selections == 1, + "specify exactly one of --address, --module with --module-offset, or --symbol" + ); + if let Some(address) = args.address.as_deref() { + return Ok(StartupBreakpointSpec::Address { + address: parse_debug_address(address)?, + }); + } + if let Some(offset) = args.module_offset.as_deref() { + return Ok(StartupBreakpointSpec::ModuleOffset { + module: args + .module + .clone() + .context("--module-offset requires --module")?, + offset: parse_debug_address(offset)?, + }); + } + let expression = args + .symbol + .as_deref() + .context("a startup breakpoint specification is required")? + .trim(); + ensure!(!expression.is_empty(), "--symbol must not be empty"); + Ok(StartupBreakpointSpec::Symbol { + expression: expression.to_string(), + }) +} + +fn set_startup_breakpoint( + session: &DebuggerSession, + spec: &StartupBreakpointSpec, +) -> anyhow::Result { + match spec { + StartupBreakpointSpec::Address { address } => session.add_code_breakpoint(*address), + StartupBreakpointSpec::ModuleOffset { module, offset } => { + let modules = session.modules()?; + let module = find_loaded_module(&modules, module)?; + let address = module + .base_address + .checked_add(*offset) + .context("module base plus breakpoint offset overflowed")?; + session.add_code_breakpoint(address) + } + StartupBreakpointSpec::Symbol { expression } => { + session.add_code_breakpoint_expression(expression) + } + } +} + +fn find_loaded_module<'a>( + modules: &'a [ModuleInfo], + requested_module: &str, +) -> anyhow::Result<&'a ModuleInfo> { + modules + .iter() + .find(|module| { + [ + module.module_name.as_deref(), + module.image_name.as_deref(), + module.loaded_image_name.as_deref(), + ] + .into_iter() + .flatten() + .any(|candidate| module_name_matches(candidate, requested_module)) + }) + .with_context(|| format!("module '{requested_module}' is not loaded at the initial break")) +} + +fn module_name_matches(candidate: &str, requested: &str) -> bool { + candidate.eq_ignore_ascii_case(requested) + || Path::new(candidate) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case(requested)) +} + +fn parse_debug_address(value: &str) -> anyhow::Result { + let value = value.trim(); + let parsed = match value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + Some(hex) => u64::from_str_radix(hex, 16), + None => value.parse(), + }; + parsed.with_context(|| { + format!("invalid address '{value}'; use decimal or 0x-prefixed hexadecimal") + }) +} + +fn parse_live_launch_end(value: &str) -> anyhow::Result { + match value { + "detach" => Ok(LiveLaunchEnd::Detach), + "terminate" => Ok(LiveLaunchEnd::Terminate), + other => bail!("unsupported live launch end action: {other}"), + } +} + pub(super) fn run_trace_record( args: TraceRecordArgs, output: &OutputOptions, @@ -819,6 +1013,7 @@ pub(super) fn live_capabilities() -> Value { "implemented": [ "dbgeng server", "live launch --command-line --end detach|terminate", + "live startup-break --command-line --address |--module --module-offset |--symbol ", "live start --command-line ", "live attach --process-id ", "dump create --process-id --output ", @@ -833,6 +1028,11 @@ pub(super) fn live_capabilities() -> Value { "status": "one_shot_initial_event", "notes": "Launches under DbgEng, waits for the initial event, reports execution status, then detaches or terminates." }, + { + "feature": "startup breakpoint workflow", + "status": "one_shot_structured_context", + "notes": "Launches at the initial break, sets an absolute, module-RVA, or deferred symbol-expression code breakpoint, then reports bounded hit evidence, thread/IP/module/register/stack context." + }, { "feature": "dump creation", "status": "dbghelp_minidump_writer", @@ -845,7 +1045,6 @@ pub(super) fn live_capabilities() -> Value { } ], "gaps": [ - "structured debug event polling", "step-over/step-out controls", "module/symbol reload management", "exception filtering and event callbacks", @@ -888,7 +1087,7 @@ pub(super) fn breakpoint_capabilities() -> Value { } ], "gaps": [ - "source and symbol breakpoints", + "source breakpoints and persistent daemon symbol breakpoints", "position watchpoints", "call/return trace jobs", "breakpoint enable/disable without remove" @@ -1268,4 +1467,70 @@ mod tests { ttd_not_found_message().contains("winget install --id Microsoft.TimeTravelDebugging") ); } + + #[test] + fn matches_loaded_module_by_basename_or_full_path() { + let modules = [ModuleInfo { + base_address: 0x140000000, + module_name: Some("RemoteDesktopManager_x64".to_string()), + image_name: Some( + r"C:\Temp\windbg-tool-ttd\rdm\RemoteDesktopManager_x64.exe".to_string(), + ), + loaded_image_name: None, + symbol_file: None, + }]; + + assert_eq!( + find_loaded_module(&modules, "remotedesktopmanager_x64.exe") + .unwrap() + .base_address, + 0x140000000 + ); + assert_eq!( + find_loaded_module( + &modules, + r"C:\Temp\windbg-tool-ttd\rdm\RemoteDesktopManager_x64.exe" + ) + .unwrap() + .base_address, + 0x140000000 + ); + } + + #[test] + fn parses_decimal_and_hexadecimal_breakpoint_addresses() { + assert_eq!(parse_debug_address("0x140001000").unwrap(), 0x140001000); + assert_eq!(parse_debug_address("4096").unwrap(), 4096); + assert!(parse_debug_address("not-an-address").is_err()); + } + + #[test] + fn startup_breakpoint_requires_exactly_one_location_kind() { + let args = LiveStartupBreakArgs { + command_line: "target.exe".to_string(), + address: Some("0x140001000".to_string()), + module: Some("target.exe".to_string()), + module_offset: Some("0x1000".to_string()), + symbol: None, + initial_break_timeout_ms: 5000, + wait_timeout_ms: 10000, + max_frames: 16, + end: "terminate".to_string(), + }; + assert!(startup_breakpoint_spec(&args).is_err()); + + let symbol_args = LiveStartupBreakArgs { + address: None, + module: None, + module_offset: None, + symbol: Some("target!entry".to_string()), + ..args + }; + assert_eq!( + startup_breakpoint_spec(&symbol_args).unwrap(), + StartupBreakpointSpec::Symbol { + expression: "target!entry".to_string() + } + ); + } } diff --git a/docs/cli.md b/docs/cli.md index 954c1e5..9327a81 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -88,6 +88,25 @@ To enable synchronous sudo recording, choose **Input Closed** or **Inline** in * sudo config --enable disableInput ``` +## Live startup breakpoints + +`live startup-break` is a one-shot native DbgEng workflow: it launches at the initial debug break, configures one code breakpoint, continues the target once, waits for a bounded event, captures JSON context, and then ends the debug session. It does not shell out to CDB or WinDbg. + +Use a module basename and RVA when the executable is present at the initial break: + +```powershell +target\debug\windbg-tool.exe --compact live startup-break ` + --command-line '"C:\Temp\windbg-tool-ttd\rdm\RemoteDesktopManager_x64.exe" /AutoCloseAfter:10' ` + --module RemoteDesktopManager_x64.exe ` + --module-offset 0x1000 ` + --wait-timeout-ms 10000 ` + --end terminate +``` + +Alternatively specify an absolute `--address`, or a deferred DbgEng `--symbol 'module!symbol'` expression. Deferred symbol breakpoints allow module and symbol loading after process creation. The response includes the initial event, configured breakpoint, an explicit `breakpoint.hit` evidence flag, PID/thread/IP, current module and symbol, core registers, and a bounded stack. A stopped event that does not match the configured breakpoint remains reported as `hit: false`; it is not misrepresented as a breakpoint hit. + +`--end terminate` is the default for disposable startup probes. Use `--end detach` only when the debuggee should continue after the captured event. + ## Canonical agent debugging commands Use `debug capabilities` before choosing actions. With no subject it returns a backend matrix for TTD cursors, daemon-owned live/dump targets, and remote process-server plans. With `--session/--cursor` or `--target`, it includes selected-subject status/evidence where available. @@ -213,7 +232,7 @@ The schema includes curated metadata where available and inferred metadata for o | Inspect runtime state | `registers`, `register-context`, `active-threads`, `command-line`, `architecture state` | | Inspect code and memory | `disasm`, `memory read`, `memory dump`, `memory strings`, `memory dps`, `memory classify`, `memory chase`, `object vtable` | | Symbol and source triage | `symbols doctor`, `symbols diagnose`, `symbols inspect`, `symbols exports`, `symbols nearest`, `source resolve` | -| WinDbg, live, dump, and remote helpers | `remote explain`, `remote doctor`, `remote status`, `remote plan`, `remote server-command`, `remote connect-command`, `dbgeng server`, `live capabilities`, `dump create`, `dump open`, `dump inspect`, `target dump`, `windbg status` | +| WinDbg, live, dump, and remote helpers | `remote explain`, `remote doctor`, `remote status`, `remote plan`, `remote server-command`, `remote connect-command`, `dbgeng server`, `live capabilities`, `live startup-break`, `dump create`, `dump open`, `dump inspect`, `target dump`, `windbg status` | ## Useful non-replay commands From aea94733644119c645b1071bea04e5a9c2822cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 9 Jul 2026 22:18:29 -0400 Subject: [PATCH 13/14] Expose degraded live debugger context Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-dbgeng/src/lib.rs | 75 +++++++++++++++++--------- crates/windbg-tool/src/cli.rs | 6 +++ crates/windbg-tool/src/cli/platform.rs | 71 +++++++++++++++++++----- docs/cli.md | 2 + 4 files changed, 118 insertions(+), 36 deletions(-) diff --git a/crates/windbg-dbgeng/src/lib.rs b/crates/windbg-dbgeng/src/lib.rs index 4075cd0..20dd8e7 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -657,16 +657,17 @@ impl DebuggerSession { pub fn add_code_breakpoint(&self, address: u64) -> anyhow::Result { use windows::Win32::System::Diagnostics::Debug::Extensions::{ - DEBUG_ANY_ID, DEBUG_BREAKPOINT_CODE, DEBUG_BREAKPOINT_ENABLED, + DEBUG_BREAKPOINT_CODE, DEBUG_BREAKPOINT_ENABLED, }; - let breakpoint = unsafe { - self.control - .AddBreakpoint2(DEBUG_BREAKPOINT_CODE, DEBUG_ANY_ID)? - }; + let breakpoint = self.add_compatible_breakpoint(DEBUG_BREAKPOINT_CODE)?; unsafe { - breakpoint.SetOffset(address)?; - breakpoint.AddFlags(DEBUG_BREAKPOINT_ENABLED)?; + breakpoint.SetOffset(address).with_context(|| { + format!("DbgEng could not set code breakpoint offset 0x{address:X}") + })?; + breakpoint + .AddFlags(DEBUG_BREAKPOINT_ENABLED) + .context("DbgEng could not enable code breakpoint")?; } self.breakpoint_info(&breakpoint) } @@ -677,18 +678,21 @@ impl DebuggerSession { ) -> anyhow::Result { use windows::core::PCWSTR; use windows::Win32::System::Diagnostics::Debug::Extensions::{ - DEBUG_ANY_ID, DEBUG_BREAKPOINT_CODE, DEBUG_BREAKPOINT_ENABLED, + DEBUG_BREAKPOINT_CODE, DEBUG_BREAKPOINT_ENABLED, }; let mut expression_wide = expression.encode_utf16().collect::>(); expression_wide.push(0); - let breakpoint = unsafe { - self.control - .AddBreakpoint2(DEBUG_BREAKPOINT_CODE, DEBUG_ANY_ID)? - }; + let breakpoint = self.add_compatible_breakpoint(DEBUG_BREAKPOINT_CODE)?; unsafe { - breakpoint.SetOffsetExpressionWide(PCWSTR(expression_wide.as_ptr()))?; - breakpoint.AddFlags(DEBUG_BREAKPOINT_ENABLED)?; + breakpoint + .SetOffsetExpressionWide(PCWSTR(expression_wide.as_ptr())) + .with_context(|| { + format!("DbgEng could not set code breakpoint expression '{expression}'") + })?; + breakpoint + .AddFlags(DEBUG_BREAKPOINT_ENABLED) + .context("DbgEng could not enable code breakpoint")?; } self.breakpoint_info(&breakpoint) } @@ -700,13 +704,10 @@ impl DebuggerSession { access_type: u32, ) -> anyhow::Result { use windows::Win32::System::Diagnostics::Debug::Extensions::{ - DEBUG_ANY_ID, DEBUG_BREAKPOINT_DATA, DEBUG_BREAKPOINT_ENABLED, + DEBUG_BREAKPOINT_DATA, DEBUG_BREAKPOINT_ENABLED, }; - let breakpoint = unsafe { - self.control - .AddBreakpoint2(DEBUG_BREAKPOINT_DATA, DEBUG_ANY_ID)? - }; + let breakpoint = self.add_compatible_breakpoint(DEBUG_BREAKPOINT_DATA)?; unsafe { breakpoint.SetOffset(address)?; breakpoint.SetDataParameters(size, access_type)?; @@ -789,6 +790,29 @@ impl DebuggerSession { .ok() } + fn add_compatible_breakpoint( + &self, + break_type: u32, + ) -> anyhow::Result + { + use windows::core::Interface; + use windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_ANY_ID; + + match unsafe { self.control.AddBreakpoint2(break_type, DEBUG_ANY_ID) } { + Ok(breakpoint) => Ok(breakpoint), + Err(modern_error) => unsafe { + self.control + .AddBreakpoint(break_type, DEBUG_ANY_ID) + .and_then(|breakpoint| breakpoint.cast()) + } + .with_context(|| { + format!( + "DbgEng could not add breakpoint type {break_type}; AddBreakpoint2 failed first: {modern_error}" + ) + }), + } + } + fn module_info(&self, index: u32, base_address: u64) -> ModuleInfo { ModuleInfo { base_address, @@ -1090,11 +1114,14 @@ fn launch_live_session_impl(options: LiveLaunchSessionOptions) -> anyhow::Result let system_objects: IDebugSystemObjects = client.cast()?; let symbol_path = configure_dbgeng_symbol_path(&symbols)?; unsafe { - client.CreateProcessWide( - 0, - PCWSTR(command_line.as_ptr()), - DEBUG_PROCESS_ONLY_THIS_PROCESS, - )?; + // DbgEng can mutate the command buffer; command_line is owned and remains live for the call. + client + .CreateProcessWide( + 0, + PCWSTR(command_line.as_ptr()), + DEBUG_PROCESS_ONLY_THIS_PROCESS, + ) + .context("DbgEng CreateProcessWide failed")?; control.WaitForEvent( windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_WAIT_DEFAULT, options.initial_break_timeout_ms, diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 386d100..90ee296 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -662,6 +662,12 @@ struct LiveLaunchArgs { struct LiveStartupBreakArgs { #[arg(long, help = "Full command line to launch under DbgEng")] command_line: String, + #[arg( + long, + conflicts_with_all = ["address", "module", "module_offset", "symbol"], + help = "Capture the initial DbgEng break without setting a code breakpoint" + )] + initial_break: bool, #[arg(long, help = "Absolute code address for the breakpoint")] address: Option, #[arg( diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 658f3b5..3203d40 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -80,9 +80,19 @@ pub(super) fn run_live_startup_break( let result = (|| { let initial_event = session.summary(); - let requested_breakpoint = set_startup_breakpoint(&session, &breakpoint_spec)?; - let continued = session.continue_execution()?; - let event = session.wait_for_event(args.wait_timeout_ms)?; + let requested_breakpoint = match breakpoint_spec { + StartupBreakpointSpec::InitialBreak => None, + _ => Some(set_startup_breakpoint(&session, &breakpoint_spec)?), + }; + let continued = requested_breakpoint + .is_some() + .then(|| session.continue_execution()) + .transpose()?; + let event = requested_breakpoint + .is_some() + .then(|| session.wait_for_event(args.wait_timeout_ms)) + .transpose()? + .unwrap_or_else(|| session.execution_status()); let registers = session.core_registers()?; let instruction_offset = registers.instruction_offset; let current_module = instruction_offset @@ -93,10 +103,26 @@ pub(super) fn run_live_startup_break( .map(|address| session.symbol_by_offset(address)) .transpose()? .flatten(); - let configured_breakpoint = session - .list_breakpoints()? - .into_iter() - .find(|breakpoint| breakpoint.id == requested_breakpoint.id); + let stack = match session.stack_trace(args.max_frames) { + Ok(frames) => json!({ + "status": "ok", + "frames": frames, + "frame_limit": args.max_frames + }), + Err(error) => json!({ + "status": "error", + "error": error.to_string(), + "frames": [], + "frame_limit": args.max_frames + }), + }; + let configured_breakpoint = match requested_breakpoint.as_ref() { + Some(requested) => session + .list_breakpoints()? + .into_iter() + .find(|breakpoint| breakpoint.id == requested.id), + None => None, + }; let breakpoint_hit = instruction_offset .zip( configured_breakpoint @@ -120,6 +146,8 @@ pub(super) fn run_live_startup_break( "hit": breakpoint_hit, "hit_evidence": if breakpoint_hit { "current instruction pointer equals the configured breakpoint offset" + } else if matches!(breakpoint_spec, StartupBreakpointSpec::InitialBreak) { + "the initial DbgEng process break was intentionally captured without setting a code breakpoint" } else { "DbgEng stopped, but its current instruction pointer did not match the configured breakpoint offset" } @@ -130,8 +158,7 @@ pub(super) fn run_live_startup_break( "instruction_pointer": instruction_offset, "current_module": current_module, "current_symbol": current_symbol, - "stack": session.stack_trace(args.max_frames)?, - "stack_frame_limit": args.max_frames + "stack": stack }, "end": end })) @@ -151,19 +178,24 @@ pub(super) fn run_live_startup_break( #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] enum StartupBreakpointSpec { + InitialBreak, Address { address: u64 }, ModuleOffset { module: String, offset: u64 }, Symbol { expression: String }, } fn startup_breakpoint_spec(args: &LiveStartupBreakArgs) -> anyhow::Result { - let selections = usize::from(args.address.is_some()) + let selections = usize::from(args.initial_break) + + usize::from(args.address.is_some()) + usize::from(args.module_offset.is_some()) + usize::from(args.symbol.is_some()); ensure!( selections == 1, - "specify exactly one of --address, --module with --module-offset, or --symbol" + "specify exactly one of --initial-break, --address, --module with --module-offset, or --symbol" ); + if args.initial_break { + return Ok(StartupBreakpointSpec::InitialBreak); + } if let Some(address) = args.address.as_deref() { return Ok(StartupBreakpointSpec::Address { address: parse_debug_address(address)?, @@ -194,6 +226,9 @@ fn set_startup_breakpoint( spec: &StartupBreakpointSpec, ) -> anyhow::Result { match spec { + StartupBreakpointSpec::InitialBreak => { + bail!("initial-break capture does not create a code breakpoint") + } StartupBreakpointSpec::Address { address } => session.add_code_breakpoint(*address), StartupBreakpointSpec::ModuleOffset { module, offset } => { let modules = session.modules()?; @@ -1013,7 +1048,7 @@ pub(super) fn live_capabilities() -> Value { "implemented": [ "dbgeng server", "live launch --command-line --end detach|terminate", - "live startup-break --command-line --address |--module --module-offset |--symbol ", + "live startup-break --command-line --initial-break|--address |--module --module-offset |--symbol ", "live start --command-line ", "live attach --process-id ", "dump create --process-id --output ", @@ -1508,6 +1543,7 @@ mod tests { fn startup_breakpoint_requires_exactly_one_location_kind() { let args = LiveStartupBreakArgs { command_line: "target.exe".to_string(), + initial_break: false, address: Some("0x140001000".to_string()), module: Some("target.exe".to_string()), module_offset: Some("0x1000".to_string()), @@ -1520,6 +1556,7 @@ mod tests { assert!(startup_breakpoint_spec(&args).is_err()); let symbol_args = LiveStartupBreakArgs { + initial_break: false, address: None, module: None, module_offset: None, @@ -1532,5 +1569,15 @@ mod tests { expression: "target!entry".to_string() } ); + + let initial_break_args = LiveStartupBreakArgs { + initial_break: true, + symbol: None, + ..symbol_args + }; + assert_eq!( + startup_breakpoint_spec(&initial_break_args).unwrap(), + StartupBreakpointSpec::InitialBreak + ); } } diff --git a/docs/cli.md b/docs/cli.md index 9327a81..e1a7350 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -105,6 +105,8 @@ target\debug\windbg-tool.exe --compact live startup-break ` Alternatively specify an absolute `--address`, or a deferred DbgEng `--symbol 'module!symbol'` expression. Deferred symbol breakpoints allow module and symbol loading after process creation. The response includes the initial event, configured breakpoint, an explicit `breakpoint.hit` evidence flag, PID/thread/IP, current module and symbol, core registers, and a bounded stack. A stopped event that does not match the configured breakpoint remains reported as `hit: false`; it is not misrepresented as a breakpoint hit. +Use `--initial-break` when no reliable code breakpoint is available or the host policy rejects breakpoint creation. It captures the initial DbgEng process break without continuing the target and labels that evidence explicitly; it does not claim a code-breakpoint hit. + `--end terminate` is the default for disposable startup probes. Use `--end detach` only when the debuggee should continue after the captured event. ## Canonical agent debugging commands From 7e4ee8abe39dc6088e02eb42388a18cbd8243cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Jul 2026 07:25:22 -0400 Subject: [PATCH 14/14] Expand AI debugger target inspection Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-dbgeng/src/lib.rs | 254 ++++++++++++++++++++++++- crates/windbg-tool/src/cli.rs | 62 +++++- crates/windbg-tool/src/cli/dispatch.rs | 6 + crates/windbg-tool/tests/mcp_stdio.rs | 2 + crates/windbg-ttd/src/targets.rs | 56 +++++- crates/windbg-ttd/src/tools.rs | 20 +- docs/cli.md | 20 +- 7 files changed, 411 insertions(+), 9 deletions(-) diff --git a/crates/windbg-dbgeng/src/lib.rs b/crates/windbg-dbgeng/src/lib.rs index 20dd8e7..339f7f3 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context}; +use anyhow::{bail, ensure, Context}; use serde::Serialize; use std::{ env, @@ -297,6 +297,40 @@ pub struct EvaluationResult { pub float64_value: Option, } +#[derive(Debug, Clone, Serialize)] +pub struct DebuggerExceptionInfo { + pub code: u32, + pub flags: u32, + pub address: u64, + pub first_chance: bool, + pub parameters: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DebuggerEventInfo { + pub event_type: u32, + pub event_name: String, + pub process_system_id: u32, + pub thread_system_id: u32, + pub description: Option, + pub extra_information_size: u32, + pub breakpoint_id: Option, + pub exception: Option, + pub module_base: Option, + pub exit_code: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ThreadContext { + pub thread: ThreadInfo, + pub registers: CoreRegisterState, + pub current_module: Option, + pub current_symbol: Option, + pub stack: Vec, + pub disassembly: Option, + pub current_thread_preserved: bool, +} + pub fn start_process_server(options: ProcessServerOptions) -> anyhow::Result { start_process_server_impl(options) } @@ -401,6 +435,111 @@ impl DebuggerSession { Ok(self.execution_status()) } + pub fn last_event(&self) -> anyhow::Result { + use windows::Win32::System::Diagnostics::Debug::Extensions::{ + DEBUG_EVENT_BREAKPOINT, DEBUG_EVENT_EXCEPTION, DEBUG_EVENT_EXIT_PROCESS, + DEBUG_EVENT_EXIT_THREAD, DEBUG_EVENT_LOAD_MODULE, DEBUG_EVENT_UNLOAD_MODULE, + DEBUG_LAST_EVENT_INFO_BREAKPOINT, DEBUG_LAST_EVENT_INFO_EXCEPTION, + DEBUG_LAST_EVENT_INFO_EXIT_PROCESS, DEBUG_LAST_EVENT_INFO_EXIT_THREAD, + DEBUG_LAST_EVENT_INFO_LOAD_MODULE, DEBUG_LAST_EVENT_INFO_UNLOAD_MODULE, + }; + + const MAX_EVENT_DESCRIPTION_CHARS: usize = 512; + const MAX_EVENT_EXTRA_BYTES: usize = 512; + + let mut event_type = 0u32; + let mut process_system_id = 0u32; + let mut thread_system_id = 0u32; + let mut extra_information = vec![0u8; MAX_EVENT_EXTRA_BYTES]; + let mut extra_information_used = 0u32; + let mut description = vec![0u16; MAX_EVENT_DESCRIPTION_CHARS]; + let mut description_used = 0u32; + unsafe { + self.control.GetLastEventInformationWide( + &mut event_type, + &mut process_system_id, + &mut thread_system_id, + Some(extra_information.as_mut_ptr().cast()), + extra_information.len() as u32, + Some(&mut extra_information_used), + Some(&mut description), + Some(&mut description_used), + )?; + } + ensure!( + extra_information_used as usize <= extra_information.len(), + "DbgEng last-event payload exceeds the bounded {MAX_EVENT_EXTRA_BYTES}-byte limit" + ); + ensure!( + description_used as usize <= description.len(), + "DbgEng last-event description exceeds the bounded {MAX_EVENT_DESCRIPTION_CHARS}-character limit" + ); + + let description_len = description_used as usize; + let description = &description[..description_len]; + let description_len = if description.last().is_some_and(|character| *character == 0) { + description_len - 1 + } else { + description_len + }; + let description = String::from_utf16_lossy(&description[..description_len]); + let description = (!description.is_empty()).then_some(description); + let extra_information = &extra_information[..extra_information_used as usize]; + let breakpoint_id = (event_type == DEBUG_EVENT_BREAKPOINT) + .then(|| read_event_info::(extra_information)) + .flatten() + .map(|info| info.Id); + let exception = (event_type == DEBUG_EVENT_EXCEPTION) + .then(|| read_event_info::(extra_information)) + .flatten() + .map(|info| { + let count = (info.ExceptionRecord.NumberParameters as usize) + .min(info.ExceptionRecord.ExceptionInformation.len()); + DebuggerExceptionInfo { + code: info.ExceptionRecord.ExceptionCode.0 as u32, + flags: info.ExceptionRecord.ExceptionFlags, + address: info.ExceptionRecord.ExceptionAddress, + first_chance: info.FirstChance != 0, + parameters: info.ExceptionRecord.ExceptionInformation[..count].to_vec(), + } + }); + let module_base = match event_type { + DEBUG_EVENT_LOAD_MODULE => { + read_event_info::(extra_information) + .map(|info| info.Base) + } + DEBUG_EVENT_UNLOAD_MODULE => { + read_event_info::(extra_information) + .map(|info| info.Base) + } + _ => None, + }; + let exit_code = match event_type { + DEBUG_EVENT_EXIT_PROCESS => { + read_event_info::(extra_information) + .map(|info| info.ExitCode) + } + DEBUG_EVENT_EXIT_THREAD => { + read_event_info::(extra_information) + .map(|info| info.ExitCode) + } + _ => None, + }; + + Ok(DebuggerEventInfo { + event_type, + event_name: event_type_name(event_type).to_string(), + process_system_id, + thread_system_id, + description, + extra_information_size: extra_information_used, + breakpoint_id, + exception, + module_base, + exit_code, + }) + } + pub fn detach(&self) -> anyhow::Result<()> { unsafe { self.client.DetachProcesses()?; @@ -608,6 +747,72 @@ impl DebuggerSession { .collect()) } + pub fn thread_context( + &self, + engine_thread_id: u32, + max_frames: u32, + disassembly_count: u32, + ) -> anyhow::Result { + ensure!(max_frames > 0, "max_frames must be greater than zero"); + ensure!( + disassembly_count > 0, + "disassembly_count must be greater than zero" + ); + ensure!( + engine_thread_id != u32::MAX, + "engine_thread_id cannot be DEBUG_ANY_ID" + ); + + let previous_thread_id = unsafe { self.system_objects.GetCurrentThreadId()? }; + let changed_thread = previous_thread_id != engine_thread_id; + if changed_thread { + unsafe { + self.system_objects.SetCurrentThreadId(engine_thread_id)?; + } + } + + let context = (|| { + let registers = self.core_registers()?; + let instruction_offset = registers.instruction_offset; + let current_module = instruction_offset + .map(|address| self.module_by_offset(address)) + .transpose()? + .flatten(); + let current_symbol = instruction_offset + .map(|address| self.symbol_by_offset(address)) + .transpose()? + .flatten(); + let stack = self.stack_trace(max_frames)?; + let disassembly = instruction_offset + .map(|address| self.disassemble(Some(address), disassembly_count)) + .transpose()?; + let system_id = self.current_thread_system_id()?; + Ok(ThreadContext { + thread: ThreadInfo { + engine_id: engine_thread_id, + system_id, + }, + registers, + current_module, + current_symbol, + stack, + disassembly, + current_thread_preserved: true, + }) + })(); + + let restore = changed_thread + .then(|| unsafe { self.system_objects.SetCurrentThreadId(previous_thread_id) }); + match (context, restore) { + (Ok(context), Some(Ok(()))) | (Ok(context), None) => Ok(context), + (Ok(_), Some(Err(error))) => Err(error).context("failed to restore the current thread"), + (Err(error), Some(Err(restore_error))) => Err(error).context(format!( + "failed to inspect the requested thread and failed to restore the current thread: {restore_error}" + )), + (Err(error), _) => Err(error), + } + } + pub fn disassemble( &self, address: Option, @@ -889,6 +1094,32 @@ impl DebuggerSession { } } +#[cfg(windows)] +fn read_event_info(bytes: &[u8]) -> Option { + (bytes.len() >= std::mem::size_of::()) + .then(|| unsafe { bytes.as_ptr().cast::().read_unaligned() }) +} + +fn event_type_name(event_type: u32) -> &'static str { + match event_type { + 1 => "breakpoint", + 2 => "exception", + 4 => "create_thread", + 8 => "exit_thread", + 16 => "create_process", + 32 => "exit_process", + 64 => "load_module", + 128 => "unload_module", + 256 => "system_error", + 512 => "session_status", + 1024 => "change_debuggee_state", + 2048 => "change_engine_state", + 4096 => "change_symbol_state", + 8192 => "service_exception", + _ => "unknown", + } +} + #[cfg(not(windows))] impl DebuggerSession { pub fn summary(&self) -> DebuggerSessionSummary { @@ -930,6 +1161,10 @@ impl DebuggerSession { anyhow::bail!("DbgEng sessions are only supported on Windows") } + pub fn last_event(&self) -> anyhow::Result { + anyhow::bail!("DbgEng sessions are only supported on Windows") + } + pub fn detach(&self) -> anyhow::Result<()> { anyhow::bail!("DbgEng sessions are only supported on Windows") } @@ -974,6 +1209,15 @@ impl DebuggerSession { anyhow::bail!("DbgEng sessions are only supported on Windows") } + pub fn thread_context( + &self, + _engine_thread_id: u32, + _max_frames: u32, + _disassembly_count: u32, + ) -> anyhow::Result { + anyhow::bail!("DbgEng sessions are only supported on Windows") + } + pub fn disassemble( &self, _address: Option, @@ -1514,4 +1758,12 @@ mod tests { 1 ); } + + #[test] + fn names_documented_dbgeng_event_types() { + assert_eq!(event_type_name(1), "breakpoint"); + assert_eq!(event_type_name(2), "exception"); + assert_eq!(event_type_name(64), "load_module"); + assert_eq!(event_type_name(0xFFFF), "unknown"); + } } diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 90ee296..df5f47a 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -468,10 +468,18 @@ enum TargetCommand { Modules(TargetIdArgs), #[command(about = "Read current thread and instruction offsets for a daemon-owned target")] Registers(TargetIdArgs), + #[command( + about = "Read the last DbgEng event with bounded exception, breakpoint, module, or exit evidence" + )] + Event(TargetIdArgs), #[command(about = "Read memory from a daemon-owned target")] Memory(TargetMemoryReadArgs), #[command(about = "Walk the current stack for a daemon-owned target")] Stack(TargetStackTraceArgs), + #[command( + about = "Inspect one engine thread without leaving it selected after the bounded query" + )] + Thread(TargetThreadContextArgs), #[command(about = "Disassemble instructions from a daemon-owned target")] Disasm(TargetDisasmArgs), #[command(about = "Resolve the nearest symbol for a daemon-owned target address")] @@ -1149,6 +1157,21 @@ struct TargetStackTraceArgs { max_frames: u32, } +#[derive(Debug, Args)] +struct TargetThreadContextArgs { + #[arg(short = 't', long = "target")] + target: u64, + #[arg( + long, + help = "DbgEng engine thread id returned by `target threads --target `" + )] + engine_thread_id: u32, + #[arg(long, default_value_t = 32)] + max_frames: u32, + #[arg(long, default_value_t = 16)] + disassembly_count: u32, +} + #[derive(Debug, Args)] struct TargetDisasmArgs { #[arg(short = 't', long = "target")] @@ -1818,9 +1841,11 @@ async fn target_capabilities_and_print( "continue", "step_into", "registers", + "last_event", "memory", "modules", "threads", + "thread_context", "stack", "symbol_lookup", "source_lookup", @@ -1843,6 +1868,7 @@ async fn target_capabilities_and_print( "threads", "registers", "stack", + "thread_context", "symbols", "source", "disassembly", @@ -2061,6 +2087,22 @@ async fn debug_target_snapshot_value( ), ); } + if snapshot_section_enabled(&args, "event") { + let started = Instant::now(); + sections.insert( + "event".to_string(), + composite_section( + started, + call_status_value( + client + .call_tool(target_call("target_last_event", target)) + .await, + ), + Some("target event --target "), + None, + ), + ); + } if snapshot_section_enabled(&args, "threads") { let started = Instant::now(); sections.insert( @@ -3604,7 +3646,7 @@ fn backend_capability(kind: &str) -> Value { "supports_jobs": false, "supports_timeline": false, "required_identifiers": ["target_id"], - "safe_commands": ["debug snapshot", "target status", "target stack", "target disasm", "breakpoint plan"], + "safe_commands": ["debug snapshot", "target status", "target event", "target thread", "target stack", "target disasm", "breakpoint plan"], "mutating_commands": ["target continue", "target step", "breakpoint set", "target dump"], "destructive_commands": ["target terminate", "target close"], "unsupported_operations": [ @@ -3627,7 +3669,7 @@ fn backend_capability(kind: &str) -> Value { "supports_jobs": false, "supports_timeline": false, "required_identifiers": ["target_id"], - "safe_commands": ["debug snapshot", "target status", "target stack", "target disasm"], + "safe_commands": ["debug snapshot", "target status", "target thread", "target stack", "target disasm"], "mutating_commands": [], "destructive_commands": ["target close"], "unsupported_operations": [ @@ -4482,6 +4524,8 @@ fn discover_manifest() -> Value { "target threads --target ", "target modules --target ", "target registers --target ", + "target event --target ", + "target thread --target --engine-thread-id ", "target memory --target --address --size ", "target dump --target --output ", "target stack --target ", @@ -4867,12 +4911,14 @@ fn tool_command_map() -> Value { { "tool": "target_continue", "commands": ["target continue"] }, { "tool": "target_step_into", "commands": ["target step"] }, { "tool": "target_core_registers", "commands": ["target registers"] }, + { "tool": "target_last_event", "commands": ["target event", "debug snapshot --include event"] }, { "tool": "target_read_memory", "commands": ["target memory"] }, { "tool": "target_list_threads", "commands": ["target threads"] }, { "tool": "target_list_modules", "commands": ["target modules"] }, { "tool": "target_symbol_by_offset", "commands": ["target symbol"] }, { "tool": "target_source_by_offset", "commands": ["target source"] }, { "tool": "target_stack_trace", "commands": ["target stack"] }, + { "tool": "target_thread_context", "commands": ["target thread"] }, { "tool": "target_disassemble", "commands": ["target disasm"] }, { "tool": "target_list_breakpoints", "commands": ["breakpoint list"] }, { "tool": "target_set_breakpoint", "commands": ["breakpoint set"] }, @@ -5451,6 +5497,18 @@ fn target_stack_call(args: TargetStackTraceArgs) -> ToolCall { } } +fn target_thread_context_call(args: TargetThreadContextArgs) -> ToolCall { + ToolCall { + name: "target_thread_context".to_string(), + arguments: json!({ + "target_id": args.target, + "engine_thread_id": args.engine_thread_id, + "max_frames": args.max_frames, + "disassembly_count": args.disassembly_count, + }), + } +} + fn cli_dump_kind_name(kind: CliDumpKind) -> &'static str { match kind { CliDumpKind::Mini => "mini", diff --git a/crates/windbg-tool/src/cli/dispatch.rs b/crates/windbg-tool/src/cli/dispatch.rs index c0c5411..2913c2e 100644 --- a/crates/windbg-tool/src/cli/dispatch.rs +++ b/crates/windbg-tool/src/cli/dispatch.rs @@ -307,12 +307,18 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { ) .await } + TargetCommand::Event(args) => { + call_and_print(pipe, target_call("target_last_event", args.target), &output).await + } TargetCommand::Memory(args) => { call_and_print(pipe, target_memory_call(args)?, &output).await } TargetCommand::Stack(args) => { call_and_print(pipe, target_stack_call(args), &output).await } + TargetCommand::Thread(args) => { + call_and_print(pipe, target_thread_context_call(args), &output).await + } TargetCommand::Disasm(args) => { call_and_print(pipe, target_disasm_call(args)?, &output).await } diff --git a/crates/windbg-tool/tests/mcp_stdio.rs b/crates/windbg-tool/tests/mcp_stdio.rs index a80d191..c8800b3 100644 --- a/crates/windbg-tool/tests/mcp_stdio.rs +++ b/crates/windbg-tool/tests/mcp_stdio.rs @@ -990,6 +990,7 @@ fn required_args_for_tool(name: &str) -> anyhow::Result<&'static [&'static str]> | "target_continue" | "target_step_into" | "target_core_registers" + | "target_last_event" | "target_list_threads" | "target_list_modules" | "target_list_breakpoints" => Ok(&["target_id"]), @@ -998,6 +999,7 @@ fn required_args_for_tool(name: &str) -> anyhow::Result<&'static [&'static str]> "target_write_dump" => Ok(&["target_id", "path"]), "target_symbol_by_offset" | "target_source_by_offset" => Ok(&["target_id", "address"]), "target_stack_trace" => Ok(&["target_id"]), + "target_thread_context" => Ok(&["target_id", "engine_thread_id"]), "target_disassemble" => Ok(&["target_id"]), "target_set_breakpoint" => Ok(&["target_id", "address"]), "target_remove_breakpoint" => Ok(&["target_id", "breakpoint_id"]), diff --git a/crates/windbg-ttd/src/targets.rs b/crates/windbg-ttd/src/targets.rs index f8b3741..6e05f59 100644 --- a/crates/windbg-ttd/src/targets.rs +++ b/crates/windbg-ttd/src/targets.rs @@ -5,10 +5,11 @@ use std::collections::HashMap; use std::path::PathBuf; use windbg_dbgeng::{ attach_live_session, launch_live_session, open_dump_session, BreakpointInfo, CoreRegisterState, - DebuggerExecutionStatus, DebuggerSession, DebuggerSessionKind, DebuggerSessionSummary, - DisassemblyResult, DumpKind, DumpOpenOptions, DumpWriteOptions, DumpWriteResult, - EvaluationResult, LiveAttachOptions, LiveLaunchSessionOptions, MemoryReadResult, ModuleInfo, - SourceLocation, StackFrameInfo, SymbolInfo, ThreadInfo, + DebuggerEventInfo, DebuggerExecutionStatus, DebuggerSession, DebuggerSessionKind, + DebuggerSessionSummary, DisassemblyResult, DumpKind, DumpOpenOptions, DumpWriteOptions, + DumpWriteResult, EvaluationResult, LiveAttachOptions, LiveLaunchSessionOptions, + MemoryReadResult, ModuleInfo, SourceLocation, StackFrameInfo, SymbolInfo, ThreadContext, + ThreadInfo, }; pub type TargetId = u64; @@ -74,6 +75,17 @@ pub struct TargetStackTraceRequest { pub max_frames: u32, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct TargetThreadContextRequest { + pub target_id: TargetId, + #[schemars(description = "DbgEng engine thread id returned by target_list_threads")] + pub engine_thread_id: u32, + #[serde(default = "default_target_stack_frames")] + pub max_frames: u32, + #[serde(default = "default_target_disasm_count")] + pub disassembly_count: u32, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct TargetDisassembleRequest { pub target_id: TargetId, @@ -168,6 +180,18 @@ pub struct TargetRegisterState { pub registers: CoreRegisterState, } +#[derive(Debug, Clone, Serialize)] +pub struct TargetEventResponse { + pub target_id: TargetId, + pub event: DebuggerEventInfo, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TargetThreadContextResponse { + pub target_id: TargetId, + pub context: ThreadContext, +} + #[derive(Debug, Clone, Serialize)] pub struct TargetMemoryReadResponse { pub target_id: TargetId, @@ -365,6 +389,15 @@ impl TargetRegistry { }) } + pub fn last_event(&self, request: TargetRequest) -> anyhow::Result { + let target = self.target(request.target_id)?; + ensure_live_target(request.target_id, &target.session)?; + Ok(TargetEventResponse { + target_id: request.target_id, + event: target.session.last_event()?, + }) + } + pub fn read_memory( &self, request: TargetMemoryReadRequest, @@ -425,6 +458,21 @@ impl TargetRegistry { }) } + pub fn thread_context( + &self, + request: TargetThreadContextRequest, + ) -> anyhow::Result { + let target = self.target(request.target_id)?; + Ok(TargetThreadContextResponse { + target_id: request.target_id, + context: target.session.thread_context( + request.engine_thread_id, + request.max_frames, + request.disassembly_count, + )?, + }) + } + pub fn disassemble( &self, request: TargetDisassembleRequest, diff --git a/crates/windbg-ttd/src/tools.rs b/crates/windbg-ttd/src/tools.rs index 521db8d..afd6e7d 100644 --- a/crates/windbg-ttd/src/tools.rs +++ b/crates/windbg-ttd/src/tools.rs @@ -4,7 +4,7 @@ use crate::targets::{ DumpOpenRequest, LiveAttachRequest, LiveLaunchRequest, TargetAddressRequest, TargetBreakpointRemoveRequest, TargetBreakpointSetRequest, TargetDisassembleRequest, TargetExpressionRequest, TargetMemoryReadRequest, TargetRequest, TargetStackTraceRequest, - TargetWaitRequest, TargetWriteDumpRequest, + TargetThreadContextRequest, TargetWaitRequest, TargetWriteDumpRequest, }; use crate::ttd_replay::{ AddressInfoRequest, CursorId, IndexBuildRequest, IndexStatsRequest, IndexStatusRequest, @@ -207,6 +207,10 @@ pub fn definitions() -> Vec { "target_core_registers", "Read current thread and instruction, stack, and frame offsets from a daemon-owned target session.", ), + tool::( + "target_last_event", + "Read the last DbgEng event, including bounded exception, breakpoint, module, or exit evidence when available.", + ), tool::( "target_read_memory", "Read memory from a daemon-owned live or dump target session.", @@ -231,6 +235,10 @@ pub fn definitions() -> Vec { "target_stack_trace", "Walk the current stack for a daemon-owned live or dump target session.", ), + tool::( + "target_thread_context", + "Read bounded registers, module/symbol, stack, and disassembly for one engine thread, then restore the prior current thread.", + ), tool::( "target_disassemble", "Disassemble instructions from a daemon-owned live or dump target session.", @@ -488,6 +496,10 @@ pub async fn call(state: &mut ServiceState, call: ToolCall) -> anyhow::Result { + let request = parse::(call.arguments)?; + Ok(serde_json::to_value(state.targets.last_event(request)?)?) + } "target_read_memory" => { let request = parse::(call.arguments)?; Ok(serde_json::to_value(state.targets.read_memory(request)?)?) @@ -516,6 +528,12 @@ pub async fn call(state: &mut ServiceState, call: ToolCall) -> anyhow::Result(call.arguments)?; Ok(serde_json::to_value(state.targets.stack_trace(request)?)?) } + "target_thread_context" => { + let request = parse::(call.arguments)?; + Ok(serde_json::to_value( + state.targets.thread_context(request)?, + )?) + } "target_disassemble" => { let request = parse::(call.arguments)?; Ok(serde_json::to_value(state.targets.disassemble(request)?)?) diff --git a/docs/cli.md b/docs/cli.md index e1a7350..8c8a391 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -109,6 +109,24 @@ Use `--initial-break` when no reliable code breakpoint is available or the host `--end terminate` is the default for disposable startup probes. Use `--end detach` only when the debuggee should continue after the captured event. +## AI-oriented DbgEng target inspection + +Daemon-owned live and dump targets expose bounded, read-only inspection commands for agent workflows. After `target wait` reports a stop on a live target, use `target event` to identify the DbgEng event that caused it. The response includes the process/thread IDs, event type, and, where DbgEng provides it, a breakpoint ID, exception code/address/first-chance state, module base, or exit code. It does not return unbounded debugger output. Event inspection is unavailable for dump targets because a dump has no live event stream. + +```powershell +target\debug\windbg-tool.exe --compact target event --target 1 +``` + +`target threads` returns DbgEng engine thread IDs. Use one of those IDs with `target thread` to capture that thread's core registers, nearest module/symbol, bounded stack, and bounded disassembly. The command restores the previously selected DbgEng thread before returning, so it is safe for agents to inspect worker threads without changing subsequent target commands. + +```powershell +target\debug\windbg-tool.exe --compact target threads --target 1 +target\debug\windbg-tool.exe --compact target thread --target 1 ` + --engine-thread-id 3 --max-frames 16 --disassembly-count 8 +``` + +`debug snapshot --target ` now includes a best-effort `event` section by default for live targets. Use `--exclude event` when an event query is not relevant, or `--include event` to request it alone. + ## Canonical agent debugging commands Use `debug capabilities` before choosing actions. With no subject it returns a backend matrix for TTD cursors, daemon-owned live/dump targets, and remote process-server plans. With `--session/--cursor` or `--target`, it includes selected-subject status/evidence where available. @@ -234,7 +252,7 @@ The schema includes curated metadata where available and inferred metadata for o | Inspect runtime state | `registers`, `register-context`, `active-threads`, `command-line`, `architecture state` | | Inspect code and memory | `disasm`, `memory read`, `memory dump`, `memory strings`, `memory dps`, `memory classify`, `memory chase`, `object vtable` | | Symbol and source triage | `symbols doctor`, `symbols diagnose`, `symbols inspect`, `symbols exports`, `symbols nearest`, `source resolve` | -| WinDbg, live, dump, and remote helpers | `remote explain`, `remote doctor`, `remote status`, `remote plan`, `remote server-command`, `remote connect-command`, `dbgeng server`, `live capabilities`, `live startup-break`, `dump create`, `dump open`, `dump inspect`, `target dump`, `windbg status` | +| WinDbg, live, dump, and remote helpers | `remote explain`, `remote doctor`, `remote status`, `remote plan`, `remote server-command`, `remote connect-command`, `dbgeng server`, `live capabilities`, `live startup-break`, `dump create`, `dump open`, `dump inspect`, `target event`, `target thread`, `target dump`, `windbg status` | ## Useful non-replay commands