From 595651bdd554f847ad605a66469a5c7ae152999c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 4 May 2026 14:32:57 -0400 Subject: [PATCH 1/4] Add DbgEng process dump smoke workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/smoke-tests.yml | 119 ++++++++++++++++++++ README.md | 2 +- crates/windbg-dbgeng/src/lib.rs | 146 ++++++++++++++++++++++++- crates/windbg-tool/src/cli.rs | 99 ++++++++++++++++- crates/windbg-tool/src/cli/dispatch.rs | 4 + crates/windbg-tool/src/cli/platform.rs | 42 ++++++- crates/windbg-tool/tests/mcp_stdio.rs | 1 + crates/windbg-ttd/src/targets.rs | 55 +++++++++- crates/windbg-ttd/src/tools.rs | 10 +- docs/architecture.md | 2 +- docs/cli.md | 2 +- 11 files changed, 467 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/smoke-tests.yml diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml new file mode 100644 index 0000000..b090a1b --- /dev/null +++ b/.github/workflows/smoke-tests.yml @@ -0,0 +1,119 @@ +name: Smoke tests + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + dump-smoke: + name: Process dump smoke test + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select stable Rust + shell: pwsh + run: rustup default stable + + - name: Restore debugger dependencies + shell: pwsh + run: cargo xtask deps + + - name: Build windbg-tool + shell: pwsh + run: cargo build -p windbg-tool + + - name: Create and inspect ping dump + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + $tool = Join-Path $PWD 'target\debug\windbg-tool.exe' + $dumpDir = Join-Path $env:RUNNER_TEMP 'windbg-tool-smoke' + New-Item -ItemType Directory -Path $dumpDir -Force | Out-Null + $dumpPath = Join-Path $dumpDir 'ping.dmp' + Remove-Item -Path $dumpPath -Force -ErrorAction SilentlyContinue + + $ping = Start-Process ` + -FilePath 'C:\Windows\System32\ping.exe' ` + -ArgumentList @('127.0.0.1', '-n', '10') ` + -PassThru + + try { + Start-Sleep -Seconds 1 + + & $tool dump create ` + --process-id $ping.Id ` + --output $dumpPath ` + --kind mini ` + --overwrite | ConvertFrom-Json | Out-Null + + if (-not (Test-Path $dumpPath)) { + throw "Expected dump file was not created: $dumpPath" + } + + $dumpFile = Get-Item $dumpPath + if ($dumpFile.Length -le 0) { + throw "Dump file is empty: $dumpPath" + } + } + finally { + if (-not $ping.HasExited) { + Stop-Process -Id $ping.Id -Force + $ping.WaitForExit() + } + } + + & $tool daemon ensure | ConvertFrom-Json | Out-Null + + $opened = & $tool dump open $dumpPath | ConvertFrom-Json + if (-not $opened.target_id) { + throw "dump open did not return a target_id" + } + $target = $opened.target_id + + $status = & $tool target status --target $target | ConvertFrom-Json + if ($status.target.kind -ne 'dump') { + throw "Expected dump target kind, got '$($status.target.kind)'" + } + + $modules = & $tool target modules --target $target | ConvertFrom-Json + $pingModule = $modules.modules | Where-Object { + ($_.module_name -like '*ping*') -or + ($_.image_name -like '*ping.exe*') -or + ($_.loaded_image_name -like '*ping.exe*') + } | Select-Object -First 1 + if (-not $pingModule) { + throw "Expected loaded modules to include ping.exe" + } + + $threads = & $tool target threads --target $target | ConvertFrom-Json + if (-not $threads.threads -or $threads.threads.Count -lt 1) { + throw "Expected at least one thread in the dump" + } + + $registers = & $tool target registers --target $target | ConvertFrom-Json + if ( + -not $registers.registers.instruction_offset -and + -not $registers.registers.stack_offset -and + -not $registers.registers.frame_offset + ) { + throw "Expected at least one current-thread register offset in the dump" + } + + $stack = & $tool target stack --target $target --max-frames 8 | ConvertFrom-Json + if (-not $stack.frames -or $stack.frames.Count -lt 1) { + throw "Expected at least one stack frame in the dump" + } + + - name: Upload dump on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ping-dump-smoke + path: ${{ runner.temp }}\windbg-tool-smoke\ping.dmp + if-no-files-found: ignore + retention-days: 3 diff --git a/README.md b/README.md index 9106a36..d47505b 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Representative command areas: - Discovery: `discover`, `recipes`, `tools`, `schema` - Session and replay: `open`, `load`, `sessions`, `info`, `position set`, `step`, `replay to` - Analysis: `symbols diagnose`, `disasm`, `memory dump`, `memory strings`, `memory chase`, `stack recover`, `stack backtrace` -- Platform helpers: `remote explain`, `dbgeng server`, `live launch`, `windbg status` +- Platform helpers: `remote explain`, `dbgeng server`, `live launch`, `dump create`, `windbg status` For a fuller CLI walkthrough, output-shaping flags, and command map, see [the CLI guide](docs/cli.md). diff --git a/crates/windbg-dbgeng/src/lib.rs b/crates/windbg-dbgeng/src/lib.rs index 876eaa3..6fbaf87 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -1,4 +1,4 @@ -use anyhow::Context; +use anyhow::{bail, Context}; use serde::Serialize; use std::path::PathBuf; @@ -37,6 +37,27 @@ pub struct DumpOpenOptions { pub path: PathBuf, } +#[derive(Debug, Clone)] +pub struct DumpWriteOptions { + pub path: PathBuf, + pub kind: DumpKind, + pub overwrite: bool, +} + +#[derive(Debug, Clone)] +pub struct ProcessDumpOptions { + pub process_id: u32, + pub initial_break_timeout_ms: u32, + pub write: DumpWriteOptions, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DumpKind { + Mini, + Full, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum LiveLaunchEnd { @@ -61,6 +82,18 @@ pub struct LiveLaunchResult { pub end: LiveLaunchEnd, } +#[derive(Debug, Clone, Serialize)] +pub struct DumpWriteResult { + pub path: PathBuf, + pub kind: DumpKind, + pub qualifier: u32, + pub format_flags: u32, + pub overwrite: bool, + pub target: String, + pub process_id: Option, + pub detached: bool, +} + #[derive(Debug, Clone, Serialize)] pub struct DebuggerExecutionStatus { pub raw: Option, @@ -196,6 +229,10 @@ pub fn open_dump_session(options: DumpOpenOptions) -> anyhow::Result anyhow::Result { + write_process_dump_impl(options) +} + #[cfg(windows)] pub struct DebuggerSession { kind: DebuggerSessionKind, @@ -281,6 +318,23 @@ impl DebuggerSession { Ok(()) } + pub fn write_dump(&self, options: DumpWriteOptions) -> anyhow::Result { + if self.kind != DebuggerSessionKind::Live { + bail!("DbgEng dump writing requires a live target session"); + } + write_dump_file(&self.client, &options)?; + Ok(DumpWriteResult { + path: options.path, + kind: options.kind, + qualifier: dump_kind_qualifier(options.kind), + format_flags: dump_format_flags(options.overwrite), + overwrite: options.overwrite, + target: self.target.clone(), + process_id: self.current_process_system_id().ok().or(self.process_id), + detached: false, + }) + } + pub fn core_registers(&self) -> anyhow::Result { let instruction_offset = unsafe { self.registers.GetInstructionOffset().ok() }; let stack_offset = unsafe { self.registers.GetStackOffset().ok() }; @@ -699,6 +753,10 @@ impl DebuggerSession { anyhow::bail!("DbgEng sessions are only supported on Windows") } + pub fn write_dump(&self, _options: DumpWriteOptions) -> anyhow::Result { + anyhow::bail!("DbgEng dump writing is only supported on Windows") + } + pub fn core_registers(&self) -> anyhow::Result { anyhow::bail!("DbgEng sessions are only supported on Windows") } @@ -952,6 +1010,27 @@ fn open_dump_session_impl(options: DumpOpenOptions) -> anyhow::Result anyhow::Result { + let session = attach_live_session_impl(LiveAttachOptions { + process_id: options.process_id, + initial_break_timeout_ms: options.initial_break_timeout_ms, + })?; + let write_result = session.write_dump(options.write); + let detach_result = session.detach(); + match (write_result, detach_result) { + (Ok(mut result), Ok(())) => { + result.detached = true; + Ok(result) + } + (Ok(_), Err(error)) => Err(error).context("dump was written, but DbgEng detach failed"), + (Err(error), Ok(())) => Err(error), + (Err(write_error), Err(detach_error)) => Err(write_error).with_context(|| { + format!("DbgEng detach also failed after dump write failed: {detach_error}") + }), + } +} + #[cfg(windows)] fn read_wide_string(mut reader: F) -> anyhow::Result where @@ -999,6 +1078,26 @@ fn debug_value_type_name(value_type: u32) -> &'static str { } } +const DEBUG_DUMP_SMALL_VALUE: u32 = 1024; +const DEBUG_DUMP_FULL_VALUE: u32 = 1026; +const DEBUG_FORMAT_DEFAULT_VALUE: u32 = 0x0000_0000; +const DEBUG_FORMAT_NO_OVERWRITE_VALUE: u32 = 0x8000_0000; + +fn dump_kind_qualifier(kind: DumpKind) -> u32 { + match kind { + DumpKind::Mini => DEBUG_DUMP_SMALL_VALUE, + DumpKind::Full => DEBUG_DUMP_FULL_VALUE, + } +} + +fn dump_format_flags(overwrite: bool) -> u32 { + if overwrite { + DEBUG_FORMAT_DEFAULT_VALUE + } else { + DEBUG_FORMAT_NO_OVERWRITE_VALUE + } +} + fn encode_hex(bytes: &[u8]) -> String { let mut result = String::with_capacity(bytes.len() * 2); for byte in bytes { @@ -1008,6 +1107,28 @@ fn encode_hex(bytes: &[u8]) -> String { result } +#[cfg(windows)] +fn write_dump_file( + client: &windows::Win32::System::Diagnostics::Debug::Extensions::IDebugClient5, + options: &DumpWriteOptions, +) -> anyhow::Result<()> { + use windows::core::PCWSTR; + + let path_string = options.path.to_string_lossy().to_string(); + let mut path = path_string.encode_utf16().collect::>(); + path.push(0); + unsafe { + client.WriteDumpFileWide( + PCWSTR(path.as_ptr()), + 0, + dump_kind_qualifier(options.kind), + dump_format_flags(options.overwrite), + PCWSTR(std::ptr::null()), + )?; + } + Ok(()) +} + #[cfg(not(windows))] fn start_process_server_impl(options: ProcessServerOptions) -> anyhow::Result { let _ = options; @@ -1037,3 +1158,26 @@ fn open_dump_session_impl(options: DumpOpenOptions) -> anyhow::Result anyhow::Result { + let _ = options; + anyhow::bail!("DbgEng dump writing is only supported on Windows") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_dump_kinds_to_dbgeng_qualifiers() { + assert_eq!(dump_kind_qualifier(DumpKind::Mini), 1024); + assert_eq!(dump_kind_qualifier(DumpKind::Full), 1026); + } + + #[test] + fn uses_no_overwrite_by_default() { + assert_eq!(dump_format_flags(false), 0x8000_0000); + assert_eq!(dump_format_flags(true), 0); + } +} diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 926f6bc..9bb33b5 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -260,6 +260,8 @@ enum LiveCommand { enum DumpCommand { #[command(about = "Open a dump file as a daemon-owned target")] Open(DumpOpenArgs), + #[command(about = "Create a process dump from a live process id")] + Create(DumpCreateArgs), } #[derive(Debug, Subcommand)] @@ -396,6 +398,8 @@ enum TargetCommand { about = "Resolve source file and line information for a daemon-owned target address" )] Source(TargetAddressArgs), + #[command(about = "Write a process dump from a daemon-owned live target")] + Dump(TargetDumpArgs), } #[derive(Debug, Subcommand)] @@ -524,6 +528,20 @@ struct DumpOpenArgs { path: PathBuf, } +#[derive(Debug, Args)] +struct DumpCreateArgs { + #[arg(long, help = "Process id to dump through DbgEng")] + process_id: u32, + #[arg(long, value_name = "PATH", help = "Output .dmp path")] + output: PathBuf, + #[arg(long, value_enum, default_value_t = CliDumpKind::Mini)] + kind: CliDumpKind, + #[arg(long, help = "Allow replacing an existing dump file")] + overwrite: bool, + #[arg(long, default_value_t = 5000)] + initial_break_timeout_ms: u32, +} + #[derive(Debug, Args)] struct RemoteExplainArgs { #[arg(long, value_enum)] @@ -561,6 +579,12 @@ enum RemoteKind { Ntsd, } +#[derive(Debug, Clone, Copy, ValueEnum)] +enum CliDumpKind { + Mini, + Full, +} + #[derive(Debug, Args)] struct WindbgCommonArgs { #[arg(long = "install-dir")] @@ -730,6 +754,18 @@ struct TargetMemoryReadArgs { size: u32, } +#[derive(Debug, Args)] +struct TargetDumpArgs { + #[arg(short = 't', long = "target")] + target: u64, + #[arg(long, value_name = "PATH", help = "Output .dmp path")] + output: PathBuf, + #[arg(long, value_enum, default_value_t = CliDumpKind::Mini)] + kind: CliDumpKind, + #[arg(long, help = "Allow replacing an existing dump file")] + overwrite: bool, +} + #[derive(Debug, Args)] struct TargetStackTraceArgs { #[arg(short = 't', long = "target")] @@ -1329,8 +1365,8 @@ async fn target_capabilities_and_print( { "kind": "live_dbgeng_one_shot", "status": "partial", - "entry": "live launch", - "supports": ["launch", "initial_debug_event_status", "detach_or_terminate"], + "entry": ["live launch", "dump create"], + "supports": ["launch", "initial_debug_event_status", "detach_or_terminate", "process_dump_create"], "missing": ["persistence", "attach", "interactive session control"] }, { @@ -1353,7 +1389,8 @@ async fn target_capabilities_and_print( "source_lookup", "disassembly", "breakpoints", - "expression_evaluation" + "expression_evaluation", + "dump_write" ], "missing": ["event_streaming", "step_over", "step_out", "symbol_breakpoints", "output_capture"] }, @@ -2674,7 +2711,7 @@ fn discover_manifest() -> Value { "live start --command-line ", "live attach --process-id " ], - "dump": ["dump open "], + "dump": ["dump open ", "dump create --process-id --output "], "job": [ "job list", "job status --job ", @@ -2705,6 +2742,7 @@ fn discover_manifest() -> Value { "target modules --target ", "target registers --target ", "target memory --target --address --size ", + "target dump --target --output ", "target stack --target ", "target disasm --target ", "target symbol --target --address ", @@ -3061,6 +3099,7 @@ fn tool_command_map() -> Value { { "tool": "live_launch_session", "commands": ["live start"] }, { "tool": "live_attach_process", "commands": ["live attach"] }, { "tool": "dump_open_session", "commands": ["dump open"] }, + { "tool": "target_write_dump", "commands": ["target dump"] }, { "tool": "target_list", "commands": ["target list"] }, { "tool": "target_status", "commands": ["target status"] }, { "tool": "target_close", "commands": ["target close"] }, @@ -3249,6 +3288,15 @@ fn command_metadata() -> Value { "cost": "opens_dump_and_persists_target", "safety": "read_only_dump_analysis" }, + { + "command": "dump create", + "requires_daemon": false, + "requires_native_ttd": false, + "session_required": false, + "cost": "attaches_to_process_and_writes_dump", + "safety": "live_debugging_changes_target_execution_state", + "bounds": ["--kind mini|full", "--initial-break-timeout-ms", "--overwrite"] + }, { "command": "target capabilities", "requires_daemon": false, @@ -3282,6 +3330,15 @@ fn command_metadata() -> Value { "safety": "read_only_memory", "bounds": ["--size"] }, + { + "command": "target dump", + "requires_daemon": true, + "requires_native_ttd": false, + "session_required": false, + "cost": "writes_dump_from_live_target", + "safety": "live_debugging_changes_target_execution_state", + "bounds": ["--kind mini|full", "--overwrite"] + }, { "command": "target stack", "requires_daemon": true, @@ -3516,6 +3573,18 @@ fn target_memory_call(args: TargetMemoryReadArgs) -> anyhow::Result { }) } +fn target_dump_call(args: TargetDumpArgs) -> ToolCall { + ToolCall { + name: "target_write_dump".to_string(), + arguments: json!({ + "target_id": args.target, + "path": args.output, + "kind": cli_dump_kind_name(args.kind), + "overwrite": args.overwrite, + }), + } +} + fn target_stack_call(args: TargetStackTraceArgs) -> ToolCall { ToolCall { name: "target_stack_trace".to_string(), @@ -3526,6 +3595,13 @@ fn target_stack_call(args: TargetStackTraceArgs) -> ToolCall { } } +fn cli_dump_kind_name(kind: CliDumpKind) -> &'static str { + match kind { + CliDumpKind::Mini => "mini", + CliDumpKind::Full => "full", + } +} + fn target_disasm_call(args: TargetDisasmArgs) -> anyhow::Result { Ok(ToolCall { name: "target_disassemble".to_string(), @@ -5528,4 +5604,19 @@ mod tests { assert!(normalize_dll_name(r"C:\temp\example.dll").is_err()); Ok(()) } + + #[test] + fn builds_target_dump_tool_call() { + let call = target_dump_call(TargetDumpArgs { + target: 7, + output: PathBuf::from(r"C:\dumps\app.dmp"), + kind: CliDumpKind::Full, + overwrite: true, + }); + assert_eq!(call.name, "target_write_dump"); + assert_eq!(call.arguments["target_id"], 7); + assert_eq!(call.arguments["path"], r"C:\dumps\app.dmp"); + assert_eq!(call.arguments["kind"], "full"); + assert_eq!(call.arguments["overwrite"], true); + } } diff --git a/crates/windbg-tool/src/cli/dispatch.rs b/crates/windbg-tool/src/cli/dispatch.rs index 753aa19..fd57be0 100644 --- a/crates/windbg-tool/src/cli/dispatch.rs +++ b/crates/windbg-tool/src/cli/dispatch.rs @@ -41,6 +41,7 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { }, Some(Commands::Dump { command }) => match command { DumpCommand::Open(args) => dump_open_and_print(pipe, args, &output).await, + DumpCommand::Create(args) => platform::run_dump_create(args, &output), }, Some(Commands::DbgSrv(args)) => platform::run_dbgeng_server(args, &output), Some(Commands::Remote { command }) => { @@ -288,6 +289,9 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { ) .await } + TargetCommand::Dump(args) => { + call_and_print(pipe, target_dump_call(args), &output).await + } }, Some(Commands::Disasm(args)) => disasm_and_print(pipe, args, &output).await, Some(Commands::Registers(args)) => { diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 405c583..e86fc66 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -2,13 +2,13 @@ use anyhow::{bail, Context}; use serde_json::{json, Value}; use std::process::Command; use windbg_dbgeng::{ - live_launch_initial_break, start_process_server, LiveLaunchEnd, LiveLaunchOptions, - ProcessServerOptions, + live_launch_initial_break, start_process_server, write_process_dump, DumpKind, + DumpWriteOptions, LiveLaunchEnd, LiveLaunchOptions, ProcessDumpOptions, ProcessServerOptions, }; use windbg_install::WindbgManager; use super::output::{print_value, OutputOptions}; -use super::{DbgEngServerArgs, LiveLaunchArgs, WindbgCommand}; +use super::{CliDumpKind, DbgEngServerArgs, DumpCreateArgs, LiveLaunchArgs, WindbgCommand}; pub(super) fn run_dbgeng_server( args: DbgEngServerArgs, @@ -44,6 +44,35 @@ pub(super) fn run_live_launch(args: LiveLaunchArgs, output: &OutputOptions) -> a ) } +pub(super) fn run_dump_create(args: DumpCreateArgs, output: &OutputOptions) -> anyhow::Result<()> { + let result = write_process_dump(ProcessDumpOptions { + process_id: args.process_id, + initial_break_timeout_ms: args.initial_break_timeout_ms, + write: DumpWriteOptions { + path: args.output, + kind: cli_dump_kind(args.kind), + overwrite: args.overwrite, + }, + })?; + print_value( + json!({ + "result": result, + "session_persistence": "one_shot", + "notes": [ + "DbgEng attaches to the process to create the dump and detaches before returning." + ] + }), + output, + ) +} + +fn cli_dump_kind(kind: CliDumpKind) -> DumpKind { + match kind { + CliDumpKind::Mini => DumpKind::Mini, + CliDumpKind::Full => DumpKind::Full, + } +} + pub(super) fn live_capabilities() -> Value { json!({ "implemented": [ @@ -51,6 +80,8 @@ pub(super) fn live_capabilities() -> Value { "live launch --command-line --end detach|terminate", "live start --command-line ", "live attach --process-id ", + "dump create --process-id --output ", + "target dump --target --output ", "target list/status/wait/continue/step for live targets", "target threads/modules/registers/memory/stack/disasm/symbol/source for live targets" ], @@ -60,6 +91,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": "dump creation", + "status": "dbgeng_write_dump", + "notes": "Creates mini or full process dumps through DbgEng, either one-shot from a process id or from a daemon-owned live target." + }, { "feature": "daemon-backed live sessions", "status": "persistent_core_control", diff --git a/crates/windbg-tool/tests/mcp_stdio.rs b/crates/windbg-tool/tests/mcp_stdio.rs index a2dc449..a80d191 100644 --- a/crates/windbg-tool/tests/mcp_stdio.rs +++ b/crates/windbg-tool/tests/mcp_stdio.rs @@ -995,6 +995,7 @@ fn required_args_for_tool(name: &str) -> anyhow::Result<&'static [&'static str]> | "target_list_breakpoints" => Ok(&["target_id"]), "target_wait" => Ok(&["target_id"]), "target_read_memory" => Ok(&["target_id", "address", "size"]), + "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_disassemble" => Ok(&["target_id"]), diff --git a/crates/windbg-ttd/src/targets.rs b/crates/windbg-ttd/src/targets.rs index 640c0a9..f8b3741 100644 --- a/crates/windbg-ttd/src/targets.rs +++ b/crates/windbg-ttd/src/targets.rs @@ -6,9 +6,9 @@ use std::path::PathBuf; use windbg_dbgeng::{ attach_live_session, launch_live_session, open_dump_session, BreakpointInfo, CoreRegisterState, DebuggerExecutionStatus, DebuggerSession, DebuggerSessionKind, DebuggerSessionSummary, - DisassemblyResult, DumpOpenOptions, EvaluationResult, LiveAttachOptions, - LiveLaunchSessionOptions, MemoryReadResult, ModuleInfo, SourceLocation, StackFrameInfo, - SymbolInfo, ThreadInfo, + DisassemblyResult, DumpKind, DumpOpenOptions, DumpWriteOptions, DumpWriteResult, + EvaluationResult, LiveAttachOptions, LiveLaunchSessionOptions, MemoryReadResult, ModuleInfo, + SourceLocation, StackFrameInfo, SymbolInfo, ThreadInfo, }; pub type TargetId = u64; @@ -88,6 +88,24 @@ pub struct TargetExpressionRequest { pub expression: String, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct TargetWriteDumpRequest { + pub target_id: TargetId, + pub path: PathBuf, + #[serde(default)] + pub kind: TargetDumpKind, + #[serde(default)] + pub overwrite: bool, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TargetDumpKind { + #[default] + Mini, + Full, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum TargetBreakpointKind { @@ -205,6 +223,12 @@ pub struct TargetEvaluationResponse { pub evaluation: EvaluationResult, } +#[derive(Debug, Clone, Serialize)] +pub struct TargetWriteDumpResponse { + pub target_id: TargetId, + pub dump: DumpWriteResult, +} + impl TargetRegistry { pub fn list_targets(&self) -> TargetListResponse { let mut targets = self @@ -483,6 +507,22 @@ impl TargetRegistry { }) } + pub fn write_dump( + &self, + request: TargetWriteDumpRequest, + ) -> anyhow::Result { + let target = self.target(request.target_id)?; + ensure_live_target(request.target_id, &target.session)?; + Ok(TargetWriteDumpResponse { + target_id: request.target_id, + dump: target.session.write_dump(DumpWriteOptions { + path: request.path, + kind: request.kind.into(), + overwrite: request.overwrite, + })?, + }) + } + fn insert_target(&mut self, session: DebuggerSession) -> TargetOpenedResponse { let target_id = self.allocate_target_id(); let target = session.summary(); @@ -524,3 +564,12 @@ fn ensure_live_target(target_id: TargetId, session: &DebuggerSession) -> anyhow: } Ok(()) } + +impl From for DumpKind { + fn from(kind: TargetDumpKind) -> Self { + match kind { + TargetDumpKind::Mini => DumpKind::Mini, + TargetDumpKind::Full => DumpKind::Full, + } + } +} diff --git a/crates/windbg-ttd/src/tools.rs b/crates/windbg-ttd/src/tools.rs index 91dfedc..833c1d5 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, + TargetWaitRequest, TargetWriteDumpRequest, }; use crate::ttd_replay::{ AddressInfoRequest, CursorId, IndexBuildRequest, IndexStatsRequest, IndexStatusRequest, @@ -251,6 +251,10 @@ pub fn definitions() -> Vec { "target_evaluate_expression", "Evaluate a DbgEng expression against a daemon-owned live or dump target session.", ), + tool::( + "target_write_dump", + "Write a process dump from a daemon-owned live target session.", + ), tool::( "job_start_watch_memory_sweep", "Start a daemon-owned background replay job that collects multiple watch-memory hits.", @@ -538,6 +542,10 @@ pub async fn call(state: &mut ServiceState, call: ToolCall) -> anyhow::Result(call.arguments)?; Ok(serde_json::to_value(state.targets.evaluate(request)?)?) } + "target_write_dump" => { + let request = parse::(call.arguments)?; + Ok(serde_json::to_value(state.targets.write_dump(request)?)?) + } _ => bail!("unknown tool: {}", call.name), } } diff --git a/docs/architecture.md b/docs/architecture.md index b33db66..3373752 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,4 +45,4 @@ The Rust facade resolves caller settings into a process-local symbol configurati ## Current State -The Rust MCP server uses `rmcp` for stdio MCP protocol handling, and the native bridge boundary is scaffolded, built, and wired for the first native replay slices. The server can advertise tools, validate inputs, load a trace through `ttd_mcp_open_trace`, read `ttd_mcp_trace_info`, enumerate trace-wide threads/modules/exceptions/keyframes, list cursor-local module snapshots, list module and thread lifecycle events, create cursors, get/set cursor positions including TTD thread-scoped seeking, list active cursor threads with runtime PCs, step or trace cursors forward/backward, read compact and x64 scalar/SIMD cursor register/thread state, read bounded guest memory with selectable TTD query policies, query trace-backed memory ranges and provenance-rich memory buffers at a cursor, replay to memory watchpoints, and extract the process command line from PEB process parameters. The CLI discovery layer also exposes TimDbg-inspired recipes, remote-debugging command generation, one-shot `live launch`, `target capabilities`, `timeline events`, `symbols diagnose`/`symbols inspect`/`symbols exports`/`symbols nearest`, `source resolve`, `architecture state`, `module audit`, x64 `disasm`, `object vtable`, `stack recover`, `memory strings`, `memory dps`, `memory chase`, and a `context snapshot` command that composes daemon/session/cursor state, architecture, current disassembly, nearest-export fallback, and a bounded timeline into a single JSON object for agent skills. The next implementation step is to expand deeper PDB line/source diagnostics, daemon-backed live sessions, real unwind metadata, live/dump disassembly support, and callback-backed bounded replay sweeps for call tracing and event collection. +The Rust MCP server uses `rmcp` for stdio MCP protocol handling, and the native bridge boundary is scaffolded, built, and wired for the first native replay slices. The server can advertise tools, validate inputs, load a trace through `ttd_mcp_open_trace`, read `ttd_mcp_trace_info`, enumerate trace-wide threads/modules/exceptions/keyframes, list cursor-local module snapshots, list module and thread lifecycle events, create cursors, get/set cursor positions including TTD thread-scoped seeking, list active cursor threads with runtime PCs, step or trace cursors forward/backward, read compact and x64 scalar/SIMD cursor register/thread state, read bounded guest memory with selectable TTD query policies, query trace-backed memory ranges and provenance-rich memory buffers at a cursor, replay to memory watchpoints, and extract the process command line from PEB process parameters. The CLI discovery layer also exposes TimDbg-inspired recipes, remote-debugging command generation, one-shot `live launch`, DbgEng-backed `dump create`, daemon-backed `target dump`, `target capabilities`, `timeline events`, `symbols diagnose`/`symbols inspect`/`symbols exports`/`symbols nearest`, `source resolve`, `architecture state`, `module audit`, x64 `disasm`, `object vtable`, `stack recover`, `memory strings`, `memory dps`, `memory chase`, and a `context snapshot` command that composes daemon/session/cursor state, architecture, current disassembly, nearest-export fallback, and a bounded timeline into a single JSON object for agent skills. The next implementation step is to expand deeper PDB line/source diagnostics, daemon-backed live sessions, real unwind metadata, live/dump disassembly support, and callback-backed bounded replay sweeps for call tracing and event collection. diff --git a/docs/cli.md b/docs/cli.md index 54119de..b680f31 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -74,7 +74,7 @@ target\debug\windbg-tool.exe --compact registers --session 1 --cursor 1 | 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 diagnose`, `symbols inspect`, `symbols exports`, `symbols nearest`, `source resolve` | -| WinDbg and remote helpers | `remote explain`, `remote server-command`, `remote connect-command`, `dbgeng server`, `live capabilities`, `windbg status` | +| WinDbg, live, dump, and remote helpers | `remote explain`, `remote server-command`, `remote connect-command`, `dbgeng server`, `live capabilities`, `dump create`, `dump open`, `target dump`, `windbg status` | ## Useful non-replay commands From d3a52d08c43b56364f1ac9b596fb926482b467d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 4 May 2026 15:03:26 -0400 Subject: [PATCH 2/4] Use DbgHelp for process dump creation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/smoke-tests.yml | 17 ++- crates/windbg-dbgeng/Cargo.toml | 2 + crates/windbg-dbgeng/src/lib.rs | 145 +++++++++++++++++-------- crates/windbg-tool/src/cli.rs | 4 +- crates/windbg-tool/src/cli/platform.rs | 6 +- docs/architecture.md | 2 +- 6 files changed, 120 insertions(+), 56 deletions(-) diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index b090a1b..e83deed 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -9,6 +9,7 @@ jobs: dump-smoke: name: Process dump smoke test runs-on: windows-latest + timeout-minutes: 30 steps: - name: Checkout @@ -28,6 +29,7 @@ jobs: - name: Create and inspect ping dump shell: pwsh + timeout-minutes: 10 run: | $ErrorActionPreference = 'Stop' @@ -37,6 +39,7 @@ jobs: $dumpPath = Join-Path $dumpDir 'ping.dmp' Remove-Item -Path $dumpPath -Force -ErrorAction SilentlyContinue + Write-Host 'Launching ping.exe' $ping = Start-Process ` -FilePath 'C:\Windows\System32\ping.exe' ` -ArgumentList @('127.0.0.1', '-n', '10') ` @@ -45,11 +48,13 @@ jobs: try { Start-Sleep -Seconds 1 - & $tool dump create ` + Write-Host 'Creating process dump' + $created = & $tool dump create ` --process-id $ping.Id ` --output $dumpPath ` --kind mini ` - --overwrite | ConvertFrom-Json | Out-Null + --overwrite | Tee-Object -Variable createOutput + $createOutput | ConvertFrom-Json | Out-Null if (-not (Test-Path $dumpPath)) { throw "Expected dump file was not created: $dumpPath" @@ -62,24 +67,29 @@ jobs: } finally { if (-not $ping.HasExited) { + Write-Host 'Terminating ping.exe' Stop-Process -Id $ping.Id -Force $ping.WaitForExit() } } + Write-Host 'Ensuring daemon' & $tool daemon ensure | ConvertFrom-Json | Out-Null + Write-Host 'Opening dump' $opened = & $tool dump open $dumpPath | ConvertFrom-Json if (-not $opened.target_id) { throw "dump open did not return a target_id" } $target = $opened.target_id + Write-Host 'Checking target status' $status = & $tool target status --target $target | ConvertFrom-Json if ($status.target.kind -ne 'dump') { throw "Expected dump target kind, got '$($status.target.kind)'" } + Write-Host 'Checking modules' $modules = & $tool target modules --target $target | ConvertFrom-Json $pingModule = $modules.modules | Where-Object { ($_.module_name -like '*ping*') -or @@ -90,11 +100,13 @@ jobs: throw "Expected loaded modules to include ping.exe" } + Write-Host 'Checking threads' $threads = & $tool target threads --target $target | ConvertFrom-Json if (-not $threads.threads -or $threads.threads.Count -lt 1) { throw "Expected at least one thread in the dump" } + Write-Host 'Checking registers' $registers = & $tool target registers --target $target | ConvertFrom-Json if ( -not $registers.registers.instruction_offset -and @@ -104,6 +116,7 @@ jobs: throw "Expected at least one current-thread register offset in the dump" } + Write-Host 'Checking stack' $stack = & $tool target stack --target $target --max-frames 8 | ConvertFrom-Json if (-not $stack.frames -or $stack.frames.Count -lt 1) { throw "Expected at least one stack frame in the dump" diff --git a/crates/windbg-dbgeng/Cargo.toml b/crates/windbg-dbgeng/Cargo.toml index d78d34a..db52ddb 100644 --- a/crates/windbg-dbgeng/Cargo.toml +++ b/crates/windbg-dbgeng/Cargo.toml @@ -11,8 +11,10 @@ serde.workspace = true [target.'cfg(windows)'.dependencies] windows = { workspace = true, features = [ "Win32_Foundation", + "Win32_Storage_FileSystem", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_Debug_Extensions", + "Win32_System_Kernel", "Win32_System_Memory", "Win32_System_ProcessStatus", "Win32_System_SystemInformation", diff --git a/crates/windbg-dbgeng/src/lib.rs b/crates/windbg-dbgeng/src/lib.rs index 6fbaf87..177fe25 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -322,17 +322,12 @@ impl DebuggerSession { if self.kind != DebuggerSessionKind::Live { bail!("DbgEng dump writing requires a live target session"); } - write_dump_file(&self.client, &options)?; - Ok(DumpWriteResult { - path: options.path, - kind: options.kind, - qualifier: dump_kind_qualifier(options.kind), - format_flags: dump_format_flags(options.overwrite), - overwrite: options.overwrite, - target: self.target.clone(), - process_id: self.current_process_system_id().ok().or(self.process_id), - detached: false, - }) + let process_id = self + .current_process_system_id() + .ok() + .or(self.process_id) + .context("no process id is available for this live target")?; + write_process_dump_file(process_id, self.target.clone(), false, options) } pub fn core_registers(&self) -> anyhow::Result { @@ -1012,23 +1007,13 @@ fn open_dump_session_impl(options: DumpOpenOptions) -> anyhow::Result anyhow::Result { - let session = attach_live_session_impl(LiveAttachOptions { - process_id: options.process_id, - initial_break_timeout_ms: options.initial_break_timeout_ms, - })?; - let write_result = session.write_dump(options.write); - let detach_result = session.detach(); - match (write_result, detach_result) { - (Ok(mut result), Ok(())) => { - result.detached = true; - Ok(result) - } - (Ok(_), Err(error)) => Err(error).context("dump was written, but DbgEng detach failed"), - (Err(error), Ok(())) => Err(error), - (Err(write_error), Err(detach_error)) => Err(write_error).with_context(|| { - format!("DbgEng detach also failed after dump write failed: {detach_error}") - }), - } + let _ = options.initial_break_timeout_ms; + write_process_dump_file( + options.process_id, + format!("pid:{}", options.process_id), + false, + options.write, + ) } #[cfg(windows)] @@ -1079,14 +1064,14 @@ fn debug_value_type_name(value_type: u32) -> &'static str { } const DEBUG_DUMP_SMALL_VALUE: u32 = 1024; -const DEBUG_DUMP_FULL_VALUE: u32 = 1026; +const DEBUG_DUMP_DEFAULT_VALUE: u32 = 1025; const DEBUG_FORMAT_DEFAULT_VALUE: u32 = 0x0000_0000; const DEBUG_FORMAT_NO_OVERWRITE_VALUE: u32 = 0x8000_0000; fn dump_kind_qualifier(kind: DumpKind) -> u32 { match kind { DumpKind::Mini => DEBUG_DUMP_SMALL_VALUE, - DumpKind::Full => DEBUG_DUMP_FULL_VALUE, + DumpKind::Full => DEBUG_DUMP_DEFAULT_VALUE, } } @@ -1108,25 +1093,89 @@ fn encode_hex(bytes: &[u8]) -> String { } #[cfg(windows)] -fn write_dump_file( - client: &windows::Win32::System::Diagnostics::Debug::Extensions::IDebugClient5, - options: &DumpWriteOptions, -) -> anyhow::Result<()> { - use windows::core::PCWSTR; +fn write_process_dump_file( + process_id: u32, + target: String, + detached: bool, + options: DumpWriteOptions, +) -> anyhow::Result { + use std::fs::OpenOptions; + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::Win32::System::Diagnostics::Debug::{ + MiniDumpWithDataSegs, MiniDumpWithFullMemory, MiniDumpWithFullMemoryInfo, + MiniDumpWithHandleData, MiniDumpWithProcessThreadData, MiniDumpWithThreadInfo, + MiniDumpWithUnloadedModules, MiniDumpWriteDump, MINIDUMP_TYPE, + }; + use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ, + }; - let path_string = options.path.to_string_lossy().to_string(); - let mut path = path_string.encode_utf16().collect::>(); - path.push(0); - unsafe { - client.WriteDumpFileWide( - PCWSTR(path.as_ptr()), - 0, - dump_kind_qualifier(options.kind), - dump_format_flags(options.overwrite), - PCWSTR(std::ptr::null()), - )?; + if !options.overwrite && options.path.exists() { + bail!("dump output already exists: {}", options.path.display()); } - Ok(()) + + let process = unsafe { + OpenProcess( + PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, + false, + process_id, + )? + }; + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(options.overwrite) + .create_new(!options.overwrite) + .open(&options.path) + .with_context(|| format!("failed to create dump file: {}", options.path.display()))?; + + let dump_type = match options.kind { + DumpKind::Mini => MINIDUMP_TYPE(0), + DumpKind::Full => { + MiniDumpWithFullMemory + | MiniDumpWithHandleData + | MiniDumpWithUnloadedModules + | MiniDumpWithProcessThreadData + | MiniDumpWithFullMemoryInfo + | MiniDumpWithThreadInfo + | MiniDumpWithDataSegs + } + }; + + let write_result = unsafe { + MiniDumpWriteDump( + process, + process_id, + HANDLE(file.as_raw_handle()), + dump_type, + None, + None, + None, + ) + }; + unsafe { + CloseHandle(process)?; + } + if let Err(error) = write_result { + return Err(error).context("MiniDumpWriteDump failed"); + } + drop(file); + let metadata = std::fs::metadata(&options.path) + .with_context(|| format!("dump file was not created: {}", options.path.display()))?; + if metadata.len() == 0 { + bail!("created an empty dump file: {}", options.path.display()); + } + Ok(DumpWriteResult { + path: options.path, + kind: options.kind, + qualifier: dump_kind_qualifier(options.kind), + format_flags: dump_format_flags(options.overwrite), + overwrite: options.overwrite, + target, + process_id: Some(process_id), + detached, + }) } #[cfg(not(windows))] @@ -1172,7 +1221,7 @@ mod tests { #[test] fn maps_dump_kinds_to_dbgeng_qualifiers() { assert_eq!(dump_kind_qualifier(DumpKind::Mini), 1024); - assert_eq!(dump_kind_qualifier(DumpKind::Full), 1026); + assert_eq!(dump_kind_qualifier(DumpKind::Full), 1025); } #[test] diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 9bb33b5..0f58ddb 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -3293,8 +3293,8 @@ fn command_metadata() -> Value { "requires_daemon": false, "requires_native_ttd": false, "session_required": false, - "cost": "attaches_to_process_and_writes_dump", - "safety": "live_debugging_changes_target_execution_state", + "cost": "opens_process_handle_and_writes_dump", + "safety": "process_snapshot_read", "bounds": ["--kind mini|full", "--initial-break-timeout-ms", "--overwrite"] }, { diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index e86fc66..0078841 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -59,7 +59,7 @@ pub(super) fn run_dump_create(args: DumpCreateArgs, output: &OutputOptions) -> a "result": result, "session_persistence": "one_shot", "notes": [ - "DbgEng attaches to the process to create the dump and detaches before returning." + "DbgHelp writes the dump from a process handle using the Microsoft Debugging Platform runtime staged by cargo xtask deps." ] }), output, @@ -93,8 +93,8 @@ pub(super) fn live_capabilities() -> Value { }, { "feature": "dump creation", - "status": "dbgeng_write_dump", - "notes": "Creates mini or full process dumps through DbgEng, either one-shot from a process id or from a daemon-owned live target." + "status": "dbghelp_minidump_writer", + "notes": "Creates mini or full process dumps through DbgHelp from the Microsoft Debugging Platform runtime, either one-shot from a process id or from a daemon-owned live target." }, { "feature": "daemon-backed live sessions", diff --git a/docs/architecture.md b/docs/architecture.md index 3373752..45d4c0b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,4 +45,4 @@ The Rust facade resolves caller settings into a process-local symbol configurati ## Current State -The Rust MCP server uses `rmcp` for stdio MCP protocol handling, and the native bridge boundary is scaffolded, built, and wired for the first native replay slices. The server can advertise tools, validate inputs, load a trace through `ttd_mcp_open_trace`, read `ttd_mcp_trace_info`, enumerate trace-wide threads/modules/exceptions/keyframes, list cursor-local module snapshots, list module and thread lifecycle events, create cursors, get/set cursor positions including TTD thread-scoped seeking, list active cursor threads with runtime PCs, step or trace cursors forward/backward, read compact and x64 scalar/SIMD cursor register/thread state, read bounded guest memory with selectable TTD query policies, query trace-backed memory ranges and provenance-rich memory buffers at a cursor, replay to memory watchpoints, and extract the process command line from PEB process parameters. The CLI discovery layer also exposes TimDbg-inspired recipes, remote-debugging command generation, one-shot `live launch`, DbgEng-backed `dump create`, daemon-backed `target dump`, `target capabilities`, `timeline events`, `symbols diagnose`/`symbols inspect`/`symbols exports`/`symbols nearest`, `source resolve`, `architecture state`, `module audit`, x64 `disasm`, `object vtable`, `stack recover`, `memory strings`, `memory dps`, `memory chase`, and a `context snapshot` command that composes daemon/session/cursor state, architecture, current disassembly, nearest-export fallback, and a bounded timeline into a single JSON object for agent skills. The next implementation step is to expand deeper PDB line/source diagnostics, daemon-backed live sessions, real unwind metadata, live/dump disassembly support, and callback-backed bounded replay sweeps for call tracing and event collection. +The Rust MCP server uses `rmcp` for stdio MCP protocol handling, and the native bridge boundary is scaffolded, built, and wired for the first native replay slices. The server can advertise tools, validate inputs, load a trace through `ttd_mcp_open_trace`, read `ttd_mcp_trace_info`, enumerate trace-wide threads/modules/exceptions/keyframes, list cursor-local module snapshots, list module and thread lifecycle events, create cursors, get/set cursor positions including TTD thread-scoped seeking, list active cursor threads with runtime PCs, step or trace cursors forward/backward, read compact and x64 scalar/SIMD cursor register/thread state, read bounded guest memory with selectable TTD query policies, query trace-backed memory ranges and provenance-rich memory buffers at a cursor, replay to memory watchpoints, and extract the process command line from PEB process parameters. The CLI discovery layer also exposes TimDbg-inspired recipes, remote-debugging command generation, one-shot `live launch`, DbgHelp-backed `dump create`, daemon-backed `target dump`, `target capabilities`, `timeline events`, `symbols diagnose`/`symbols inspect`/`symbols exports`/`symbols nearest`, `source resolve`, `architecture state`, `module audit`, x64 `disasm`, `object vtable`, `stack recover`, `memory strings`, `memory dps`, `memory chase`, and a `context snapshot` command that composes daemon/session/cursor state, architecture, current disassembly, nearest-export fallback, and a bounded timeline into a single JSON object for agent skills. The next implementation step is to expand deeper PDB line/source diagnostics, daemon-backed live sessions, real unwind metadata, live/dump disassembly support, and callback-backed bounded replay sweeps for call tracing and event collection. From 44000c2b346c8773dc3cbd9b4a06298ba80eb02a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 4 May 2026 15:30:28 -0400 Subject: [PATCH 3/4] Use daemon-free dump inspection in smoke workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/smoke-tests.yml | 34 ++++++++------------------ README.md | 2 +- crates/windbg-tool/src/cli.rs | 20 ++++++++++++++- crates/windbg-tool/src/cli/dispatch.rs | 1 + crates/windbg-tool/src/cli/platform.rs | 27 +++++++++++++++++--- docs/cli.md | 2 +- 6 files changed, 56 insertions(+), 30 deletions(-) diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index e83deed..dd1e76f 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -73,25 +73,14 @@ jobs: } } - Write-Host 'Ensuring daemon' - & $tool daemon ensure | ConvertFrom-Json | Out-Null - - Write-Host 'Opening dump' - $opened = & $tool dump open $dumpPath | ConvertFrom-Json - if (-not $opened.target_id) { - throw "dump open did not return a target_id" - } - $target = $opened.target_id - - Write-Host 'Checking target status' - $status = & $tool target status --target $target | ConvertFrom-Json - if ($status.target.kind -ne 'dump') { - throw "Expected dump target kind, got '$($status.target.kind)'" + Write-Host 'Opening and inspecting dump' + $inspection = & $tool dump inspect $dumpPath --max-frames 8 | ConvertFrom-Json + if ($inspection.target.kind -ne 'dump') { + throw "Expected dump target kind, got '$($inspection.target.kind)'" } Write-Host 'Checking modules' - $modules = & $tool target modules --target $target | ConvertFrom-Json - $pingModule = $modules.modules | Where-Object { + $pingModule = $inspection.modules | Where-Object { ($_.module_name -like '*ping*') -or ($_.image_name -like '*ping.exe*') -or ($_.loaded_image_name -like '*ping.exe*') @@ -101,24 +90,21 @@ jobs: } Write-Host 'Checking threads' - $threads = & $tool target threads --target $target | ConvertFrom-Json - if (-not $threads.threads -or $threads.threads.Count -lt 1) { + if (-not $inspection.threads -or $inspection.threads.Count -lt 1) { throw "Expected at least one thread in the dump" } Write-Host 'Checking registers' - $registers = & $tool target registers --target $target | ConvertFrom-Json if ( - -not $registers.registers.instruction_offset -and - -not $registers.registers.stack_offset -and - -not $registers.registers.frame_offset + -not $inspection.registers.instruction_offset -and + -not $inspection.registers.stack_offset -and + -not $inspection.registers.frame_offset ) { throw "Expected at least one current-thread register offset in the dump" } Write-Host 'Checking stack' - $stack = & $tool target stack --target $target --max-frames 8 | ConvertFrom-Json - if (-not $stack.frames -or $stack.frames.Count -lt 1) { + if (-not $inspection.frames -or $inspection.frames.Count -lt 1) { throw "Expected at least one stack frame in the dump" } diff --git a/README.md b/README.md index d47505b..a11a23c 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Representative command areas: - Discovery: `discover`, `recipes`, `tools`, `schema` - Session and replay: `open`, `load`, `sessions`, `info`, `position set`, `step`, `replay to` - Analysis: `symbols diagnose`, `disasm`, `memory dump`, `memory strings`, `memory chase`, `stack recover`, `stack backtrace` -- Platform helpers: `remote explain`, `dbgeng server`, `live launch`, `dump create`, `windbg status` +- Platform helpers: `remote explain`, `dbgeng server`, `live launch`, `dump create`, `dump inspect`, `windbg status` For a fuller CLI walkthrough, output-shaping flags, and command map, see [the CLI guide](docs/cli.md). diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 0f58ddb..ab6fd11 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -260,6 +260,8 @@ enum LiveCommand { enum DumpCommand { #[command(about = "Open a dump file as a daemon-owned target")] Open(DumpOpenArgs), + #[command(about = "Open and inspect a dump file without the daemon")] + Inspect(DumpInspectArgs), #[command(about = "Create a process dump from a live process id")] Create(DumpCreateArgs), } @@ -542,6 +544,13 @@ struct DumpCreateArgs { initial_break_timeout_ms: u32, } +#[derive(Debug, Args)] +struct DumpInspectArgs { + path: PathBuf, + #[arg(long, default_value_t = 8)] + max_frames: u32, +} + #[derive(Debug, Args)] struct RemoteExplainArgs { #[arg(long, value_enum)] @@ -2711,7 +2720,7 @@ fn discover_manifest() -> Value { "live start --command-line ", "live attach --process-id " ], - "dump": ["dump open ", "dump create --process-id --output "], + "dump": ["dump open ", "dump inspect ", "dump create --process-id --output "], "job": [ "job list", "job status --job ", @@ -3288,6 +3297,15 @@ fn command_metadata() -> Value { "cost": "opens_dump_and_persists_target", "safety": "read_only_dump_analysis" }, + { + "command": "dump inspect", + "requires_daemon": false, + "requires_native_ttd": false, + "session_required": false, + "cost": "opens_dump_and_reads_summary", + "safety": "read_only_dump_analysis", + "bounds": ["--max-frames"] + }, { "command": "dump create", "requires_daemon": false, diff --git a/crates/windbg-tool/src/cli/dispatch.rs b/crates/windbg-tool/src/cli/dispatch.rs index fd57be0..c3c7590 100644 --- a/crates/windbg-tool/src/cli/dispatch.rs +++ b/crates/windbg-tool/src/cli/dispatch.rs @@ -41,6 +41,7 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { }, Some(Commands::Dump { command }) => match command { DumpCommand::Open(args) => dump_open_and_print(pipe, args, &output).await, + DumpCommand::Inspect(args) => platform::run_dump_inspect(args, &output), DumpCommand::Create(args) => platform::run_dump_create(args, &output), }, Some(Commands::DbgSrv(args)) => platform::run_dbgeng_server(args, &output), diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 0078841..446a7b2 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -2,13 +2,16 @@ use anyhow::{bail, Context}; use serde_json::{json, Value}; use std::process::Command; use windbg_dbgeng::{ - live_launch_initial_break, start_process_server, write_process_dump, DumpKind, - DumpWriteOptions, LiveLaunchEnd, LiveLaunchOptions, ProcessDumpOptions, ProcessServerOptions, + live_launch_initial_break, open_dump_session, start_process_server, write_process_dump, + DumpKind, DumpOpenOptions, DumpWriteOptions, LiveLaunchEnd, LiveLaunchOptions, + ProcessDumpOptions, ProcessServerOptions, }; use windbg_install::WindbgManager; use super::output::{print_value, OutputOptions}; -use super::{CliDumpKind, DbgEngServerArgs, DumpCreateArgs, LiveLaunchArgs, WindbgCommand}; +use super::{ + CliDumpKind, DbgEngServerArgs, DumpCreateArgs, DumpInspectArgs, LiveLaunchArgs, WindbgCommand, +}; pub(super) fn run_dbgeng_server( args: DbgEngServerArgs, @@ -66,6 +69,23 @@ pub(super) fn run_dump_create(args: DumpCreateArgs, output: &OutputOptions) -> a ) } +pub(super) fn run_dump_inspect( + args: DumpInspectArgs, + output: &OutputOptions, +) -> anyhow::Result<()> { + let session = open_dump_session(DumpOpenOptions { path: args.path })?; + print_value( + json!({ + "target": session.summary(), + "modules": session.modules()?, + "threads": session.threads()?, + "registers": session.core_registers()?, + "frames": session.stack_trace(args.max_frames)?, + }), + output, + ) +} + fn cli_dump_kind(kind: CliDumpKind) -> DumpKind { match kind { CliDumpKind::Mini => DumpKind::Mini, @@ -81,6 +101,7 @@ pub(super) fn live_capabilities() -> Value { "live start --command-line ", "live attach --process-id ", "dump create --process-id --output ", + "dump inspect ", "target dump --target --output ", "target list/status/wait/continue/step for live targets", "target threads/modules/registers/memory/stack/disasm/symbol/source for live targets" diff --git a/docs/cli.md b/docs/cli.md index b680f31..0128abb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -74,7 +74,7 @@ target\debug\windbg-tool.exe --compact registers --session 1 --cursor 1 | 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 diagnose`, `symbols inspect`, `symbols exports`, `symbols nearest`, `source resolve` | -| WinDbg, live, dump, and remote helpers | `remote explain`, `remote server-command`, `remote connect-command`, `dbgeng server`, `live capabilities`, `dump create`, `dump open`, `target dump`, `windbg status` | +| WinDbg, live, dump, and remote helpers | `remote explain`, `remote server-command`, `remote connect-command`, `dbgeng server`, `live capabilities`, `dump create`, `dump open`, `dump inspect`, `target dump`, `windbg status` | ## Useful non-replay commands From b2c4accdf6deb3a1c1303eafc849ae0867993307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 4 May 2026 16:37:12 -0400 Subject: [PATCH 4/4] Add Windows release package workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-windows.yml | 172 ++++++++++++++ .github/workflows/smoke-tests.yml | 4 +- README.md | 2 + docs/architecture.md | 4 +- docs/development.md | 17 ++ scripts/Get-TtdReplayRuntime.ps1 | 1 + xtask/src/main.rs | 347 +++++++++++++++++++++++----- 7 files changed, 490 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/build-windows.yml diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 0000000..dd0446a --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,172 @@ +name: Windows packages + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + package: + name: Package ${{ matrix.label }} + runs-on: windows-latest + timeout-minutes: 60 + + strategy: + fail-fast: false + matrix: + include: + - label: windows-x64 + rust_target: x86_64-pc-windows-msvc + nuget_arch: amd64 + vcvars_arch: x64 + artifact: windbg-tool-windows-x64 + can_run: true + - label: windows-arm64 + rust_target: aarch64-pc-windows-msvc + nuget_arch: arm64 + vcvars_arch: x64_arm64 + artifact: windbg-tool-windows-arm64 + can_run: false + + env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -C target-feature=+crt-static + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Select stable Rust + shell: pwsh + run: | + rustup default stable + rustup target add ${{ matrix.rust_target }} + + - name: Restore debugger dependencies + shell: pwsh + run: cargo xtask deps --arch ${{ matrix.nuget_arch }} + + - name: Ensure ARM64 MSVC toolset + if: matrix.nuget_arch == 'arm64' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + $installer = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vs_installer.exe' + if (-not (Test-Path $vswhere)) { + throw "vswhere.exe was not found at $vswhere" + } + if (-not (Test-Path $installer)) { + throw "vs_installer.exe was not found at $installer" + } + $installationPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $installationPath) { + throw 'Could not find a Visual Studio installation with the MSVC x64 toolset' + } + & $installer modify ` + --installPath $installationPath ` + --add Microsoft.VisualStudio.Component.VC.Tools.ARM64 ` + --quiet ` + --norestart ` + --nocache ` + --wait + if (($LASTEXITCODE -ne 0) -and ($LASTEXITCODE -ne 3010)) { + exit $LASTEXITCODE + } + + - name: Build native bridge + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + $installationPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $installationPath) { + throw 'Could not find a Visual Studio installation with the MSVC x64 toolset' + } + $vcvars = Join-Path $installationPath 'VC\Auxiliary\Build\vcvarsall.bat' + cmd /c "`"$vcvars`" ${{ matrix.vcvars_arch }} && cargo xtask native-build --arch ${{ matrix.nuget_arch }} --static-crt" + + - name: Build windbg-tool + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + $installationPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $installationPath) { + throw 'Could not find a Visual Studio installation with the MSVC x64 toolset' + } + $vcvars = Join-Path $installationPath 'VC\Auxiliary\Build\vcvarsall.bat' + cmd /c "`"$vcvars`" ${{ matrix.vcvars_arch }} && cargo build -p windbg-tool --release --target ${{ matrix.rust_target }}" + + - name: Package runtime files + shell: pwsh + run: | + cargo xtask package ` + --arch ${{ matrix.nuget_arch }} ` + --target ${{ matrix.rust_target }} ` + --profile release ` + --out "target\package\${{ matrix.artifact }}" + + - name: Validate package and create ZIP + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + $packageDir = Join-Path $PWD 'target\package\${{ matrix.artifact }}' + $zipPath = Join-Path $PWD 'target\package\${{ matrix.artifact }}.zip' + $requiredFiles = @( + 'windbg-tool.exe', + 'ttd_replay_bridge.dll', + 'TTDReplay.dll', + 'TTDReplayCPU.dll', + 'dbgeng.dll', + 'dbgcore.dll', + 'dbghelp.dll', + 'dbgmodel.dll', + 'msdia140.dll', + 'symsrv.dll', + 'srcsrv.dll' + ) + + foreach ($file in $requiredFiles) { + $path = Join-Path $packageDir $file + if (-not (Test-Path $path)) { + throw "Package is missing required file: $file" + } + } + + if ('${{ matrix.can_run }}' -eq 'true') { + Push-Location $packageDir + try { + & '.\windbg-tool.exe' discover | Out-Null + } + finally { + Pop-Location + } + } + + if (Test-Path $zipPath) { + Remove-Item $zipPath -Force + } + Compress-Archive -Path (Join-Path $packageDir '*') -DestinationPath $zipPath -Force + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) + try { + $entries = @($zip.Entries | ForEach-Object { $_.FullName.Replace('/', '\') }) + foreach ($file in $requiredFiles) { + if ($entries -notcontains $file) { + throw "ZIP is missing required file: $file" + } + } + } + finally { + $zip.Dispose() + } + + - name: Upload package + uses: actions/upload-artifact@v6 + with: + name: ${{ matrix.artifact }} + path: target\package\${{ matrix.artifact }}.zip + if-no-files-found: error diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index dd1e76f..fab0c65 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Select stable Rust shell: pwsh @@ -110,7 +110,7 @@ jobs: - name: Upload dump on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ping-dump-smoke path: ${{ runner.temp }}\windbg-tool-smoke\ping.dmp diff --git a/README.md b/README.md index a11a23c..3d11f69 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ The built executable is: target\debug\windbg-tool.exe ``` +Release ZIP artifacts are built by the Windows packaging workflow. The local equivalent uses `cargo xtask deps --arch `, `cargo xtask native-build --arch --static-crt`, a release build for the matching MSVC Rust target, and `cargo xtask package --profile release`. + For deeper setup, test commands, runtime details, and workspace notes, see [the development guide](docs/development.md). ## CLI quick start diff --git a/docs/architecture.md b/docs/architecture.md index 45d4c0b..7a99ba2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,7 @@ cargo xtask deps or run [scripts/Get-TtdReplayRuntime.ps1](../scripts/Get-TtdReplayRuntime.ps1) directly. -The native bridge has a CMake project at [native/ttd-replay-bridge/CMakeLists.txt](../native/ttd-replay-bridge/CMakeLists.txt). `cargo xtask native-build` configures it against the restored `Microsoft.TimeTravelDebugging.Apis` package and emits the bridge under `target/native/ttd-replay-bridge`. +The native bridge has a CMake project at [native/ttd-replay-bridge/CMakeLists.txt](../native/ttd-replay-bridge/CMakeLists.txt). `cargo xtask native-build` configures it against the restored `Microsoft.TimeTravelDebugging.Apis` package and emits the bridge under `target/native/ttd-replay-bridge`. Release packaging can pass `--arch amd64` or `--arch arm64` plus `--static-crt` so cross-compiled packages use the matching native bridge and statically link the MSVC runtime. ## Symbols @@ -37,7 +37,7 @@ The default symbol path is equivalent to: srv*.ttd-symbol-cache*https://msdl.microsoft.com/download/symbols ``` -`cargo xtask deps` stages `dbghelp.dll`, `symsrv.dll`, and `srcsrv.dll` from Microsoft Debugging Platform NuGet packages into `target/symbol-runtime`, and stages DbgEng process-server runtime DLLs into `target/dbgeng-runtime`. Keep this repo-local and process-local; do not set machine-wide `_NT_SYMBOL_PATH` or write debugger registry keys as part of normal server operation. If `_NT_SYMBOL_PATH` is already set in the server process environment, use it as a fallback only when the MCP request does not provide explicit `symbols.symbol_paths`. +`cargo xtask deps` stages `dbghelp.dll`, `symsrv.dll`, and `srcsrv.dll` from Microsoft Debugging Platform NuGet packages into `target/symbol-runtime`, and stages DbgEng process-server runtime DLLs into `target/dbgeng-runtime`. Architecture-explicit release staging uses `target/runtime//...` to keep x64 and ARM64 DLLs separate. Keep this repo-local and process-local; do not set machine-wide `_NT_SYMBOL_PATH` or write debugger registry keys as part of normal server operation. If `_NT_SYMBOL_PATH` is already set in the server process environment, use it as a fallback only when the MCP request does not provide explicit `symbols.symbol_paths`. Callers can provide additional binary paths, symbol paths, and a symbol cache directory when loading a trace. Public symbols are useful for module/function names. Private symbols are needed for richer function signatures and local details. diff --git a/docs/development.md b/docs/development.md index 5b5fdae..fd8e464 100644 --- a/docs/development.md +++ b/docs/development.md @@ -46,6 +46,23 @@ cargo xtask native-build `cargo xtask native-build` configures and builds the C++ bridge under `target\native\ttd-replay-bridge`. +For release packaging, use explicit target architecture inputs so the Rust binary, native bridge, and staged debugger runtime DLLs all match: + +```powershell +$env:RUSTFLAGS = "-C target-feature=+crt-static" +rustup target add x86_64-pc-windows-msvc +cargo xtask deps --arch amd64 +cargo xtask native-build --arch amd64 --static-crt +cargo build -p windbg-tool --release --target x86_64-pc-windows-msvc +cargo xtask package --arch amd64 --target x86_64-pc-windows-msvc --profile release --out target\package\windbg-tool-windows-x64 +``` + +Use `--arch arm64` with `--target aarch64-pc-windows-msvc` for the Windows ARM64 package. Architecture-specific dependency staging uses `target\runtime\\...`, while the legacy no-argument `cargo xtask deps`, `cargo xtask native-build`, and `cargo xtask package` commands keep using the existing host-architecture directories. + +Release packages statically link the MSVC C runtime into Rust code with `RUSTFLAGS=-C target-feature=+crt-static` and into the native bridge with `cargo xtask native-build --static-crt`. WinDbg, DbgEng, symbol, and TTD replay runtime DLLs remain dynamic dependencies and are copied into the package directory. + +Cross-compiling the ARM64 package from an x64 machine requires the Visual Studio ARM64 MSVC toolset and an `x64_arm64` developer environment for the native bridge and Rust crates that compile C/C++ code. + To smoke-test the packaged MCP server: ```powershell diff --git a/scripts/Get-TtdReplayRuntime.ps1 b/scripts/Get-TtdReplayRuntime.ps1 index e401a46..e3e9903 100644 --- a/scripts/Get-TtdReplayRuntime.ps1 +++ b/scripts/Get-TtdReplayRuntime.ps1 @@ -6,6 +6,7 @@ param( ) $ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" $resolvedOutDir = Resolve-Path -Path $OutDir -ErrorAction SilentlyContinue if ($null -eq $resolvedOutDir) { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 535ad20..bf6b709 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -38,17 +38,193 @@ const SYMBOL_RUNTIME_FILES: &[SymbolRuntimeFile] = &[ }, ]; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PackageArch { + Amd64, + Arm64, +} + +impl PackageArch { + fn from_value(value: &str) -> anyhow::Result { + match value.to_ascii_lowercase().as_str() { + "amd64" | "x64" | "x86_64" => Ok(Self::Amd64), + "arm64" | "aarch64" => Ok(Self::Arm64), + _ => bail!("unsupported package architecture: {value}"), + } + } + + fn from_rust_target(target: &str) -> anyhow::Result { + if target.starts_with("x86_64-") { + Ok(Self::Amd64) + } else if target.starts_with("aarch64-") { + Ok(Self::Arm64) + } else { + bail!("unsupported Windows Rust target: {target}") + } + } + + fn host() -> Self { + if env::var("PROCESSOR_ARCHITECTURE") + .map(|arch| arch.eq_ignore_ascii_case("ARM64")) + .unwrap_or(false) + { + Self::Arm64 + } else { + Self::Amd64 + } + } + + fn nuget_arch(self) -> &'static str { + match self { + Self::Amd64 => "amd64", + Self::Arm64 => "arm64", + } + } + + fn ttd_arch(self) -> &'static str { + match self { + Self::Amd64 => "x64", + Self::Arm64 => "arm64", + } + } + + fn msvc_platform(self) -> &'static str { + match self { + Self::Amd64 => "x64", + Self::Arm64 => "ARM64", + } + } + + fn rust_target(self) -> &'static str { + match self { + Self::Amd64 => "x86_64-pc-windows-msvc", + Self::Arm64 => "aarch64-pc-windows-msvc", + } + } + + fn package_label(self) -> &'static str { + match self { + Self::Amd64 => "windows-x64", + Self::Arm64 => "windows-arm64", + } + } +} + +#[derive(Clone, Debug)] +struct XtaskOptions { + arch: PackageArch, + explicit_target_layout: bool, + rust_target: Option, + profile: String, + package_dir: Option, + static_crt: bool, +} + +impl XtaskOptions { + fn parse(args: &[String]) -> anyhow::Result { + let mut arch = None; + let mut rust_target = None; + let mut profile = String::from("debug"); + let mut package_dir = None; + let mut static_crt = false; + let mut explicit_target_layout = false; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--arch" => { + index += 1; + let value = args.get(index).context("--arch requires a value")?; + arch = Some(PackageArch::from_value(value)?); + explicit_target_layout = true; + } + "--target" => { + index += 1; + let value = args.get(index).context("--target requires a value")?; + rust_target = Some(value.clone()); + explicit_target_layout = true; + } + "--profile" => { + index += 1; + let value = args.get(index).context("--profile requires a value")?; + ensure!( + value == "debug" || value == "release", + "--profile must be 'debug' or 'release'" + ); + profile = value.clone(); + } + "--out" | "--package-dir" => { + index += 1; + let value = args.get(index).context("--out requires a value")?; + package_dir = Some(PathBuf::from(value)); + } + "--static-crt" => { + static_crt = true; + } + option => bail!("unknown xtask option: {option}"), + } + index += 1; + } + + let inferred_arch = match (&arch, &rust_target) { + (Some(arch), Some(target)) => { + let target_arch = PackageArch::from_rust_target(target)?; + ensure!( + *arch == target_arch, + "--arch {} does not match --target {}", + arch.nuget_arch(), + target + ); + *arch + } + (Some(arch), None) => *arch, + (None, Some(target)) => PackageArch::from_rust_target(target)?, + (None, None) => PackageArch::host(), + }; + + let rust_target = if explicit_target_layout { + Some(rust_target.unwrap_or_else(|| inferred_arch.rust_target().to_string())) + } else { + None + }; + + Ok(Self { + arch: inferred_arch, + explicit_target_layout, + rust_target, + profile, + package_dir, + static_crt, + }) + } + + fn default_host() -> Self { + Self { + arch: PackageArch::host(), + explicit_target_layout: false, + rust_target: None, + profile: String::from("debug"), + package_dir: None, + static_crt: false, + } + } +} + fn main() -> anyhow::Result<()> { let mut args = env::args().skip(1); - match args.next().as_deref() { + let command = args.next(); + let rest = args.collect::>(); + match command.as_deref() { Some("doctor") => doctor(), - Some("deps") => deps(), - Some("native-build") => native_build(), - Some("package") => package(), + Some("deps") => deps(&XtaskOptions::parse(&rest)?), + Some("native-build") => native_build(&XtaskOptions::parse(&rest)?), + Some("package") => package(&XtaskOptions::parse(&rest)?), Some("mcp-smoke") => mcp_smoke(), Some(command) => bail!("unknown xtask command: {command}"), None => { - eprintln!("Usage: cargo xtask "); + eprintln!( + "Usage: cargo xtask [--arch amd64|arm64] [--target ] [--profile debug|release] [--out ] [--static-crt]" + ); Ok(()) } } @@ -103,7 +279,7 @@ fn doctor() -> anyhow::Result<()> { check_file(&test_trace); check_file(&root.join("traces/ping/ping.exe")); - if let Some(path) = native_bridge_candidates(&root) + if let Some(path) = native_bridge_candidates(&root, &XtaskOptions::default_host()) .into_iter() .find(|path| path.is_file()) { @@ -115,7 +291,7 @@ fn doctor() -> anyhow::Result<()> { Ok(()) } -fn deps() -> anyhow::Result<()> { +fn deps(options: &XtaskOptions) -> anyhow::Result<()> { let root = workspace_root()?; let packages_config = root.join("native/ttd-replay-bridge/packages.config"); let packages_dir = root.join("target/nuget"); @@ -128,10 +304,18 @@ fn deps() -> anyhow::Result<()> { .arg(&packages_dir)) .context("restoring native NuGet packages")?; - stage_symbol_runtime(&packages_dir, &root.join("target/symbol-runtime")) - .context("staging symbol runtime DLLs")?; - stage_dbgeng_runtime(&packages_dir, &root.join("target/dbgeng-runtime")) - .context("staging DbgEng runtime DLLs")?; + stage_symbol_runtime( + &packages_dir, + &symbol_runtime_dir(&root, options), + options.arch, + ) + .context("staging symbol runtime DLLs")?; + stage_dbgeng_runtime( + &packages_dir, + &dbgeng_runtime_dir(&root, options), + options.arch, + ) + .context("staging DbgEng runtime DLLs")?; run(Command::new("powershell") .arg("-ExecutionPolicy") @@ -139,18 +323,20 @@ fn deps() -> anyhow::Result<()> { .arg("-File") .arg(root.join("scripts/Get-TtdReplayRuntime.ps1")) .arg("-OutDir") - .arg(root.join("target/ttd-runtime"))) + .arg(ttd_runtime_dir(&root, options)) + .arg("-Arch") + .arg(options.arch.ttd_arch())) .context("downloading TTD replay runtime")?; Ok(()) } -fn native_build() -> anyhow::Result<()> { +fn native_build(options: &XtaskOptions) -> anyhow::Result<()> { let root = workspace_root()?; let packages_dir = root.join("target/nuget"); let ttd_apis_package = package_dir(&packages_dir, "Microsoft.TimeTravelDebugging.Apis")?; let source_dir = root.join("native/ttd-replay-bridge"); - let build_dir = root.join("target/native/ttd-replay-bridge"); + let build_dir = native_build_dir(&root, options); fs::create_dir_all(&build_dir).context("creating native bridge build directory")?; let mut configure = Command::new("cmake"); @@ -163,10 +349,14 @@ fn native_build() -> anyhow::Result<()> { "-DTTD_APIS_PACKAGE_DIR={}", ttd_apis_package.display() )) - .env("Platform", msvc_platform()); + .env("Platform", options.arch.msvc_platform()); + + if options.static_crt { + configure.arg("-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded$<$:Debug>"); + } if cfg!(windows) && cmake_generator_accepts_platform() { - configure.arg("-A").arg(msvc_platform()); + configure.arg("-A").arg(options.arch.msvc_platform()); } run(&mut configure).context("configuring native TTD replay bridge")?; @@ -181,31 +371,38 @@ fn native_build() -> anyhow::Result<()> { Ok(()) } -fn package() -> anyhow::Result<()> { +fn package(options: &XtaskOptions) -> anyhow::Result<()> { let root = workspace_root()?; - let package_dir = root.join("target/package"); + let package_dir = options + .package_dir + .clone() + .unwrap_or_else(|| default_package_dir(&root, options)); + if package_dir.exists() { + fs::remove_dir_all(&package_dir) + .with_context(|| format!("clearing package directory {}", package_dir.display()))?; + } fs::create_dir_all(&package_dir).context("creating package directory")?; copy_if_exists( - &root.join("target/debug/windbg-tool.exe"), + &windbg_tool_exe(&root, options), &package_dir.join("windbg-tool.exe"), )?; copy_first_existing( - native_bridge_candidates(&root), + native_bridge_candidates(&root, options), &package_dir.join(NATIVE_BRIDGE_DLL), )?; copy_runtime_files( - &root.join("target/ttd-runtime"), + &ttd_runtime_dir(&root, options), &package_dir, TTD_RUNTIME_FILES, )?; for file in SYMBOL_RUNTIME_FILES { copy_if_exists( - &root.join("target/symbol-runtime").join(file.dll), + &symbol_runtime_dir(&root, options).join(file.dll), &package_dir.join(file.dll), )?; } copy_runtime_files( - &root.join("target/dbgeng-runtime"), + &dbgeng_runtime_dir(&root, options), &package_dir, DBGENG_RUNTIME_FILES, )?; @@ -220,7 +417,7 @@ fn mcp_smoke() -> anyhow::Result<()> { .arg("-p") .arg("windbg-tool")) .context("building windbg-tool before packaged MCP smoke test")?; - package().context("preparing MCP package directory")?; + package(&XtaskOptions::default_host()).context("preparing MCP package directory")?; let package_dir = root.join("target/package"); let server_path = package_dir.join("windbg-tool.exe"); @@ -332,12 +529,15 @@ fn check_file(path: &Path) { } } -fn stage_symbol_runtime(packages_dir: &Path, symbol_runtime_dir: &Path) -> anyhow::Result<()> { - fs::create_dir_all(symbol_runtime_dir).context("creating target/symbol-runtime")?; - let arch = native_package_arch(); - +fn stage_symbol_runtime( + packages_dir: &Path, + symbol_runtime_dir: &Path, + arch: PackageArch, +) -> anyhow::Result<()> { + fs::create_dir_all(symbol_runtime_dir) + .with_context(|| format!("creating {}", symbol_runtime_dir.display()))?; for file in SYMBOL_RUNTIME_FILES { - let source = package_content_file(packages_dir, file.package, file.dll, arch)?; + let source = package_content_file(packages_dir, file.package, file.dll, arch.nuget_arch())?; let destination = symbol_runtime_dir.join(file.dll); fs::copy(&source, &destination).with_context(|| { format!("copying {} to {}", source.display(), destination.display()) @@ -348,15 +548,19 @@ fn stage_symbol_runtime(packages_dir: &Path, symbol_runtime_dir: &Path) -> anyho Ok(()) } -fn stage_dbgeng_runtime(packages_dir: &Path, dbgeng_runtime_dir: &Path) -> anyhow::Result<()> { - fs::create_dir_all(dbgeng_runtime_dir).context("creating target/dbgeng-runtime")?; - let arch = native_package_arch(); +fn stage_dbgeng_runtime( + packages_dir: &Path, + dbgeng_runtime_dir: &Path, + arch: PackageArch, +) -> anyhow::Result<()> { + fs::create_dir_all(dbgeng_runtime_dir) + .with_context(|| format!("creating {}", dbgeng_runtime_dir.display()))?; for dll in DBGENG_RUNTIME_FILES { let source = package_content_file( packages_dir, "Microsoft.Debugging.Platform.DbgEng", dll, - arch, + arch.nuget_arch(), )?; let destination = dbgeng_runtime_dir.join(dll); fs::copy(&source, &destination).with_context(|| { @@ -408,41 +612,78 @@ fn package_dir(packages_dir: &Path, package: &str) -> anyhow::Result { ) } -fn native_package_arch() -> &'static str { - if env::var("PROCESSOR_ARCHITECTURE") - .map(|arch| arch.eq_ignore_ascii_case("ARM64")) - .unwrap_or(false) - { - "arm64" +fn cmake_generator_accepts_platform() -> bool { + env::var("CMAKE_GENERATOR") + .map(|generator| !generator.to_ascii_lowercase().contains("ninja")) + .unwrap_or(true) +} + +fn runtime_root(root: &Path, options: &XtaskOptions) -> PathBuf { + root.join("target/runtime").join(options.arch.nuget_arch()) +} + +fn symbol_runtime_dir(root: &Path, options: &XtaskOptions) -> PathBuf { + if options.explicit_target_layout { + runtime_root(root, options).join("symbol-runtime") } else { - "amd64" + root.join("target/symbol-runtime") } } -fn msvc_platform() -> &'static str { - if env::var("PROCESSOR_ARCHITECTURE") - .map(|arch| arch.eq_ignore_ascii_case("ARM64")) - .unwrap_or(false) - { - "ARM64" +fn dbgeng_runtime_dir(root: &Path, options: &XtaskOptions) -> PathBuf { + if options.explicit_target_layout { + runtime_root(root, options).join("dbgeng-runtime") } else { - "x64" + root.join("target/dbgeng-runtime") } } -fn cmake_generator_accepts_platform() -> bool { - env::var("CMAKE_GENERATOR") - .map(|generator| !generator.to_ascii_lowercase().contains("ninja")) - .unwrap_or(true) +fn ttd_runtime_dir(root: &Path, options: &XtaskOptions) -> PathBuf { + if options.explicit_target_layout { + runtime_root(root, options).join("ttd-runtime") + } else { + root.join("target/ttd-runtime") + } +} + +fn native_build_dir(root: &Path, options: &XtaskOptions) -> PathBuf { + if options.explicit_target_layout { + root.join("target/native") + .join(format!("ttd-replay-bridge-{}", options.arch.nuget_arch())) + } else { + root.join("target/native/ttd-replay-bridge") + } +} + +fn default_package_dir(root: &Path, options: &XtaskOptions) -> PathBuf { + if options.explicit_target_layout { + root.join("target/package") + .join(options.arch.package_label()) + } else { + root.join("target/package") + } +} + +fn windbg_tool_exe(root: &Path, options: &XtaskOptions) -> PathBuf { + if let Some(rust_target) = &options.rust_target { + root.join("target") + .join(rust_target) + .join(&options.profile) + .join("windbg-tool.exe") + } else { + root.join("target") + .join(&options.profile) + .join("windbg-tool.exe") + } } -fn native_bridge_candidates(root: &Path) -> Vec { +fn native_bridge_candidates(root: &Path, options: &XtaskOptions) -> Vec { let mut candidates = Vec::new(); if let Some(path) = env::var_os("TTD_NATIVE_BRIDGE_DLL").map(PathBuf::from) { candidates.push(path); } - let build_dir = root.join("target/native/ttd-replay-bridge"); + let build_dir = native_build_dir(root, options); candidates.push(build_dir.join("bin/Release").join(NATIVE_BRIDGE_DLL)); candidates.push(build_dir.join("bin/Debug").join(NATIVE_BRIDGE_DLL)); candidates.push(build_dir.join("Release").join(NATIVE_BRIDGE_DLL));