From 4298734874edbb621d789bcf61add5c3d1a5de1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Jul 2026 09:39:28 -0400 Subject: [PATCH 01/12] Fix live DbgEng breakpoints Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-dbgeng/Cargo.toml | 1 + crates/windbg-dbgeng/src/lib.rs | 239 ++++++++++++++++--- crates/windbg-tool/src/cli.rs | 34 +++ crates/windbg-tool/src/cli/dispatch.rs | 1 + crates/windbg-tool/src/cli/platform.rs | 309 ++++++++++++++++++++++--- docs/cli.md | 19 ++ docs/development.md | 9 + 7 files changed, 545 insertions(+), 67 deletions(-) diff --git a/crates/windbg-dbgeng/Cargo.toml b/crates/windbg-dbgeng/Cargo.toml index db52ddb..921992f 100644 --- a/crates/windbg-dbgeng/Cargo.toml +++ b/crates/windbg-dbgeng/Cargo.toml @@ -15,6 +15,7 @@ windows = { workspace = true, features = [ "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_Debug_Extensions", "Win32_System_Kernel", + "Win32_System_LibraryLoader", "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 339f7f3..b44759c 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -1,15 +1,21 @@ use anyhow::{bail, ensure, Context}; use serde::Serialize; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; use std::{ env, path::{Path, PathBuf}, + sync::OnceLock, }; 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"; +pub const DBGENG_RUNTIME_DIR_ENV: &str = "WINDBG_DBGENG_RUNTIME_DIR"; const DEFAULT_DBGENG_SYMBOL_CACHE: &str = ".windbg-symbol-cache"; +const DBGENG_DLL_NAME: &str = "dbgeng.dll"; +const DBGENG_WAIT_TIMEOUT_HRESULT: i32 = 1; #[derive(Debug, Clone, PartialEq, Eq)] pub struct StandardSymbolEnvironment { @@ -88,6 +94,111 @@ fn env_path(name: &str) -> Option { }) } +#[cfg(windows)] +fn dbgeng_runtime_dll( + explicit_runtime_dir: Option<&Path>, + executable_path: Option<&Path>, +) -> anyhow::Result> { + if let Some(runtime_dir) = explicit_runtime_dir { + let dll = runtime_dir.join(DBGENG_DLL_NAME); + ensure!( + dll.is_file(), + "{DBGENG_RUNTIME_DIR_ENV} must name a directory containing {}", + dll.display() + ); + return Ok(Some(dll)); + } + + Ok(executable_path + .and_then(Path::parent) + .map(|directory| directory.join(DBGENG_DLL_NAME)) + .filter(|dll| dll.is_file())) +} + +#[cfg(windows)] +fn ensure_dbgeng_runtime_loaded() -> anyhow::Result<()> { + use windows::core::PCWSTR; + use windows::Win32::System::LibraryLoader::{ + LoadLibraryExW, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, + }; + + static LOAD_RESULT: OnceLock> = OnceLock::new(); + + let result = LOAD_RESULT.get_or_init(|| { + let explicit_runtime_dir = env::var_os(DBGENG_RUNTIME_DIR_ENV).map(PathBuf::from); + let executable_path = env::current_exe().ok(); + let dll = + match dbgeng_runtime_dll(explicit_runtime_dir.as_deref(), executable_path.as_deref()) { + Ok(dll) => dll, + Err(error) => return Err(error.to_string()), + }; + let Some(dll) = dll else { + return Ok(()); + }; + let mut dll_wide = dll.as_os_str().encode_wide().collect::>(); + dll_wide.push(0); + + // Keep the explicitly loaded module alive so DbgEng resolves all of its runtime dependencies + // from its own directory before DebugCreate is invoked. + unsafe { + LoadLibraryExW( + PCWSTR(dll_wide.as_ptr()), + None, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, + ) + } + .with_context(|| format!("loading DbgEng runtime {}", dll.display())) + .map(|_| ()) + .map_err(|error| error.to_string()) + }); + + if let Err(error) = result { + bail!("{error}"); + } + Ok(()) +} + +#[cfg(windows)] +fn create_debug_client( +) -> anyhow::Result { + use windows::Win32::System::Diagnostics::Debug::Extensions::DebugCreate; + + ensure_dbgeng_runtime_loaded()?; + unsafe { DebugCreate() }.context("DbgEng DebugCreate failed") +} + +#[cfg(windows)] +fn enable_initial_break( + control: &windows::Win32::System::Diagnostics::Debug::Extensions::IDebugControl5, +) -> anyhow::Result<()> { + use windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_ENGOPT_INITIAL_BREAK; + + unsafe { control.AddEngineOptions(DEBUG_ENGOPT_INITIAL_BREAK) } + .context("enabling the DbgEng initial-break engine option")?; + Ok(()) +} + +#[cfg(windows)] +fn wait_for_initial_event( + control: &windows::Win32::System::Diagnostics::Debug::Extensions::IDebugControl5, + timeout_ms: u32, + operation: &str, +) -> anyhow::Result<()> { + use windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_WAIT_DEFAULT; + + match unsafe { control.WaitForEvent(DEBUG_WAIT_DEFAULT, timeout_ms) } { + Ok(()) => Ok(()), + Err(error) if is_dbgeng_wait_timeout_hresult(error.code().0) => { + bail!("DbgEng {operation} initial WaitForEvent timed out after {timeout_ms} ms") + } + Err(error) => Err(error).context(format!("DbgEng {operation} initial WaitForEvent failed")), + } +} + +fn is_dbgeng_wait_timeout_hresult(hresult: i32) -> bool { + hresult == DBGENG_WAIT_TIMEOUT_HRESULT +} + #[derive(Debug, Clone)] pub struct ProcessServerOptions { pub transport: String, @@ -409,11 +520,16 @@ impl DebuggerSession { }; 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()), + Ok(()) => Ok(self.execution_status()), + // DbgEng documents S_FALSE as its bounded wait timeout result. + Err(error) if is_dbgeng_wait_timeout_hresult(error.code().0) => { + Ok(DebuggerExecutionStatus { + raw: Some(DEBUG_STATUS_TIMEOUT), + name: Some(status_name(DEBUG_STATUS_TIMEOUT)), + }) + } + Err(error) => Err(error).context("DbgEng WaitForEvent failed"), } } @@ -902,6 +1018,25 @@ impl DebuggerSession { self.breakpoint_info(&breakpoint) } + pub fn execute_command(&self, command: &str) -> anyhow::Result<()> { + use windows::core::PCWSTR; + use windows::Win32::System::Diagnostics::Debug::Extensions::{ + DEBUG_EXECUTE_DEFAULT, DEBUG_OUTCTL_THIS_CLIENT, + }; + + let mut command_wide = command.encode_utf16().collect::>(); + command_wide.push(0); + unsafe { + self.control.ExecuteWide( + DEBUG_OUTCTL_THIS_CLIENT, + PCWSTR(command_wide.as_ptr()), + DEBUG_EXECUTE_DEFAULT, + ) + } + .with_context(|| format!("executing DbgEng command '{command}'"))?; + Ok(()) + } + pub fn add_data_breakpoint( &self, address: u64, @@ -1292,14 +1427,14 @@ fn status_name(status: u32) -> String { fn start_process_server_impl(options: ProcessServerOptions) -> anyhow::Result { use windows::core::PCWSTR; use windows::Win32::System::Diagnostics::Debug::Extensions::{ - DebugCreate, IDebugClient5, DEBUG_CLASS_USER_WINDOWS, + IDebugClient5, DEBUG_CLASS_USER_WINDOWS, }; use windows::Win32::System::Threading::INFINITE; let mut transport = options.transport.encode_utf16().collect::>(); transport.push(0); - let client: IDebugClient5 = unsafe { DebugCreate()? }; + let client: IDebugClient5 = create_debug_client()?; unsafe { client.StartProcessServerWide( DEBUG_CLASS_USER_WINDOWS, @@ -1343,20 +1478,22 @@ fn live_launch_initial_break_impl(options: LiveLaunchOptions) -> anyhow::Result< fn launch_live_session_impl(options: LiveLaunchSessionOptions) -> anyhow::Result { use windows::core::{Interface, PCWSTR}; use windows::Win32::System::Diagnostics::Debug::Extensions::{ - DebugCreate, IDebugClient5, IDebugControl5, IDebugDataSpaces4, IDebugRegisters, - IDebugSymbols5, IDebugSystemObjects, DEBUG_PROCESS_ONLY_THIS_PROCESS, + IDebugClient5, IDebugControl5, IDebugDataSpaces4, IDebugRegisters, IDebugSymbols5, + IDebugSystemObjects, DEBUG_PROCESS_ONLY_THIS_PROCESS, }; let mut command_line = options.command_line.encode_utf16().collect::>(); command_line.push(0); - let client: IDebugClient5 = unsafe { DebugCreate()? }; - let control: IDebugControl5 = client.cast()?; - let data_spaces: IDebugDataSpaces4 = client.cast()?; - let registers: IDebugRegisters = client.cast()?; - let symbols: IDebugSymbols5 = client.cast()?; - let system_objects: IDebugSystemObjects = client.cast()?; + let client: IDebugClient5 = create_debug_client()?; + let control: IDebugControl5 = client.cast().context("querying IDebugControl5")?; + let data_spaces: IDebugDataSpaces4 = client.cast().context("querying IDebugDataSpaces4")?; + let registers: IDebugRegisters = client.cast().context("querying IDebugRegisters")?; + let symbols: IDebugSymbols5 = client.cast().context("querying IDebugSymbols5")?; + let system_objects: IDebugSystemObjects = + client.cast().context("querying IDebugSystemObjects")?; let symbol_path = configure_dbgeng_symbol_path(&symbols)?; + enable_initial_break(&control)?; unsafe { // DbgEng can mutate the command buffer; command_line is owned and remains live for the call. client @@ -1366,11 +1503,8 @@ fn launch_live_session_impl(options: LiveLaunchSessionOptions) -> anyhow::Result 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, - )?; } + wait_for_initial_event(&control, options.initial_break_timeout_ms, "launch")?; Ok(DebuggerSession { kind: DebuggerSessionKind::Live, @@ -1391,21 +1525,23 @@ fn launch_live_session_impl(options: LiveLaunchSessionOptions) -> anyhow::Result fn attach_live_session_impl(options: LiveAttachOptions) -> anyhow::Result { use windows::core::Interface; use windows::Win32::System::Diagnostics::Debug::Extensions::{ - DebugCreate, IDebugClient5, IDebugControl5, IDebugDataSpaces4, IDebugRegisters, - IDebugSymbols5, IDebugSystemObjects, DEBUG_ATTACH_DEFAULT, DEBUG_WAIT_DEFAULT, + IDebugClient5, IDebugControl5, IDebugDataSpaces4, IDebugRegisters, IDebugSymbols5, + IDebugSystemObjects, DEBUG_ATTACH_DEFAULT, }; - let client: IDebugClient5 = unsafe { DebugCreate()? }; - let control: IDebugControl5 = client.cast()?; - let data_spaces: IDebugDataSpaces4 = client.cast()?; - let registers: IDebugRegisters = client.cast()?; - let symbols: IDebugSymbols5 = client.cast()?; - let system_objects: IDebugSystemObjects = client.cast()?; + let client: IDebugClient5 = create_debug_client()?; + let control: IDebugControl5 = client.cast().context("querying IDebugControl5")?; + let data_spaces: IDebugDataSpaces4 = client.cast().context("querying IDebugDataSpaces4")?; + let registers: IDebugRegisters = client.cast().context("querying IDebugRegisters")?; + let symbols: IDebugSymbols5 = client.cast().context("querying IDebugSymbols5")?; + let system_objects: IDebugSystemObjects = + client.cast().context("querying IDebugSystemObjects")?; let symbol_path = configure_dbgeng_symbol_path(&symbols)?; + enable_initial_break(&control)?; unsafe { client.AttachProcess(0, options.process_id, DEBUG_ATTACH_DEFAULT)?; - control.WaitForEvent(DEBUG_WAIT_DEFAULT, options.initial_break_timeout_ms)?; } + wait_for_initial_event(&control, options.initial_break_timeout_ms, "attach")?; Ok(DebuggerSession { kind: DebuggerSessionKind::Live, @@ -1426,24 +1562,27 @@ fn attach_live_session_impl(options: LiveAttachOptions) -> anyhow::Result anyhow::Result { use windows::core::{Interface, PCWSTR}; use windows::Win32::System::Diagnostics::Debug::Extensions::{ - DebugCreate, IDebugClient5, IDebugControl5, IDebugDataSpaces4, IDebugRegisters, - IDebugSymbols5, IDebugSystemObjects, DEBUG_WAIT_DEFAULT, + IDebugClient5, IDebugControl5, IDebugDataSpaces4, IDebugRegisters, IDebugSymbols5, + IDebugSystemObjects, DEBUG_WAIT_DEFAULT, }; let path_string = options.path.to_string_lossy().to_string(); let mut path = path_string.encode_utf16().collect::>(); path.push(0); - let client: IDebugClient5 = unsafe { DebugCreate()? }; - let control: IDebugControl5 = client.cast()?; - let data_spaces: IDebugDataSpaces4 = client.cast()?; - let registers: IDebugRegisters = client.cast()?; - let symbols: IDebugSymbols5 = client.cast()?; - let system_objects: IDebugSystemObjects = client.cast()?; + let client: IDebugClient5 = create_debug_client()?; + let control: IDebugControl5 = client.cast().context("querying IDebugControl5")?; + let data_spaces: IDebugDataSpaces4 = client.cast().context("querying IDebugDataSpaces4")?; + let registers: IDebugRegisters = client.cast().context("querying IDebugRegisters")?; + let symbols: IDebugSymbols5 = client.cast().context("querying IDebugSymbols5")?; + let system_objects: IDebugSystemObjects = + client.cast().context("querying IDebugSystemObjects")?; let symbol_path = configure_dbgeng_symbol_path(&symbols)?; unsafe { client.OpenDumpFileWide(PCWSTR(path.as_ptr()), 0)?; - control.WaitForEvent(DEBUG_WAIT_DEFAULT, 5000)?; + control + .WaitForEvent(DEBUG_WAIT_DEFAULT, 5000) + .context("DbgEng dump WaitForEvent failed")?; } Ok(DebuggerSession { @@ -1766,4 +1905,32 @@ mod tests { assert_eq!(event_type_name(64), "load_module"); assert_eq!(event_type_name(0xFFFF), "unknown"); } + + #[test] + fn recognizes_dbgeng_s_false_as_wait_timeout() { + assert!(is_dbgeng_wait_timeout_hresult(1)); + assert!(!is_dbgeng_wait_timeout_hresult(0)); + assert!(!is_dbgeng_wait_timeout_hresult(-1)); + } + + #[cfg(windows)] + #[test] + fn explicit_dbgeng_runtime_overrides_an_adjacent_runtime() { + use std::fs; + + let root = + env::temp_dir().join(format!("windbg-dbgeng-runtime-test-{}", std::process::id())); + let explicit_runtime = root.join("explicit"); + let executable = root.join("bin").join("windbg-tool.exe"); + let adjacent_runtime = executable.parent().unwrap().join(DBGENG_DLL_NAME); + fs::create_dir_all(&explicit_runtime).unwrap(); + fs::create_dir_all(executable.parent().unwrap()).unwrap(); + fs::write(explicit_runtime.join(DBGENG_DLL_NAME), []).unwrap(); + fs::write(&adjacent_runtime, []).unwrap(); + + let resolved = dbgeng_runtime_dll(Some(&explicit_runtime), Some(&executable)).unwrap(); + + let _ = fs::remove_dir_all(&root); + assert_eq!(resolved, Some(explicit_runtime.join(DBGENG_DLL_NAME))); + } } diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index df5f47a..a56afa4 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -277,6 +277,10 @@ enum LiveCommand { about = "Launch under DbgEng, set an address/module-RVA/symbol breakpoint, and emit bounded stop context" )] StartupBreak(LiveStartupBreakArgs), + #[command( + about = "Launch .NET under DbgEng, use SOS bpmd for a managed method, and emit bounded hit evidence" + )] + ManagedBreak(LiveManagedBreakArgs), #[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")] @@ -704,6 +708,36 @@ struct LiveStartupBreakArgs { end: String, } +#[derive(Debug, Args)] +struct LiveManagedBreakArgs { + #[arg(long, help = "Full command line to launch under DbgEng")] + command_line: String, + #[arg( + long, + value_name = "PATH", + help = "Path to the x64 SOS debugger extension DLL" + )] + sos: PathBuf, + #[arg( + long, + help = "Managed assembly name without the .dll extension, for example RemoteDesktopManager" + )] + managed_module: String, + #[arg( + long, + help = "Fully qualified managed method name, for example Namespace.Type.Method" + )] + method: String, + #[arg(long, default_value_t = 30000)] + initial_break_timeout_ms: u32, + #[arg(long, default_value_t = 60000)] + 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( diff --git a/crates/windbg-tool/src/cli/dispatch.rs b/crates/windbg-tool/src/cli/dispatch.rs index 2913c2e..af86833 100644 --- a/crates/windbg-tool/src/cli/dispatch.rs +++ b/crates/windbg-tool/src/cli/dispatch.rs @@ -46,6 +46,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::ManagedBreak(args) => platform::run_live_managed_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 3203d40..7ea07e5 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -2,13 +2,14 @@ use anyhow::{bail, ensure, Context}; use serde_json::{json, Value}; use std::env; use std::ffi::OsString; +use std::fs; use std::os::windows::ffi::OsStrExt; use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; use std::sync::mpsc; use std::thread; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use windbg_dbgeng::{ launch_live_session, live_launch_initial_break, open_dump_session, start_process_server, write_process_dump, BreakpointInfo, DebuggerSession, DumpKind, DumpOpenOptions, @@ -29,8 +30,8 @@ use windows::Win32::System::Threading::{ use super::output::{print_value, OutputOptions}; use super::{ CliDumpKind, DbgEngServerArgs, DumpCreateArgs, DumpInspectArgs, LiveLaunchArgs, - LiveStartupBreakArgs, TraceRecordArgs, TraceRecordProfile, TraceReplayCpuSupport, - WindbgCommand, + LiveManagedBreakArgs, LiveStartupBreakArgs, TraceRecordArgs, TraceRecordProfile, + TraceReplayCpuSupport, WindbgCommand, }; pub(super) fn run_dbgeng_server( @@ -95,27 +96,7 @@ pub(super) fn run_live_startup_break( .unwrap_or_else(|| session.execution_status()); 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 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 context = live_stop_context(&session, registers, args.max_frames)?; let configured_breakpoint = match requested_breakpoint.as_ref() { Some(requested) => session .list_breakpoints()? @@ -152,14 +133,123 @@ pub(super) fn run_live_startup_break( "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": stack + "context": context, + "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"), + } +} + +pub(super) fn run_live_managed_break( + args: LiveManagedBreakArgs, + output: &OutputOptions, +) -> anyhow::Result<()> { + const RUNTIME_ENTRY_SYMBOL: &str = "coreclr!coreclr_execute_assembly"; + + let end = parse_live_launch_end(&args.end)?; + let managed_module = + validate_managed_breakpoint_token(&args.managed_module, "--managed-module")?; + let method = validate_managed_breakpoint_token(&args.method, "--method")?; + let sos_path = args + .sos + .canonicalize() + .with_context(|| format!("resolving SOS extension {}", args.sos.display()))?; + ensure!(sos_path.is_file(), "--sos must identify a file"); + let sos_command_path = dbgeng_command_path(&sos_path)?; + 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 runtime_entry_breakpoint = + session.add_code_breakpoint_expression(RUNTIME_ENTRY_SYMBOL)?; + let continued_to_runtime = session.continue_execution()?; + let runtime_wait = session + .wait_for_event(args.wait_timeout_ms) + .context("waiting for the CoreCLR runtime-entry breakpoint")?; + let runtime_event = session + .last_event() + .context("reading the CoreCLR runtime-entry event")?; + let runtime_hit = runtime_wait.name.as_deref() == Some("break") + && runtime_event.event_name == "breakpoint" + && runtime_event.breakpoint_id == Some(runtime_entry_breakpoint.id); + ensure!( + runtime_hit, + "DbgEng did not stop at the CoreCLR runtime-entry breakpoint before configuring SOS" + ); + + let load_sos_command = format!(r#".load "{sos_command_path}""#); + let set_managed_breakpoint_command = format!("!bpmd {managed_module} {method}"); + let sos_output = execute_sos_breakpoint_commands( + &session, + &load_sos_command, + &set_managed_breakpoint_command, + )?; + let sos_output_excerpt = sos_output["text"] + .as_str() + .unwrap_or_default() + .chars() + .take(2048) + .collect::(); + + let continued_to_managed = session.continue_execution()?; + let managed_wait = session + .wait_for_event(args.wait_timeout_ms) + .with_context(|| { + format!("waiting for the SOS managed breakpoint; SOS output: {sos_output_excerpt}") + })?; + let managed_event = session + .last_event() + .context("reading the SOS managed-breakpoint event")?; + let managed_hit = managed_wait.name.as_deref() == Some("break") + && managed_event.event_name == "breakpoint" + && managed_event.breakpoint_id != Some(runtime_entry_breakpoint.id); + ensure!( + managed_hit, + "SOS bpmd did not produce a distinct managed breakpoint event for {managed_module}!{method}" + ); + + let registers = session.core_registers()?; + let context = live_stop_context(&session, registers, args.max_frames)?; + Ok(json!({ + "workflow": "live_managed_break", + "command_line": args.command_line, + "initial_event": initial_event, + "runtime_entry_breakpoint": { + "expression": RUNTIME_ENTRY_SYMBOL, + "configured": runtime_entry_breakpoint, + "continued": continued_to_runtime, + "wait": runtime_wait, + "event": runtime_event, + "hit": runtime_hit, + "hit_evidence": "DbgEng breakpoint event ID matches the CoreCLR runtime-entry breakpoint" + }, + "managed_breakpoint": { + "kind": "sos_bpmd", + "sos_path": sos_path, + "managed_module": managed_module, + "method": method, + "load_command": load_sos_command, + "set_command": set_managed_breakpoint_command, + "output": sos_output, + "continued": continued_to_managed, + "wait": managed_wait, + "event": managed_event, + "hit": managed_hit, + "hit_evidence": "SOS bpmd was configured after CoreCLR loaded and DbgEng reported a distinct breakpoint event" }, + "context": context, "end": end })) })(); @@ -175,6 +265,133 @@ pub(super) fn run_live_startup_break( } } +fn live_stop_context( + session: &DebuggerSession, + registers: windbg_dbgeng::CoreRegisterState, + max_frames: u32, +) -> anyhow::Result { + 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 stack = match session.stack_trace(max_frames) { + Ok(frames) => json!({ + "status": "ok", + "frames": frames, + "frame_limit": max_frames + }), + Err(error) => json!({ + "status": "error", + "error": error.to_string(), + "frames": [], + "frame_limit": max_frames + }), + }; + Ok(json!({ + "target": session.summary(), + "registers": registers, + "instruction_pointer": instruction_offset, + "current_module": current_module, + "current_symbol": current_symbol, + "stack": stack + })) +} + +fn validate_managed_breakpoint_token(value: &str, argument: &str) -> anyhow::Result { + let value = value.trim(); + ensure!(!value.is_empty(), "{argument} must not be empty"); + ensure!( + value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '+' | '$' | '`') + }), + "{argument} contains unsupported characters" + ); + Ok(value.to_string()) +} + +fn dbgeng_command_path(path: &Path) -> anyhow::Result { + let path = path.to_string_lossy(); + let path = path + .strip_prefix(r"\\?\UNC\") + .map(|value| format!(r"\\{value}")) + .or_else(|| path.strip_prefix(r"\\?\").map(str::to_string)) + .unwrap_or_else(|| path.into_owned()); + let path = path.replace('\\', "/"); + ensure!( + !path.contains('"'), + "DbgEng command paths cannot contain quotes" + ); + Ok(path) +} + +fn execute_sos_breakpoint_commands( + session: &DebuggerSession, + load_sos_command: &str, + set_managed_breakpoint_command: &str, +) -> anyhow::Result { + const MAX_SOS_OUTPUT_BYTES: usize = 16 * 1024; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("reading the system clock for the SOS output log")? + .as_nanos(); + let log_path = env::temp_dir().join(format!( + "windbg-tool-sos-{}-{timestamp}.log", + std::process::id() + )); + let open_log_command = format!(r#".logopen "{}""#, log_path.display()); + session + .execute_command(&open_log_command) + .context("opening the bounded SOS command-output log")?; + + let command_result = (|| { + session + .execute_command(load_sos_command) + .context("loading the SOS debugger extension")?; + session + .execute_command(set_managed_breakpoint_command) + .context("configuring the SOS managed breakpoint") + })(); + let close_result = session + .execute_command(".logclose") + .context("closing the SOS command-output log"); + let output_result = fs::read(&log_path) + .with_context(|| format!("reading SOS command output {}", log_path.display())); + let remove_result = fs::remove_file(&log_path) + .with_context(|| format!("removing SOS command output {}", log_path.display())); + + if let Err(command_error) = command_result { + let cleanup_failures = [close_result.err(), output_result.err(), remove_result.err()] + .into_iter() + .flatten() + .map(|error| error.to_string()) + .collect::>(); + if cleanup_failures.is_empty() { + return Err(command_error); + } + return Err(command_error).context(format!( + "SOS command cleanup also failed: {}", + cleanup_failures.join("; ") + )); + } + close_result?; + let output = output_result?; + remove_result?; + let truncated = output.len() > MAX_SOS_OUTPUT_BYTES; + let output = + String::from_utf8_lossy(&output[..output.len().min(MAX_SOS_OUTPUT_BYTES)]).into_owned(); + Ok(json!({ + "text": output, + "byte_limit": MAX_SOS_OUTPUT_BYTES, + "truncated": truncated + })) +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] enum StartupBreakpointSpec { @@ -1580,4 +1797,34 @@ mod tests { StartupBreakpointSpec::InitialBreak ); } + + #[test] + fn validates_managed_breakpoint_metadata_tokens() { + assert_eq!( + validate_managed_breakpoint_token( + "Devolutions.RemoteDesktopManager.Program.Main", + "--method" + ) + .unwrap(), + "Devolutions.RemoteDesktopManager.Program.Main" + ); + assert_eq!( + validate_managed_breakpoint_token("Outer+Inner.Method`1", "--method").unwrap(), + "Outer+Inner.Method`1" + ); + assert!(validate_managed_breakpoint_token("Program.Main;qd", "--method").is_err()); + assert!(validate_managed_breakpoint_token(r#"Program.Main""#, "--method").is_err()); + } + + #[test] + fn normalizes_verbatim_paths_for_dbgeng_commands() { + assert_eq!( + dbgeng_command_path(Path::new(r"\\?\C:\tools\sos.dll")).unwrap(), + "C:/tools/sos.dll" + ); + assert_eq!( + dbgeng_command_path(Path::new(r"\\?\UNC\server\share\sos.dll")).unwrap(), + "//server/share/sos.dll" + ); + } } diff --git a/docs/cli.md b/docs/cli.md index 8c8a391..86b43cb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -92,6 +92,8 @@ sudo config --enable disableInput `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. +For a development build with DbgEng staged outside the executable directory, set `WINDBG_DBGENG_RUNTIME_DIR` to the matching `dbgeng-runtime` directory. Packaged builds load the matching DbgEng runtime copied beside `windbg-tool.exe`; both paths use a process-local, dependency-safe loader. + Use a module basename and RVA when the executable is present at the initial break: ```powershell @@ -109,6 +111,23 @@ 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. +## Managed SOS breakpoints + +`live managed-break` uses DbgEng and the supported SOS `bpmd` command to stop on a managed method. It first stops at the native `coreclr!coreclr_execute_assembly` export so CoreCLR is loaded, loads the explicit x64 SOS extension, configures `!bpmd`, then requires a distinct DbgEng breakpoint event before reporting `managed_breakpoint.hit: true`. The response keeps the initial event, the native CoreCLR setup breakpoint, and the managed event separate. + +Install the current Microsoft debugger extension with `dotnet tool install --global dotnet-debugger-extensions`, then `dotnet-debugger-extensions install --architecture X64`, or provide an equivalent compatible x64 `sos.dll`: + +```powershell +target\debug\windbg-tool.exe --compact live managed-break ` + --command-line '"C:\apps\RemoteDesktopManager_x64.exe" /AutoCloseAfter:10' ` + --sos "$env:USERPROFILE\.dotnet\sos\sos.dll" ` + --managed-module RemoteDesktopManager ` + --method Devolutions.RemoteDesktopManager.Program.Main ` + --end terminate +``` + +The managed assembly and method inputs are intentionally limited to managed metadata-name characters; this prevents a generated SOS command from accepting additional debugger commands. + ## 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. diff --git a/docs/development.md b/docs/development.md index 36a2967..98026c6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -61,6 +61,15 @@ Use `--arch arm64` with `--target aarch64-pc-windows-msvc` for the Windows ARM64 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. +For a development build that uses architecture-specific staged DbgEng DLLs, set the process-local runtime directory before using a live DbgEng command: + +```powershell +$env:WINDBG_DBGENG_RUNTIME_DIR = (Resolve-Path target\runtime\amd64\dbgeng-runtime) +target\debug\windbg-tool.exe live capabilities +``` + +The live backend securely preloads `dbgeng.dll` from this directory (or from beside `windbg-tool.exe` in a package) so its dependent DLLs resolve from the matching runtime set. It does not alter global DLL-search or security policy. + 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. ### Publishing a GitHub Release From 37d30cb9990f369b5aab5d909645d68a8569a2d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Jul 2026 10:30:42 -0400 Subject: [PATCH 02/12] Add direct CoreCLR DAC breakpoints Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + crates/windbg-dbgeng/Cargo.toml | 1 + crates/windbg-dbgeng/src/coreclr_dac.rs | 393 ++++++++ crates/windbg-dbgeng/src/lib.rs | 114 ++- crates/windbg-tool/src/cli.rs | 22 +- crates/windbg-tool/src/cli/platform.rs | 453 ++++++--- crates/windbg-ttd/src/targets.rs | 7 +- docs/architecture.md | 4 +- docs/cli.md | 14 +- docs/development.md | 7 +- native/coreclr-dac-bridge/CMakeLists.txt | 40 + .../windbg_coreclr_dac_bridge.cpp | 896 ++++++++++++++++++ .../windbg_coreclr_dac_bridge.h | 78 ++ xtask/src/main.rs | 65 ++ 14 files changed, 1903 insertions(+), 192 deletions(-) create mode 100644 crates/windbg-dbgeng/src/coreclr_dac.rs create mode 100644 native/coreclr-dac-bridge/CMakeLists.txt create mode 100644 native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.cpp create mode 100644 native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h diff --git a/Cargo.lock b/Cargo.lock index 26b205d..f50c44e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1791,6 +1791,7 @@ name = "windbg-dbgeng" version = "0.1.0" dependencies = [ "anyhow", + "libloading", "serde", "windows", ] diff --git a/crates/windbg-dbgeng/Cargo.toml b/crates/windbg-dbgeng/Cargo.toml index 921992f..6419158 100644 --- a/crates/windbg-dbgeng/Cargo.toml +++ b/crates/windbg-dbgeng/Cargo.toml @@ -9,6 +9,7 @@ anyhow.workspace = true serde.workspace = true [target.'cfg(windows)'.dependencies] +libloading.workspace = true windows = { workspace = true, features = [ "Win32_Foundation", "Win32_Storage_FileSystem", diff --git a/crates/windbg-dbgeng/src/coreclr_dac.rs b/crates/windbg-dbgeng/src/coreclr_dac.rs new file mode 100644 index 0000000..1e5cd0d --- /dev/null +++ b/crates/windbg-dbgeng/src/coreclr_dac.rs @@ -0,0 +1,393 @@ +use std::{ + env, + ffi::{c_void, OsStr}, + mem, + path::{Path, PathBuf}, + ptr::NonNull, +}; + +use anyhow::{bail, ensure, Context}; +use libloading::Library; +use serde::Serialize; +use windows::core::Interface; + +use crate::DebuggerSession; + +const BRIDGE_DLL_NAME: &str = "windbg_coreclr_dac_bridge.dll"; +const BRIDGE_DLL_ENV: &str = "WINDBG_CORECLR_DAC_BRIDGE_DLL"; +const WINDBG_DAC_OK: u32 = 0; +const WINDBG_DAC_NOT_FOUND: u32 = 3; +const WINDBG_DAC_AMBIGUOUS: u32 = 4; +const WINDBG_DAC_CODE_UNAVAILABLE: u32 = 5; +const MAX_WIDE_CHARS: usize = 1024; + +#[repr(C)] +#[derive(Clone, Copy)] +struct NativeRuntimeInfo { + coreclr_path: [u16; MAX_WIDE_CHARS], + dac_path: [u16; MAX_WIDE_CHARS], + coreclr_version_ms: u32, + coreclr_version_ls: u32, + dac_version_ms: u32, + dac_version_ls: u32, +} + +impl Default for NativeRuntimeInfo { + fn default() -> Self { + // The C ABI consists entirely of integer fields and fixed UTF-16 buffers. + unsafe { mem::zeroed() } + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct NativeMethodInfo { + method_token: u32, + matching_method_count: u32, + representative_entry_address: u64, + code_notification_flags: u32, + code_available: u8, + reserved: [u8; 3], + resolved_method: [u16; MAX_WIDE_CHARS], +} + +impl Default for NativeMethodInfo { + fn default() -> Self { + // The C ABI consists entirely of integer fields and a fixed UTF-16 buffer. + unsafe { mem::zeroed() } + } +} + +type CreateBridge = unsafe extern "C" fn( + debug_client: *mut c_void, + coreclr_path: *const u16, + allow_target_writes: u8, + bridge: *mut *mut c_void, + runtime_info: *mut NativeRuntimeInfo, +) -> u32; +type DestroyBridge = unsafe extern "C" fn(bridge: *mut c_void); +type EnableModuleLoadNotifications = unsafe extern "C" fn(bridge: *mut c_void) -> u32; +type IsModuleLoaded = unsafe extern "C" fn( + bridge: *mut c_void, + managed_module_path: *const u16, + loaded: *mut u8, +) -> u32; +type ResolveAndNotify = unsafe extern "C" fn( + bridge: *mut c_void, + managed_module_path: *const u16, + fully_qualified_method: *const u16, + method_info: *mut NativeMethodInfo, +) -> u32; +type RefreshMethodCode = + unsafe extern "C" fn(bridge: *mut c_void, method_info: *mut NativeMethodInfo) -> u32; +type LastError = unsafe extern "C" fn() -> *const u16; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManagedRuntimeInfo { + pub coreclr_path: PathBuf, + pub dac_path: PathBuf, + pub coreclr_file_version: (u32, u32), + pub dac_file_version: (u32, u32), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManagedMethodInfo { + pub token: u32, + pub matching_method_count: u32, + pub resolved_method: String, + pub code_notification_flags: u32, + pub representative_entry_address: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManagedCodeAvailability { + Available, + PendingJit, +} + +pub struct CoreClrDacBridge { + _library: Library, + bridge: NonNull, + destroy: DestroyBridge, + enable_module_load_notifications: EnableModuleLoadNotifications, + is_module_loaded: IsModuleLoaded, + resolve_and_notify: ResolveAndNotify, + refresh_method_code: RefreshMethodCode, + last_error: LastError, + runtime_info: ManagedRuntimeInfo, +} + +impl CoreClrDacBridge { + pub fn open( + session: &DebuggerSession, + coreclr_path: &Path, + allow_target_writes: bool, + ) -> anyhow::Result { + ensure!( + coreclr_path.is_file(), + "the selected CoreCLR module path does not exist: {}", + coreclr_path.display() + ); + + let library_path = bridge_dll_path()?; + let library = unsafe { Library::new(&library_path) }.with_context(|| { + format!( + "loading the CoreCLR DAC bridge from {}", + library_path.display() + ) + })?; + + let create = unsafe { load_symbol::(&library, b"windbg_dac_create\0")? }; + let destroy = unsafe { load_symbol::(&library, b"windbg_dac_destroy\0")? }; + let enable_module_load_notifications = unsafe { + load_symbol::( + &library, + b"windbg_dac_enable_module_load_notifications\0", + )? + }; + let is_module_loaded = + unsafe { load_symbol::(&library, b"windbg_dac_is_module_loaded\0")? }; + let resolve_and_notify = unsafe { + load_symbol::(&library, b"windbg_dac_resolve_and_notify\0")? + }; + let refresh_method_code = unsafe { + load_symbol::(&library, b"windbg_dac_refresh_method_code\0")? + }; + let last_error = unsafe { load_symbol::(&library, b"windbg_dac_last_error\0")? }; + + let coreclr_path = to_wide(coreclr_path.as_os_str())?; + let mut raw_bridge = std::ptr::null_mut(); + let mut native_runtime = NativeRuntimeInfo::default(); + let status = unsafe { + create( + session.client.as_raw(), + coreclr_path.as_ptr(), + u8::from(allow_target_writes), + &mut raw_bridge, + &mut native_runtime, + ) + }; + if status != WINDBG_DAC_OK { + bail!( + "initializing the CoreCLR DAC bridge failed: {}", + bridge_error(last_error) + ); + } + let bridge = NonNull::new(raw_bridge) + .context("the CoreCLR DAC bridge reported success without returning a bridge handle")?; + + Ok(Self { + _library: library, + bridge, + destroy, + enable_module_load_notifications, + is_module_loaded, + resolve_and_notify, + refresh_method_code, + last_error, + runtime_info: managed_runtime_info(&native_runtime)?, + }) + } + + pub fn runtime_info(&self) -> &ManagedRuntimeInfo { + &self.runtime_info + } + + pub fn enable_module_load_notifications(&self) -> anyhow::Result<()> { + let status = unsafe { (self.enable_module_load_notifications)(self.bridge.as_ptr()) }; + if status != WINDBG_DAC_OK { + bail!( + "requesting CLR managed-module load notifications failed: {}", + bridge_error(self.last_error) + ); + } + Ok(()) + } + + pub fn is_module_loaded(&self, managed_module_path: &Path) -> anyhow::Result { + let managed_module_path = to_wide(managed_module_path.as_os_str())?; + let mut loaded = 0u8; + let status = unsafe { + (self.is_module_loaded)( + self.bridge.as_ptr(), + managed_module_path.as_ptr(), + &mut loaded, + ) + }; + if status != WINDBG_DAC_OK { + bail!( + "checking whether the managed module is available through the DAC failed: {}", + bridge_error(self.last_error) + ); + } + Ok(loaded != 0) + } + + pub fn resolve_and_notify( + &mut self, + managed_module_path: &Path, + fully_qualified_method: &str, + ) -> anyhow::Result<(ManagedMethodInfo, ManagedCodeAvailability)> { + let managed_module_path = to_wide(managed_module_path.as_os_str())?; + let method = to_wide(OsStr::new(fully_qualified_method))?; + let mut native_method = NativeMethodInfo::default(); + let status = unsafe { + (self.resolve_and_notify)( + self.bridge.as_ptr(), + managed_module_path.as_ptr(), + method.as_ptr(), + &mut native_method, + ) + }; + self.handle_method_status(status, &native_method, "resolving the managed method")?; + let availability = if native_method.code_available == 0 { + ManagedCodeAvailability::PendingJit + } else { + ManagedCodeAvailability::Available + }; + Ok((managed_method_info(&native_method)?, availability)) + } + + pub fn refresh_method_code(&self) -> anyhow::Result { + let mut native_method = NativeMethodInfo::default(); + let status = + unsafe { (self.refresh_method_code)(self.bridge.as_ptr(), &mut native_method) }; + self.handle_method_status(status, &native_method, "querying generated managed code")?; + managed_method_info(&native_method) + } + + fn handle_method_status( + &self, + status: u32, + native_method: &NativeMethodInfo, + operation: &str, + ) -> anyhow::Result<()> { + match status { + WINDBG_DAC_OK => Ok(()), + WINDBG_DAC_NOT_FOUND => bail!("{operation} failed: {}", bridge_error(self.last_error)), + WINDBG_DAC_AMBIGUOUS => bail!( + "{operation} failed because {} method definitions matched; exact signature selection is required", + native_method.matching_method_count + ), + WINDBG_DAC_CODE_UNAVAILABLE => { + bail!("{operation} failed: {}", bridge_error(self.last_error)) + } + _ => bail!("{operation} failed: {}", bridge_error(self.last_error)), + } + } +} + +impl Drop for CoreClrDacBridge { + fn drop(&mut self) { + unsafe { + (self.destroy)(self.bridge.as_ptr()); + } + } +} + +fn bridge_dll_path() -> anyhow::Result { + if let Some(path) = env::var_os(BRIDGE_DLL_ENV).map(PathBuf::from) { + ensure!( + path.is_file(), + "{BRIDGE_DLL_ENV} must name the CoreCLR DAC bridge DLL: {}", + path.display() + ); + return Ok(path); + } + + let executable_path = env::current_exe().context("locating the windbg-tool executable")?; + let candidate = executable_path + .parent() + .context("the windbg-tool executable has no parent directory")? + .join(BRIDGE_DLL_NAME); + ensure!( + candidate.is_file(), + "the CoreCLR DAC bridge is unavailable at {}. Run `cargo xtask native-build` and stage {} beside windbg-tool.exe, or set {BRIDGE_DLL_ENV}.", + candidate.display(), + BRIDGE_DLL_NAME + ); + Ok(candidate) +} + +unsafe fn load_symbol(library: &Library, symbol: &[u8]) -> anyhow::Result { + Ok(*unsafe { library.get::(symbol) } + .with_context(|| format!("loading bridge export {}", String::from_utf8_lossy(symbol)))?) +} + +fn to_wide(value: impl AsRef) -> anyhow::Result> { + use std::os::windows::ffi::OsStrExt; + + let mut wide = value.as_ref().encode_wide().collect::>(); + ensure!( + !wide.contains(&0), + "the CoreCLR DAC bridge input must not contain an embedded NUL" + ); + wide.push(0); + Ok(wide) +} + +fn bridge_error(last_error: LastError) -> String { + let pointer = unsafe { last_error() }; + if pointer.is_null() { + return "the native bridge did not provide an error message".to_string(); + } + + let mut length = 0usize; + while length < MAX_WIDE_CHARS { + if unsafe { *pointer.add(length) } == 0 { + break; + } + length += 1; + } + String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(pointer, length) }) +} + +fn managed_runtime_info(native: &NativeRuntimeInfo) -> anyhow::Result { + Ok(ManagedRuntimeInfo { + coreclr_path: PathBuf::from(utf16_field(&native.coreclr_path, "CoreCLR path")?), + dac_path: PathBuf::from(utf16_field(&native.dac_path, "DAC path")?), + coreclr_file_version: (native.coreclr_version_ms, native.coreclr_version_ls), + dac_file_version: (native.dac_version_ms, native.dac_version_ls), + }) +} + +fn managed_method_info(native: &NativeMethodInfo) -> anyhow::Result { + Ok(ManagedMethodInfo { + token: native.method_token, + matching_method_count: native.matching_method_count, + resolved_method: utf16_field(&native.resolved_method, "managed method name")?, + code_notification_flags: native.code_notification_flags, + representative_entry_address: (native.code_available != 0) + .then_some(native.representative_entry_address) + .filter(|address| *address != 0), + }) +} + +fn utf16_field(value: &[u16], name: &str) -> anyhow::Result { + let length = value + .iter() + .position(|character| *character == 0) + .context(format!( + "{name} from the native bridge was not NUL terminated" + ))?; + String::from_utf16(&value[..length]) + .with_context(|| format!("{name} from the native bridge was invalid UTF-16")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_nul_terminated_utf16() { + let mut value = [0u16; 8]; + value[..4].copy_from_slice(&['t' as u16, 'e' as u16, 's' as u16, 't' as u16]); + + assert_eq!(utf16_field(&value, "test").unwrap(), "test"); + } + + #[test] + fn rejects_unterminated_utf16() { + assert!(utf16_field(&[1u16; 4], "test").is_err()); + } +} diff --git a/crates/windbg-dbgeng/src/lib.rs b/crates/windbg-dbgeng/src/lib.rs index b44759c..4640335 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -8,6 +8,13 @@ use std::{ sync::OnceLock, }; +#[cfg(windows)] +mod coreclr_dac; +#[cfg(windows)] +pub use coreclr_dac::{ + CoreClrDacBridge, ManagedCodeAvailability, ManagedMethodInfo, ManagedRuntimeInfo, +}; + 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"; @@ -125,31 +132,29 @@ fn ensure_dbgeng_runtime_loaded() -> anyhow::Result<()> { static LOAD_RESULT: OnceLock> = OnceLock::new(); let result = LOAD_RESULT.get_or_init(|| { - let explicit_runtime_dir = env::var_os(DBGENG_RUNTIME_DIR_ENV).map(PathBuf::from); - let executable_path = env::current_exe().ok(); - let dll = - match dbgeng_runtime_dll(explicit_runtime_dir.as_deref(), executable_path.as_deref()) { - Ok(dll) => dll, - Err(error) => return Err(error.to_string()), + (|| -> anyhow::Result<()> { + let explicit_runtime_dir = env::var_os(DBGENG_RUNTIME_DIR_ENV).map(PathBuf::from); + let executable_path = env::current_exe().ok(); + let dll = + dbgeng_runtime_dll(explicit_runtime_dir.as_deref(), executable_path.as_deref())?; + let Some(dll) = dll else { + return Ok(()); }; - let Some(dll) = dll else { - return Ok(()); - }; - let mut dll_wide = dll.as_os_str().encode_wide().collect::>(); - dll_wide.push(0); - - // Keep the explicitly loaded module alive so DbgEng resolves all of its runtime dependencies - // from its own directory before DebugCreate is invoked. - unsafe { - LoadLibraryExW( - PCWSTR(dll_wide.as_ptr()), - None, - LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, - ) - } - .with_context(|| format!("loading DbgEng runtime {}", dll.display())) - .map(|_| ()) - .map_err(|error| error.to_string()) + let mut dll_wide = dll.as_os_str().encode_wide().collect::>(); + dll_wide.push(0); + // Keep the explicitly loaded module alive so DbgEng resolves all of its runtime + // dependencies from its own directory before DebugCreate is invoked. + unsafe { + LoadLibraryExW( + PCWSTR(dll_wide.as_ptr()), + None, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, + ) + } + .with_context(|| format!("loading DbgEng runtime {}", dll.display()))?; + Ok(()) + })() + .map_err(|error| format!("{error:#}")) }); if let Err(error) = result { @@ -158,6 +163,27 @@ fn ensure_dbgeng_runtime_loaded() -> anyhow::Result<()> { Ok(()) } +#[cfg(windows)] +fn enable_create_process_stop( + control: &windows::Win32::System::Diagnostics::Debug::Extensions::IDebugControl5, +) -> anyhow::Result<()> { + use windows::core::PCWSTR; + use windows::Win32::System::Diagnostics::Debug::Extensions::{ + DEBUG_EXECUTE_DEFAULT, DEBUG_OUTCTL_THIS_CLIENT, + }; + + let command = "sxe cpr\0".encode_utf16().collect::>(); + unsafe { + control.ExecuteWide( + DEBUG_OUTCTL_THIS_CLIENT, + PCWSTR(command.as_ptr()), + DEBUG_EXECUTE_DEFAULT, + ) + } + .context("configuring DbgEng to stop on the create-process debug event")?; + Ok(()) +} + #[cfg(windows)] fn create_debug_client( ) -> anyhow::Result { @@ -221,6 +247,13 @@ pub struct LiveLaunchOptions { pub struct LiveLaunchSessionOptions { pub command_line: String, pub initial_break_timeout_ms: u32, + pub initial_stop: LiveInitialStop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LiveInitialStop { + SoftwareBreakpoint, + CreateProcessEvent, } #[derive(Debug, Clone)] @@ -514,6 +547,14 @@ impl DebuggerSession { } } + pub fn open_coreclr_dac_bridge( + &self, + coreclr_path: &Path, + allow_target_writes: bool, + ) -> anyhow::Result { + CoreClrDacBridge::open(self, coreclr_path, allow_target_writes) + } + pub fn wait_for_event(&self, timeout_ms: u32) -> anyhow::Result { use windows::Win32::System::Diagnostics::Debug::Extensions::{ DEBUG_STATUS_TIMEOUT, DEBUG_WAIT_DEFAULT, @@ -542,6 +583,15 @@ impl DebuggerSession { Ok(self.execution_status()) } + pub fn continue_execution_handled(&self) -> anyhow::Result { + use windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_STATUS_GO_HANDLED; + + unsafe { + self.control.SetExecutionStatus(DEBUG_STATUS_GO_HANDLED)?; + } + Ok(self.execution_status()) + } + pub fn step_into(&self) -> anyhow::Result { use windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_STATUS_STEP_INTO; @@ -1369,6 +1419,10 @@ impl DebuggerSession { anyhow::bail!("DbgEng sessions are only supported on Windows") } + pub fn continue_execution_handled(&self) -> anyhow::Result { + anyhow::bail!("DbgEng sessions are only supported on Windows") + } + pub fn add_code_breakpoint_expression( &self, _expression: &str, @@ -1455,6 +1509,7 @@ fn live_launch_initial_break_impl(options: LiveLaunchOptions) -> anyhow::Result< let session = launch_live_session_impl(LiveLaunchSessionOptions { command_line: options.command_line.clone(), initial_break_timeout_ms: options.initial_break_timeout_ms, + initial_stop: LiveInitialStop::SoftwareBreakpoint, })?; let execution_status = session.execution_status(); let symbol_path = session.symbol_path.clone(); @@ -1493,7 +1548,10 @@ fn launch_live_session_impl(options: LiveLaunchSessionOptions) -> anyhow::Result let system_objects: IDebugSystemObjects = client.cast().context("querying IDebugSystemObjects")?; let symbol_path = configure_dbgeng_symbol_path(&symbols)?; - enable_initial_break(&control)?; + match options.initial_stop { + LiveInitialStop::SoftwareBreakpoint => enable_initial_break(&control)?, + LiveInitialStop::CreateProcessEvent => enable_create_process_stop(&control)?, + } unsafe { // DbgEng can mutate the command buffer; command_line is owned and remains live for the call. client @@ -1504,7 +1562,11 @@ fn launch_live_session_impl(options: LiveLaunchSessionOptions) -> anyhow::Result ) .context("DbgEng CreateProcessWide failed")?; } - wait_for_initial_event(&control, options.initial_break_timeout_ms, "launch")?; + let initial_stop = match options.initial_stop { + LiveInitialStop::SoftwareBreakpoint => "software initial-break", + LiveInitialStop::CreateProcessEvent => "create-process", + }; + wait_for_initial_event(&control, options.initial_break_timeout_ms, initial_stop)?; Ok(DebuggerSession { kind: DebuggerSessionKind::Live, diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index a56afa4..9930986 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -278,7 +278,7 @@ enum LiveCommand { )] StartupBreak(LiveStartupBreakArgs), #[command( - about = "Launch .NET under DbgEng, use SOS bpmd for a managed method, and emit bounded hit evidence" + about = "Launch .NET under DbgEng, bind the matching CoreCLR DAC, and emit a managed method hit" )] ManagedBreak(LiveManagedBreakArgs), #[command(about = "Launch a process under DbgEng and keep it as a daemon-owned live target")] @@ -698,6 +698,12 @@ struct LiveStartupBreakArgs { help = "DbgEng symbol expression; remains deferred until its module and symbol resolve" )] symbol: Option, + #[arg( + long, + value_name = "MODULE", + help = "Stop on a trusted module-load event before configuring the requested code breakpoint" + )] + wait_for_module: Option, #[arg(long, default_value_t = 5000)] initial_break_timeout_ms: u32, #[arg(long, default_value_t = 10000)] @@ -714,20 +720,20 @@ struct LiveManagedBreakArgs { command_line: String, #[arg( long, - value_name = "PATH", - help = "Path to the x64 SOS debugger extension DLL" + value_name = "MODULE", + help = "Managed assembly module basename, for example RemoteDesktopManager.dll" )] - sos: PathBuf, + managed_module: String, #[arg( long, - help = "Managed assembly name without the .dll extension, for example RemoteDesktopManager" + help = "Fully qualified managed metadata method name, for example Namespace.Type.Method" )] - managed_module: String, + method: String, #[arg( long, - help = "Fully qualified managed method name, for example Namespace.Type.Method" + help = "Allow the matching DAC to write CLR debugger-notification state; use only in an approved test VM" )] - method: String, + allow_runtime_write: bool, #[arg(long, default_value_t = 30000)] initial_break_timeout_ms: u32, #[arg(long, default_value_t = 60000)] diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 7ea07e5..9e62d90 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -2,19 +2,18 @@ use anyhow::{bail, ensure, Context}; use serde_json::{json, Value}; use std::env; use std::ffi::OsString; -use std::fs; use std::os::windows::ffi::OsStrExt; use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; use std::sync::mpsc; use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; use windbg_dbgeng::{ 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, + DumpWriteOptions, LiveInitialStop, LiveLaunchEnd, LiveLaunchOptions, LiveLaunchSessionOptions, + ManagedCodeAvailability, ModuleInfo, ProcessDumpOptions, ProcessServerOptions, }; use windbg_install::WindbgManager; use windows::core::{PCWSTR, PWSTR}; @@ -74,18 +73,47 @@ pub(super) fn run_live_startup_break( ) -> anyhow::Result<()> { let end = parse_live_launch_end(&args.end)?; let breakpoint_spec = startup_breakpoint_spec(&args)?; + let module_load_filter = args + .wait_for_module + .as_deref() + .map(validate_module_load_filter) + .transpose()?; let session = launch_live_session(LiveLaunchSessionOptions { command_line: args.command_line.clone(), initial_break_timeout_ms: args.initial_break_timeout_ms, + initial_stop: LiveInitialStop::SoftwareBreakpoint, })?; let result = (|| { let initial_event = session.summary(); + let module_load = module_load_filter + .as_deref() + .map(|module| { + session + .execute_command(&format!("sxe ld:{module}")) + .with_context(|| format!("configuring DbgEng to stop when {module} loads"))?; + let continued = session.continue_execution()?; + let wait = session.wait_for_event(args.wait_timeout_ms)?; + let event = session + .last_event() + .context("reading the requested DbgEng module-load event")?; + ensure!( + event.event_name == "load_module" && event.module_base.is_some(), + "DbgEng did not stop on the requested {module} module-load event" + ); + Ok::<_, anyhow::Error>(json!({ + "module": module, + "continued": continued, + "wait": wait, + "event": event + })) + }) + .transpose()?; let requested_breakpoint = match breakpoint_spec { StartupBreakpointSpec::InitialBreak => None, _ => Some(set_startup_breakpoint(&session, &breakpoint_spec)?), }; - let continued = requested_breakpoint + let continued_to_breakpoint = requested_breakpoint .is_some() .then(|| session.continue_execution()) .transpose()?; @@ -119,7 +147,8 @@ pub(super) fn run_live_startup_break( "command_line": args.command_line, "breakpoint_spec": breakpoint_spec, "initial_event": initial_event, - "continued": continued, + "module_load": module_load, + "continued_to_breakpoint": continued_to_breakpoint, "event": event, "breakpoint": { "requested": requested_breakpoint, @@ -153,103 +182,206 @@ pub(super) fn run_live_managed_break( args: LiveManagedBreakArgs, output: &OutputOptions, ) -> anyhow::Result<()> { - const RUNTIME_ENTRY_SYMBOL: &str = "coreclr!coreclr_execute_assembly"; + const CORECLR_MODULE: &str = "coreclr.dll"; + const CLR_NOTIFICATION_EXCEPTION: u32 = 0xe044_4143; + const CLR_NOTIFICATION_FILTER: &str = "clrn"; let end = parse_live_launch_end(&args.end)?; - let managed_module = - validate_managed_breakpoint_token(&args.managed_module, "--managed-module")?; + let managed_module = validate_managed_module(&args.managed_module)?; let method = validate_managed_breakpoint_token(&args.method, "--method")?; - let sos_path = args - .sos - .canonicalize() - .with_context(|| format!("resolving SOS extension {}", args.sos.display()))?; - ensure!(sos_path.is_file(), "--sos must identify a file"); - let sos_command_path = dbgeng_command_path(&sos_path)?; let session = launch_live_session(LiveLaunchSessionOptions { command_line: args.command_line.clone(), initial_break_timeout_ms: args.initial_break_timeout_ms, + initial_stop: LiveInitialStop::CreateProcessEvent, })?; let result = (|| { let initial_event = session.summary(); - let runtime_entry_breakpoint = - session.add_code_breakpoint_expression(RUNTIME_ENTRY_SYMBOL)?; - let continued_to_runtime = session.continue_execution()?; - let runtime_wait = session + session + .execute_command(&format!("sxe ld:{CORECLR_MODULE}")) + .context("configuring the non-invasive CoreCLR module-load stop")?; + session + .execute_command(&format!("sxe {CLR_NOTIFICATION_FILTER}")) + .context("configuring CLR code-generation notification handling")?; + + let continued_to_coreclr = session.continue_execution()?; + let coreclr_wait = session .wait_for_event(args.wait_timeout_ms) - .context("waiting for the CoreCLR runtime-entry breakpoint")?; - let runtime_event = session + .context("waiting for CoreCLR to load")?; + let coreclr_event = session .last_event() - .context("reading the CoreCLR runtime-entry event")?; - let runtime_hit = runtime_wait.name.as_deref() == Some("break") - && runtime_event.event_name == "breakpoint" - && runtime_event.breakpoint_id == Some(runtime_entry_breakpoint.id); + .context("reading the CoreCLR module-load event")?; ensure!( - runtime_hit, - "DbgEng did not stop at the CoreCLR runtime-entry breakpoint before configuring SOS" + coreclr_wait.name.as_deref() == Some("break") + && coreclr_event.event_name == "load_module" + && coreclr_event.module_base.is_some(), + "DbgEng did not stop on the requested CoreCLR module-load event" ); - let load_sos_command = format!(r#".load "{sos_command_path}""#); - let set_managed_breakpoint_command = format!("!bpmd {managed_module} {method}"); - let sos_output = execute_sos_breakpoint_commands( - &session, - &load_sos_command, - &set_managed_breakpoint_command, - )?; - let sos_output_excerpt = sos_output["text"] - .as_str() - .unwrap_or_default() - .chars() - .take(2048) - .collect::(); + let modules_after_coreclr_load = session.modules()?; + let coreclr_module = find_loaded_module(&modules_after_coreclr_load, CORECLR_MODULE)?; + let coreclr_path = module_image_path(coreclr_module, "CoreCLR")?; + let mut dac = session + .open_coreclr_dac_bridge(&coreclr_path, args.allow_runtime_write) + .context("initializing the exact-version CoreCLR DAC bridge")?; + dac.enable_module_load_notifications() + .context("requesting CLR managed-module load notifications")?; - let continued_to_managed = session.continue_execution()?; - let managed_wait = session - .wait_for_event(args.wait_timeout_ms) + session + .execute_command(&format!("sxe ld:{managed_module}")) .with_context(|| { - format!("waiting for the SOS managed breakpoint; SOS output: {sos_output_excerpt}") + format!("configuring the managed-module load stop for {managed_module}") })?; - let managed_event = session + let continued_to_managed_module = session.continue_execution()?; + let managed_module_wait = session + .wait_for_event(args.wait_timeout_ms) + .with_context(|| format!("waiting for managed module {managed_module} to load"))?; + let managed_module_event = session .last_event() - .context("reading the SOS managed-breakpoint event")?; - let managed_hit = managed_wait.name.as_deref() == Some("break") - && managed_event.event_name == "breakpoint" - && managed_event.breakpoint_id != Some(runtime_entry_breakpoint.id); + .context("reading the managed module-load event")?; ensure!( - managed_hit, - "SOS bpmd did not produce a distinct managed breakpoint event for {managed_module}!{method}" + managed_module_wait.name.as_deref() == Some("break") + && managed_module_event.event_name == "load_module" + && managed_module_event.module_base.is_some(), + "DbgEng did not stop on the requested managed module-load event" ); + let modules_after_managed_load = session.modules()?; + let loaded_managed_module = + find_loaded_module(&modules_after_managed_load, &managed_module)?; + let managed_module_path = module_image_path(loaded_managed_module, "managed assembly")?; + let managed_module_observation = wait_for_managed_module_in_dac( + &session, + &dac, + &managed_module_path, + args.wait_timeout_ms, + CLR_NOTIFICATION_EXCEPTION, + )?; + let (resolved_method, availability) = dac + .resolve_and_notify(&managed_module_path, &method) + .with_context(|| { + format!( + "resolving {method} in the selected managed module {managed_module} through the DAC" + ) + })?; + let (code_notification, generated_method) = match availability { + ManagedCodeAvailability::Available => (None, resolved_method.clone()), + ManagedCodeAvailability::PendingJit => { + let continued = session.continue_execution()?; + let wait = session + .wait_for_event(args.wait_timeout_ms) + .context("waiting for the requested CLR code-generation notification")?; + let event = session + .last_event() + .context("reading the CLR code-generation notification")?; + ensure!( + wait.name.as_deref() == Some("break") + && event.event_name == "exception" + && event + .exception + .as_ref() + .is_some_and(|exception| exception.code == CLR_NOTIFICATION_EXCEPTION), + "DbgEng did not stop on the requested CLR code-generation notification" + ); + let generated = dac.refresh_method_code().context( + "refreshing the selected method after its CLR code-generation notification", + )?; + ensure!( + generated.representative_entry_address.is_some(), + "the CLR notification did not produce a representative native entry address" + ); + ( + Some(json!({ + "exception_code": format!("0x{CLR_NOTIFICATION_EXCEPTION:08X}"), + "continued": continued, + "wait": wait, + "event": event, + "selected_method_code_available": true + })), + generated, + ) + } + }; + ensure!( + generated_method.token == resolved_method.token, + "the DAC returned generated code for a different MethodDef token" + ); + let native_entry_address = generated_method + .representative_entry_address + .context("the selected managed method has no generated native entry address")?; + let hardware_breakpoint = session.add_data_breakpoint( + native_entry_address, + 1, + windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_BREAK_EXECUTE, + )?; + let continued_to_hardware_breakpoint = if code_notification.is_some() { + session.continue_execution_handled()? + } else { + session.continue_execution()? + }; + let hardware_wait = session + .wait_for_event(args.wait_timeout_ms) + .context("waiting for the managed hardware execute breakpoint")?; + let hardware_event = session + .last_event() + .context("reading the managed hardware execute-breakpoint event")?; let registers = session.core_registers()?; + let hardware_breakpoint_hit = hardware_wait.name.as_deref() == Some("break") + && hardware_event.event_name == "breakpoint" + && hardware_event.breakpoint_id == Some(hardware_breakpoint.id) + && registers.instruction_offset == Some(native_entry_address); let context = live_stop_context(&session, registers, args.max_frames)?; Ok(json!({ - "workflow": "live_managed_break", + "workflow": "live_managed_break_dac", "command_line": args.command_line, "initial_event": initial_event, - "runtime_entry_breakpoint": { - "expression": RUNTIME_ENTRY_SYMBOL, - "configured": runtime_entry_breakpoint, - "continued": continued_to_runtime, - "wait": runtime_wait, - "event": runtime_event, - "hit": runtime_hit, - "hit_evidence": "DbgEng breakpoint event ID matches the CoreCLR runtime-entry breakpoint" + "coreclr_module_load": { + "module": CORECLR_MODULE, + "continued": continued_to_coreclr, + "wait": coreclr_wait, + "event": coreclr_event, + "loaded_module": coreclr_module, + "runtime": dac.runtime_info() }, - "managed_breakpoint": { - "kind": "sos_bpmd", - "sos_path": sos_path, + "managed_module_load": { "managed_module": managed_module, - "method": method, - "load_command": load_sos_command, - "set_command": set_managed_breakpoint_command, - "output": sos_output, - "continued": continued_to_managed, - "wait": managed_wait, - "event": managed_event, - "hit": managed_hit, - "hit_evidence": "SOS bpmd was configured after CoreCLR loaded and DbgEng reported a distinct breakpoint event" + "continued_to_dbgeng_load_event": continued_to_managed_module, + "dbgeng_load_wait": managed_module_wait, + "dbgeng_load_event": managed_module_event, + "observation": managed_module_observation, + "loaded_module": loaded_managed_module + }, + "managed_resolution": { + "method_request": method, + "resolved_method": resolved_method, + "method_after_code_generation": generated_method, + "runtime_writes_explicitly_allowed": args.allow_runtime_write, + "exact_overload_signature_selection": false, + "private_methods_supported": true + }, + "code_generation": { + "notification_filter": CLR_NOTIFICATION_FILTER, + "notification": code_notification, + "representative_native_entry_address": format!("0x{native_entry_address:X}") + }, + "managed_breakpoint": { + "kind": "hardware_execute", + "configured": hardware_breakpoint, + "continued": continued_to_hardware_breakpoint, + "wait": hardware_wait, + "event": hardware_event, + "hit": hardware_breakpoint_hit, + "hit_evidence": if hardware_breakpoint_hit { + "the DbgEng hardware breakpoint event ID and current instruction pointer both match the DAC-mapped native entry for the selected MethodDef" + } else { + "the selected MethodDef was resolved by the DAC, but DbgEng did not report a matching hardware execute breakpoint hit" + } }, "context": context, + "limitations": [ + "This vertical slice requires one unambiguous metadata method name; exact overload signature selection is not yet implemented.", + "The breakpoint covers the DAC representative entry address. Generic instantiations, tiered recompilation, ReadyToRun entry indirection, and re-JIT/unload transitions require additional validation." + ], "end": end })) })(); @@ -265,6 +397,19 @@ pub(super) fn run_live_managed_break( } } +fn validate_module_load_filter(value: &str) -> anyhow::Result { + let value = value.trim(); + ensure!(!value.is_empty(), "--wait-for-module must not be empty"); + ensure!( + value + .chars() + .all(|character| character.is_ascii_alphanumeric() + || matches!(character, '.' | '_' | '-')), + "--wait-for-module must be a module basename" + ); + Ok(value.to_string()) +} + fn live_stop_context( session: &DebuggerSession, registers: windbg_dbgeng::CoreRegisterState, @@ -314,82 +459,94 @@ fn validate_managed_breakpoint_token(value: &str, argument: &str) -> anyhow::Res Ok(value.to_string()) } -fn dbgeng_command_path(path: &Path) -> anyhow::Result { - let path = path.to_string_lossy(); - let path = path - .strip_prefix(r"\\?\UNC\") - .map(|value| format!(r"\\{value}")) - .or_else(|| path.strip_prefix(r"\\?\").map(str::to_string)) - .unwrap_or_else(|| path.into_owned()); - let path = path.replace('\\', "/"); +fn validate_managed_module(value: &str) -> anyhow::Result { + let path = validate_module_load_filter(value)?; ensure!( - !path.contains('"'), - "DbgEng command paths cannot contain quotes" + Path::new(&path) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("dll") || extension.eq_ignore_ascii_case("exe") + }), + "--managed-module must be a managed .dll or .exe basename" ); Ok(path) } -fn execute_sos_breakpoint_commands( +fn module_image_path(module: &ModuleInfo, role: &str) -> anyhow::Result { + [ + module.loaded_image_name.as_deref(), + module.image_name.as_deref(), + ] + .into_iter() + .flatten() + .map(PathBuf::from) + .find(|path| path.is_file()) + .with_context(|| { + format!("DbgEng did not report an accessible {role} image path for the selected module") + }) +} + +fn wait_for_managed_module_in_dac( session: &DebuggerSession, - load_sos_command: &str, - set_managed_breakpoint_command: &str, + dac: &windbg_dbgeng::CoreClrDacBridge, + managed_module_path: &Path, + wait_timeout_ms: u32, + clr_notification_exception: u32, ) -> anyhow::Result { - const MAX_SOS_OUTPUT_BYTES: usize = 16 * 1024; - - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("reading the system clock for the SOS output log")? - .as_nanos(); - let log_path = env::temp_dir().join(format!( - "windbg-tool-sos-{}-{timestamp}.log", - std::process::id() - )); - let open_log_command = format!(r#".logopen "{}""#, log_path.display()); - session - .execute_command(&open_log_command) - .context("opening the bounded SOS command-output log")?; - - let command_result = (|| { - session - .execute_command(load_sos_command) - .context("loading the SOS debugger extension")?; - session - .execute_command(set_managed_breakpoint_command) - .context("configuring the SOS managed breakpoint") - })(); - let close_result = session - .execute_command(".logclose") - .context("closing the SOS command-output log"); - let output_result = fs::read(&log_path) - .with_context(|| format!("reading SOS command output {}", log_path.display())); - let remove_result = fs::remove_file(&log_path) - .with_context(|| format!("removing SOS command output {}", log_path.display())); - - if let Err(command_error) = command_result { - let cleanup_failures = [close_result.err(), output_result.err(), remove_result.err()] - .into_iter() - .flatten() - .map(|error| error.to_string()) - .collect::>(); - if cleanup_failures.is_empty() { - return Err(command_error); + const MAX_CLR_NOTIFICATIONS: usize = 64; + + let deadline = Instant::now() + Duration::from_millis(u64::from(wait_timeout_ms)); + let mut notifications = Vec::new(); + let mut continue_as_handled = false; + for attempt in 1..=MAX_CLR_NOTIFICATIONS { + let remaining = deadline.saturating_duration_since(Instant::now()); + ensure!( + !remaining.is_zero(), + "timed out waiting for the CLR to register the selected managed module" + ); + let remaining_ms = remaining.as_millis().clamp(1, u128::from(u32::MAX)) as u32; + let continued = if continue_as_handled { + session.continue_execution_handled()? + } else { + session.continue_execution()? + }; + let wait = session + .wait_for_event(remaining_ms) + .context("waiting for the next CLR managed-module notification")?; + let event = session + .last_event() + .context("reading the CLR managed-module notification")?; + let is_clr_notification = event.event_name == "exception" + && event + .exception + .as_ref() + .is_some_and(|exception| exception.code == clr_notification_exception); + ensure!( + wait.name.as_deref() == Some("break") && is_clr_notification, + "DbgEng stopped before the CLR registered the selected managed module" + ); + let module_available = dac.is_module_loaded(managed_module_path)?; + notifications.push(json!({ + "attempt": attempt, + "continued": continued, + "wait": wait, + "event": event, + "module_available": module_available + })); + if module_available { + return Ok(json!({ + "notification_exception_code": format!("0x{clr_notification_exception:08X}"), + "notifications": notifications, + "module_available": true + })); } - return Err(command_error).context(format!( - "SOS command cleanup also failed: {}", - cleanup_failures.join("; ") - )); + continue_as_handled = true; } - close_result?; - let output = output_result?; - remove_result?; - let truncated = output.len() > MAX_SOS_OUTPUT_BYTES; - let output = - String::from_utf8_lossy(&output[..output.len().min(MAX_SOS_OUTPUT_BYTES)]).into_owned(); - Ok(json!({ - "text": output, - "byte_limit": MAX_SOS_OUTPUT_BYTES, - "truncated": truncated - })) + + bail!( + "the CLR emitted {MAX_CLR_NOTIFICATIONS} notifications without registering the selected managed module" + ) } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] @@ -1285,6 +1442,11 @@ pub(super) fn live_capabilities() -> Value { "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": "managed method breakpoint workflow", + "status": "x64_coreclr_dac_vertical_slice", + "notes": "Uses a matching CoreCLR DAC through the active DbgEng client, resolves one unambiguous metadata method, requests CLR code generation, and then sets a DbgEng hardware execute breakpoint. CLR notification writes require --allow-runtime-write and an approved test VM." + }, { "feature": "dump creation", "status": "dbghelp_minidump_writer", @@ -1765,6 +1927,7 @@ mod tests { module: Some("target.exe".to_string()), module_offset: Some("0x1000".to_string()), symbol: None, + wait_for_module: None, initial_break_timeout_ms: 5000, wait_timeout_ms: 10000, max_frames: 16, @@ -1817,14 +1980,12 @@ mod tests { } #[test] - fn normalizes_verbatim_paths_for_dbgeng_commands() { - assert_eq!( - dbgeng_command_path(Path::new(r"\\?\C:\tools\sos.dll")).unwrap(), - "C:/tools/sos.dll" - ); + fn validates_managed_module_basename() { assert_eq!( - dbgeng_command_path(Path::new(r"\\?\UNC\server\share\sos.dll")).unwrap(), - "//server/share/sos.dll" + validate_managed_module("RemoteDesktopManager.dll").unwrap(), + "RemoteDesktopManager.dll" ); + assert!(validate_managed_module("RemoteDesktopManager").is_err()); + assert!(validate_managed_module(r#"RemoteDesktopManager.dll;qd"#).is_err()); } } diff --git a/crates/windbg-ttd/src/targets.rs b/crates/windbg-ttd/src/targets.rs index 6e05f59..2b9723b 100644 --- a/crates/windbg-ttd/src/targets.rs +++ b/crates/windbg-ttd/src/targets.rs @@ -7,9 +7,9 @@ use windbg_dbgeng::{ attach_live_session, launch_live_session, open_dump_session, BreakpointInfo, CoreRegisterState, DebuggerEventInfo, DebuggerExecutionStatus, DebuggerSession, DebuggerSessionKind, DebuggerSessionSummary, DisassemblyResult, DumpKind, DumpOpenOptions, DumpWriteOptions, - DumpWriteResult, EvaluationResult, LiveAttachOptions, LiveLaunchSessionOptions, - MemoryReadResult, ModuleInfo, SourceLocation, StackFrameInfo, SymbolInfo, ThreadContext, - ThreadInfo, + DumpWriteResult, EvaluationResult, LiveAttachOptions, LiveInitialStop, + LiveLaunchSessionOptions, MemoryReadResult, ModuleInfo, SourceLocation, StackFrameInfo, + SymbolInfo, ThreadContext, ThreadInfo, }; pub type TargetId = u64; @@ -292,6 +292,7 @@ impl TargetRegistry { let session = launch_live_session(LiveLaunchSessionOptions { command_line: request.command_line, initial_break_timeout_ms: request.initial_break_timeout_ms, + initial_stop: LiveInitialStop::SoftwareBreakpoint, })?; Ok(self.insert_target(session)) } diff --git a/docs/architecture.md b/docs/architecture.md index 7a99ba2..5f49566 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,9 @@ 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`. 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. +The native replay 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. + +Live managed breakpoints use a separate [CoreCLR DAC bridge](../native/coreclr-dac-bridge/CMakeLists.txt), never the TTD bridge. It is a narrow C ABI that receives the active DbgEng client, hosts a version- and architecture-matched `mscordaccore.dll`, and implements `ICLRDataTarget` using DbgEng module, memory, thread, and context services. It exposes only opaque handles and POD output for module/method resolution, CLR notification registration, and representative generated-code addresses. It does not attach `ICorDebug`, load SOS, invoke debugger extension commands, or inject managed code. The x64 bridge is staged separately under `target/native/coreclr-dac-bridge`; the target DAC remains runtime-provided and is not redistributed. ## Symbols diff --git a/docs/cli.md b/docs/cli.md index 86b43cb..fe8053b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -111,22 +111,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. -## Managed SOS breakpoints +## Managed CoreCLR DAC breakpoints -`live managed-break` uses DbgEng and the supported SOS `bpmd` command to stop on a managed method. It first stops at the native `coreclr!coreclr_execute_assembly` export so CoreCLR is loaded, loads the explicit x64 SOS extension, configures `!bpmd`, then requires a distinct DbgEng breakpoint event before reporting `managed_breakpoint.hit: true`. The response keeps the initial event, the native CoreCLR setup breakpoint, and the managed event separate. +`live managed-break` does not load SOS or execute `!bpmd`. It starts with a DbgEng create-process event, stops on CoreCLR and the requested managed module load, dynamically loads the exact x64 `mscordaccore.dll` that is a sibling of the target's loaded `coreclr.dll`, resolves the selected `MethodDef` through the DAC, requests CLR code-generation notification, then creates a DbgEng **hardware execute** breakpoint at the DAC-mapped native entry. `managed_breakpoint.hit` is true only when both the DbgEng breakpoint ID and current instruction pointer match that DAC-mapped address. -Install the current Microsoft debugger extension with `dotnet tool install --global dotnet-debugger-extensions`, then `dotnet-debugger-extensions install --architecture X64`, or provide an equivalent compatible x64 `sos.dll`: +Build and stage `windbg_coreclr_dac_bridge.dll` with `cargo xtask native-build`; `cargo xtask package` places it beside `windbg-tool.exe`. A development build can set `WINDBG_CORECLR_DAC_BRIDGE_DLL` to the bridge DLL. The target DAC is never bundled: it must exactly match the target CoreCLR architecture and file version. + +CLR's notification mechanism writes debugger-notification state into the target. Therefore `--allow-runtime-write` is explicit and should be used only in an approved test VM. Without it the command fails clearly before any target write; it does not disable endpoint protection or silently fall back to SOS. ```powershell target\debug\windbg-tool.exe --compact live managed-break ` --command-line '"C:\apps\RemoteDesktopManager_x64.exe" /AutoCloseAfter:10' ` - --sos "$env:USERPROFILE\.dotnet\sos\sos.dll" ` - --managed-module RemoteDesktopManager ` + --managed-module RemoteDesktopManager.dll ` --method Devolutions.RemoteDesktopManager.Program.Main ` + --allow-runtime-write ` --end terminate ``` -The managed assembly and method inputs are intentionally limited to managed metadata-name characters; this prevents a generated SOS command from accepting additional debugger commands. +The vertical slice resolves regular private methods as normal metadata. It requires a unique fully-qualified metadata name and currently rejects ambiguous overloads; C# signatures, generic instantiations, ReadyToRun indirection, tiered recompilation, re-JIT, and unload transitions remain explicit limits. The structured response keeps CoreCLR/module-load events, DAC runtime/token/entry resolution, CLR notification, hardware breakpoint, and final method-hit context distinct. ## AI-oriented DbgEng target inspection diff --git a/docs/development.md b/docs/development.md index 98026c6..2e59cee 100644 --- a/docs/development.md +++ b/docs/development.md @@ -13,6 +13,7 @@ This page keeps the deeper setup and contributor-oriented material out of the ma | `crates\windbg-install` | WinDbg package install/update/launch support | | `xtask` | Developer workflow commands | | `native\ttd-replay-bridge` | C++ bridge to the TTD Replay API | +| `native\coreclr-dac-bridge` | C++ bridge hosting a CoreCLR DAC over an active DbgEng client | | `scripts\Get-TtdReplayRuntime.ps1` | Runtime acquisition helper | | `docs\architecture.md` | Architecture notes and layering details | @@ -44,7 +45,7 @@ cargo xtask native-build - stages DbgEng runtime DLLs into `target\dbgeng-runtime` - downloads `TTDReplay.dll` and `TTDReplayCPU.dll` into `target\ttd-runtime` -`cargo xtask native-build` configures and builds the C++ bridge under `target\native\ttd-replay-bridge`. +`cargo xtask native-build` configures and builds the TTD bridge under `target\native\ttd-replay-bridge` and the x64 CoreCLR DAC bridge under `target\native\coreclr-dac-bridge`. Packaging copies both bridge DLLs beside `windbg-tool.exe`. For release packaging, use explicit target architecture inputs so the Rust binary, native bridge, and staged debugger runtime DLLs all match: @@ -57,7 +58,7 @@ 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-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. +Use `--arch arm64` with `--target aarch64-pc-windows-msvc` for the Windows ARM64 package. The CoreCLR DAC bridge currently supports only x64, so ARM64 packages do not include direct managed-break support. 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. @@ -70,6 +71,8 @@ target\debug\windbg-tool.exe live capabilities The live backend securely preloads `dbgeng.dll` from this directory (or from beside `windbg-tool.exe` in a package) so its dependent DLLs resolve from the matching runtime set. It does not alter global DLL-search or security policy. +For a direct managed breakpoint, the DAC bridge loads `mscordaccore.dll` only from the same directory as the CoreCLR module loaded by the target and requires exact file-version equality. It does not bundle a DAC or SOS. Enabling CLR module/code notifications requires `--allow-runtime-write`, which causes the DAC to write CLR debugger-notification state through DbgEng; run that workflow only in an approved test VM. + 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. ### Publishing a GitHub Release diff --git a/native/coreclr-dac-bridge/CMakeLists.txt b/native/coreclr-dac-bridge/CMakeLists.txt new file mode 100644 index 0000000..fed46ad --- /dev/null +++ b/native/coreclr-dac-bridge/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.16) + +project(windbg_coreclr_dac_bridge LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT DEFINED DBGENG_PACKAGE_DIR) + set(DBGENG_PACKAGE_DIR "${CMAKE_CURRENT_LIST_DIR}/../../target/nuget/Microsoft.Debugging.Platform.DbgEng.20260319.1511.0") +endif() + +set(DBGENG_INCLUDE_DIR "${DBGENG_PACKAGE_DIR}/build/native/inc") +if(NOT EXISTS "${DBGENG_INCLUDE_DIR}/dbgeng.h") + message(FATAL_ERROR "DbgEng headers were not found under ${DBGENG_INCLUDE_DIR}") +endif() + +add_library(windbg_coreclr_dac_bridge SHARED windbg_coreclr_dac_bridge.cpp) + +target_compile_definitions(windbg_coreclr_dac_bridge + PRIVATE + NOMINMAX + WIN32_LEAN_AND_MEAN +) + +target_include_directories(windbg_coreclr_dac_bridge + PRIVATE + "${DBGENG_INCLUDE_DIR}" +) + +target_link_libraries(windbg_coreclr_dac_bridge + PRIVATE + version +) + +set_target_properties(windbg_coreclr_dac_bridge PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" +) diff --git a/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.cpp b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.cpp new file mode 100644 index 0000000..e404688 --- /dev/null +++ b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.cpp @@ -0,0 +1,896 @@ +// This narrow ABI declaration is derived from the MIT-licensed CLR data contracts in +// dotnet/diagnostics (clrdata.idl and xclrdata.idl). Keep it isolated from Rust: these +// interfaces are version-sensitive runtime/DAC contracts rather than a stable Rust ABI. +#include "windbg_coreclr_dac_bridge.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using CLRDATA_ADDRESS = ULONG64; +using CLRDATA_ENUM = ULONG64; +using mdMethodDef = ULONG32; + +struct IXCLRDataProcess; +struct IXCLRDataModule; +struct IXCLRDataMethodDefinition; +struct IXCLRDataTask; +struct IXCLRDataValue; +struct IXCLRDataAppDomain; +struct IXCLRDataAssembly; +struct IXCLRDataTypeDefinition; +struct IXCLRDataTypeInstance; +struct IXCLRDataMethodInstance; +struct IXCLRDataExceptionState; +struct IXCLRDataExceptionNotification; + +static constexpr ULONG32 CLRDATA_METHNOTIFY_GENERATED = 0x00000001; +static constexpr ULONG32 CLRDATA_METHNOTIFY_DISCARDED = 0x00000002; +static constexpr ULONG32 CLRDATA_NOTIFY_ON_MODULE_LOAD = 0x00000001; + +static const IID IID_ICLRDataTarget = + {0x3e11ccee, 0xd08b, 0x43e5, {0xaf, 0x01, 0x32, 0x71, 0x7a, 0x64, 0xda, 0x03}}; +static const IID IID_IXCLRDataProcess = + {0x5c552ab6, 0xfc09, 0x4cb3, {0x8e, 0x36, 0x22, 0xfa, 0x03, 0xc7, 0x98, 0xb7}}; + +struct ICLRDataTarget : IUnknown { + virtual HRESULT STDMETHODCALLTYPE GetMachineType(ULONG32* machine_type) = 0; + virtual HRESULT STDMETHODCALLTYPE GetPointerSize(ULONG32* pointer_size) = 0; + virtual HRESULT STDMETHODCALLTYPE GetImageBase(LPCWSTR image_path, CLRDATA_ADDRESS* base_address) = 0; + virtual HRESULT STDMETHODCALLTYPE ReadVirtual( + CLRDATA_ADDRESS address, + BYTE* buffer, + ULONG32 bytes_requested, + ULONG32* bytes_read) = 0; + virtual HRESULT STDMETHODCALLTYPE WriteVirtual( + CLRDATA_ADDRESS address, + BYTE* buffer, + ULONG32 bytes_requested, + ULONG32* bytes_written) = 0; + virtual HRESULT STDMETHODCALLTYPE GetTLSValue(ULONG32 thread_id, ULONG32 index, CLRDATA_ADDRESS* value) = 0; + virtual HRESULT STDMETHODCALLTYPE SetTLSValue(ULONG32 thread_id, ULONG32 index, CLRDATA_ADDRESS value) = 0; + virtual HRESULT STDMETHODCALLTYPE GetCurrentThreadID(ULONG32* thread_id) = 0; + virtual HRESULT STDMETHODCALLTYPE GetThreadContext( + ULONG32 thread_id, + ULONG32 context_flags, + ULONG32 context_size, + BYTE* context) = 0; + virtual HRESULT STDMETHODCALLTYPE SetThreadContext( + ULONG32 thread_id, + ULONG32 context_size, + BYTE* context) = 0; + virtual HRESULT STDMETHODCALLTYPE Request( + ULONG32 request_code, + ULONG32 input_size, + BYTE* input, + ULONG32 output_size, + BYTE* output) = 0; +}; + +// Only the prefix used by this bridge is declared. Method ordering is the ABI. +struct IXCLRDataProcess : IUnknown { + virtual HRESULT STDMETHODCALLTYPE Flush() = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumTasks(CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumTask(CLRDATA_ENUM* handle, IXCLRDataTask** task) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumTasks(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE GetTaskByOSThreadID(ULONG32 thread_id, IXCLRDataTask** task) = 0; + virtual HRESULT STDMETHODCALLTYPE GetTaskByUniqueID(ULONG64 task_id, IXCLRDataTask** task) = 0; + virtual HRESULT STDMETHODCALLTYPE GetFlags(ULONG32* flags) = 0; + virtual HRESULT STDMETHODCALLTYPE IsSameObject(IXCLRDataProcess* process) = 0; + virtual HRESULT STDMETHODCALLTYPE GetManagedObject(IXCLRDataValue** value) = 0; + virtual HRESULT STDMETHODCALLTYPE GetDesiredExecutionState(ULONG32* state) = 0; + virtual HRESULT STDMETHODCALLTYPE SetDesiredExecutionState(ULONG32 state) = 0; + virtual HRESULT STDMETHODCALLTYPE GetAddressType(CLRDATA_ADDRESS address, ULONG32* type) = 0; + virtual HRESULT STDMETHODCALLTYPE GetRuntimeNameByAddress( + CLRDATA_ADDRESS address, + ULONG32 flags, + ULONG32 buffer_length, + ULONG32* name_length, + WCHAR* name, + CLRDATA_ADDRESS* displacement) = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumAppDomains(CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumAppDomain(CLRDATA_ENUM* handle, IXCLRDataAppDomain** app_domain) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumAppDomains(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE GetAppDomainByUniqueID(ULONG64 id, IXCLRDataAppDomain** app_domain) = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumAssemblies(CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumAssembly(CLRDATA_ENUM* handle, IXCLRDataAssembly** assembly) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumAssemblies(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumModules(CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumModule(CLRDATA_ENUM* handle, IXCLRDataModule** module) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumModules(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE GetModuleByAddress(CLRDATA_ADDRESS address, IXCLRDataModule** module) = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved24() = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved25() = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved26() = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved27() = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved28() = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved29() = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved30() = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved31() = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved32() = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved33() = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved34() = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved35() = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved36() = 0; + virtual HRESULT STDMETHODCALLTYPE Reserved37() = 0; + virtual HRESULT STDMETHODCALLTYPE GetOtherNotificationFlags(ULONG32* flags) = 0; + virtual HRESULT STDMETHODCALLTYPE SetOtherNotificationFlags(ULONG32 flags) = 0; +}; + +struct IXCLRDataModule : IUnknown { + virtual HRESULT STDMETHODCALLTYPE StartEnumAssemblies(CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumAssembly(CLRDATA_ENUM* handle, IXCLRDataAssembly** assembly) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumAssemblies(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumTypeDefinitions(CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumTypeDefinition(CLRDATA_ENUM* handle, IXCLRDataTypeDefinition** type) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumTypeDefinitions(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumTypeInstances(IXCLRDataAppDomain* app_domain, CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumTypeInstance(CLRDATA_ENUM* handle, IXCLRDataTypeInstance** type) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumTypeInstances(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumTypeDefinitionsByName( + LPCWSTR name, + ULONG32 flags, + CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumTypeDefinitionByName( + CLRDATA_ENUM* handle, + IXCLRDataTypeDefinition** type) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumTypeDefinitionsByName(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumTypeInstancesByName( + LPCWSTR name, + ULONG32 flags, + IXCLRDataAppDomain* app_domain, + CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumTypeInstanceByName( + CLRDATA_ENUM* handle, + IXCLRDataTypeInstance** type) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumTypeInstancesByName(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE GetTypeDefinitionByToken(ULONG32 token, IXCLRDataTypeDefinition** type) = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumMethodDefinitionsByName( + LPCWSTR name, + ULONG32 flags, + CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumMethodDefinitionByName( + CLRDATA_ENUM* handle, + IXCLRDataMethodDefinition** method) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumMethodDefinitionsByName(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumMethodInstancesByName( + LPCWSTR name, + ULONG32 flags, + IXCLRDataAppDomain* app_domain, + CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumMethodInstanceByName( + CLRDATA_ENUM* handle, + IXCLRDataMethodInstance** method) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumMethodInstancesByName(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE GetMethodDefinitionByToken( + mdMethodDef token, + IXCLRDataMethodDefinition** method) = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumDataByName( + LPCWSTR name, + ULONG32 flags, + IXCLRDataAppDomain* app_domain, + IXCLRDataTask* task, + CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumDataByName(CLRDATA_ENUM* handle, IXCLRDataValue** value) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumDataByName(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE GetName(ULONG32 buffer_length, ULONG32* name_length, WCHAR* name) = 0; + virtual HRESULT STDMETHODCALLTYPE GetFileName( + ULONG32 buffer_length, + ULONG32* name_length, + WCHAR* name) = 0; +}; + +struct IXCLRDataMethodDefinition : IUnknown { + virtual HRESULT STDMETHODCALLTYPE GetTypeDefinition(IXCLRDataTypeDefinition** type) = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumInstances(IXCLRDataAppDomain* app_domain, CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumInstance(CLRDATA_ENUM* handle, IXCLRDataMethodInstance** instance) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumInstances(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE GetName( + ULONG32 flags, + ULONG32 buffer_length, + ULONG32* name_length, + WCHAR* name) = 0; + virtual HRESULT STDMETHODCALLTYPE GetTokenAndScope(mdMethodDef* token, IXCLRDataModule** module) = 0; + virtual HRESULT STDMETHODCALLTYPE GetFlags(ULONG32* flags) = 0; + virtual HRESULT STDMETHODCALLTYPE IsSameObject(IXCLRDataMethodDefinition* method) = 0; + virtual HRESULT STDMETHODCALLTYPE GetLatestEnCVersion(ULONG32* version) = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumExtents(CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumExtent(CLRDATA_ENUM* handle, void* extent) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumExtents(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE GetCodeNotification(ULONG32* flags) = 0; + virtual HRESULT STDMETHODCALLTYPE SetCodeNotification(ULONG32 flags) = 0; + virtual HRESULT STDMETHODCALLTYPE Request( + ULONG32 request_code, + ULONG32 input_size, + BYTE* input, + ULONG32 output_size, + BYTE* output) = 0; + virtual HRESULT STDMETHODCALLTYPE GetRepresentativeEntryAddress(CLRDATA_ADDRESS* address) = 0; +}; + +using CLRDataCreateInstanceFn = + HRESULT(STDAPICALLTYPE*)(REFIID iid, ICLRDataTarget* target, void** interface_pointer); + +template +class ComReference { +public: + ComReference() = default; + explicit ComReference(T* pointer) : pointer_(pointer) {} + ~ComReference() { reset(); } + + ComReference(const ComReference&) = delete; + ComReference& operator=(const ComReference&) = delete; + + T* get() const { return pointer_; } + T** put() { + reset(); + return &pointer_; + } + + T* detach() { + T* pointer = pointer_; + pointer_ = nullptr; + return pointer; + } + + void reset(T* pointer = nullptr) { + if (pointer_ != nullptr) { + pointer_->Release(); + } + pointer_ = pointer; + } + +private: + T* pointer_ = nullptr; +}; + +thread_local std::wstring g_last_error; + +void set_error(const std::wstring& error) { + g_last_error = error; +} + +std::wstring format_hresult(const wchar_t* operation, HRESULT result) { + wchar_t message[256]{}; + swprintf_s(message, L"%s failed with HRESULT 0x%08X.", operation, static_cast(result)); + return message; +} + +WindbgDacStatus fail(WindbgDacStatus status, const std::wstring& error) { + set_error(error); + return status; +} + +bool read_file_version(const wchar_t* path, uint32_t* version_ms, uint32_t* version_ls) { + DWORD ignored = 0; + const DWORD size = GetFileVersionInfoSizeW(path, &ignored); + if (size == 0) { + return false; + } + + std::vector buffer(size); + if (!GetFileVersionInfoW(path, 0, size, buffer.data())) { + return false; + } + + VS_FIXEDFILEINFO* version = nullptr; + UINT version_size = 0; + if (!VerQueryValueW(buffer.data(), L"\\", reinterpret_cast(&version), &version_size) || + version == nullptr || + version_size < sizeof(*version) || + version->dwSignature != VS_FFI_SIGNATURE) { + return false; + } + + *version_ms = version->dwFileVersionMS; + *version_ls = version->dwFileVersionLS; + return true; +} + +void copy_string(wchar_t* destination, size_t destination_count, const std::wstring& source) { + wcsncpy_s(destination, destination_count, source.c_str(), _TRUNCATE); +} + +std::wstring sibling_dac_path(const wchar_t* coreclr_path) { + std::wstring path(coreclr_path); + const size_t separator = path.find_last_of(L"\\/"); + if (separator == std::wstring::npos) { + return {}; + } + path.resize(separator + 1); + path += L"mscordaccore.dll"; + return path; +} + +class DbgEngDataTarget final : public ICLRDataTarget { +public: + DbgEngDataTarget(IDebugClient5* client, bool allow_target_writes) + : client_(client), allow_target_writes_(allow_target_writes) { + client_.get()->AddRef(); + const HRESULT data_spaces_result = + client_.get()->QueryInterface(__uuidof(IDebugDataSpaces4), reinterpret_cast(data_spaces_.put())); + const HRESULT symbols_result = + client_.get()->QueryInterface(__uuidof(IDebugSymbols5), reinterpret_cast(symbols_.put())); + const HRESULT system_objects_result = + client_.get()->QueryInterface(__uuidof(IDebugSystemObjects4), reinterpret_cast(system_objects_.put())); + const HRESULT advanced_result = + client_.get()->QueryInterface(__uuidof(IDebugAdvanced3), reinterpret_cast(advanced_.put())); + if (FAILED(data_spaces_result) || FAILED(symbols_result) || FAILED(system_objects_result) || + FAILED(advanced_result)) { + wchar_t message[512]{}; + swprintf_s( + message, + L"DbgEng interface queries: data spaces=0x%08X, symbols=0x%08X, system objects=0x%08X, advanced=0x%08X.", + static_cast(data_spaces_result), + static_cast(symbols_result), + static_cast(system_objects_result), + static_cast(advanced_result)); + diagnostic_ = message; + } + } + + const std::wstring& diagnostic() const { return diagnostic_; } + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, void** object) override { + if (object == nullptr) { + return E_POINTER; + } + *object = nullptr; + if (iid == IID_IUnknown || iid == IID_ICLRDataTarget) { + *object = static_cast(this); + AddRef(); + return S_OK; + } + return E_NOINTERFACE; + } + + ULONG STDMETHODCALLTYPE AddRef() override { + return static_cast(InterlockedIncrement(&references_)); + } + + ULONG STDMETHODCALLTYPE Release() override { + const LONG references = InterlockedDecrement(&references_); + if (references == 0) { + delete this; + } + return static_cast(references); + } + + HRESULT STDMETHODCALLTYPE GetMachineType(ULONG32* machine_type) override { + if (machine_type == nullptr) { + diagnostic_ = L"ICLRDataTarget::GetMachineType received a null output pointer."; + return E_POINTER; + } + *machine_type = IMAGE_FILE_MACHINE_AMD64; + diagnostic_ = L"ICLRDataTarget::GetMachineType returned IMAGE_FILE_MACHINE_AMD64."; + return S_OK; + } + + HRESULT STDMETHODCALLTYPE GetPointerSize(ULONG32* pointer_size) override { + if (pointer_size == nullptr) { + diagnostic_ = L"ICLRDataTarget::GetPointerSize received a null output pointer."; + return E_POINTER; + } + *pointer_size = sizeof(uint64_t); + diagnostic_ = L"ICLRDataTarget::GetPointerSize returned 8."; + return S_OK; + } + + HRESULT STDMETHODCALLTYPE GetImageBase(LPCWSTR image_path, CLRDATA_ADDRESS* base_address) override { + if (image_path == nullptr || base_address == nullptr || symbols_.get() == nullptr) { + diagnostic_ = L"ICLRDataTarget::GetImageBase received invalid arguments or has no IDebugSymbols5."; + return E_INVALIDARG; + } + + ULONG index = 0; + ULONG64 base = 0; + HRESULT result = symbols_.get()->GetModuleByModuleNameWide(image_path, 0, &index, &base); + if (FAILED(result)) { + const wchar_t* file_name = wcsrchr(image_path, L'\\'); + file_name = file_name == nullptr ? image_path : file_name + 1; + result = symbols_.get()->GetModuleByModuleNameWide(file_name, 0, &index, &base); + if (FAILED(result)) { + std::wstring module_name(file_name); + const size_t extension = module_name.find_last_of(L'.'); + if (extension != std::wstring::npos) { + module_name.resize(extension); + result = symbols_.get()->GetModuleByModuleNameWide( + module_name.c_str(), + 0, + &index, + &base); + } + } + } + if (SUCCEEDED(result)) { + *base_address = base; + } + wchar_t message[1200]{}; + swprintf_s( + message, + L"ICLRDataTarget::GetImageBase(%s) returned 0x%08X (base=0x%llX).", + image_path, + static_cast(result), + static_cast(base)); + diagnostic_ = message; + return result; + } + + HRESULT STDMETHODCALLTYPE ReadVirtual( + CLRDATA_ADDRESS address, + BYTE* buffer, + ULONG32 bytes_requested, + ULONG32* bytes_read) override { + if (buffer == nullptr || bytes_read == nullptr || data_spaces_.get() == nullptr) { + diagnostic_ = L"ICLRDataTarget::ReadVirtual received invalid arguments or has no IDebugDataSpaces4."; + return E_POINTER; + } + ULONG read = 0; + const HRESULT result = data_spaces_.get()->ReadVirtual(address, buffer, bytes_requested, &read); + *bytes_read = read; + wchar_t message[256]{}; + swprintf_s( + message, + L"ICLRDataTarget::ReadVirtual(0x%llX, %u) returned 0x%08X (%u bytes).", + static_cast(address), + bytes_requested, + static_cast(result), + read); + diagnostic_ = message; + return read != 0 && FAILED(result) ? S_OK : result; + } + + HRESULT STDMETHODCALLTYPE WriteVirtual( + CLRDATA_ADDRESS address, + BYTE* buffer, + ULONG32 bytes_requested, + ULONG32* bytes_written) override { + if (buffer == nullptr || bytes_written == nullptr || data_spaces_.get() == nullptr) { + diagnostic_ = L"ICLRDataTarget::WriteVirtual received invalid arguments or has no IDebugDataSpaces4."; + return E_POINTER; + } + if (!allow_target_writes_) { + diagnostic_ = + L"ICLRDataTarget::WriteVirtual was rejected because runtime writes were not explicitly enabled."; + return E_ACCESSDENIED; + } + + ULONG written = 0; + const HRESULT result = + data_spaces_.get()->WriteVirtual(address, buffer, bytes_requested, &written); + *bytes_written = written; + wchar_t message[256]{}; + swprintf_s( + message, + L"ICLRDataTarget::WriteVirtual(0x%llX, %u) returned 0x%08X (%u bytes).", + static_cast(address), + bytes_requested, + static_cast(result), + written); + diagnostic_ = message; + return written != 0 && FAILED(result) ? S_OK : result; + } + + HRESULT STDMETHODCALLTYPE GetTLSValue(ULONG32, ULONG32, CLRDATA_ADDRESS*) override { + diagnostic_ = L"ICLRDataTarget::GetTLSValue is not implemented."; + return E_NOTIMPL; + } + + HRESULT STDMETHODCALLTYPE SetTLSValue(ULONG32, ULONG32, CLRDATA_ADDRESS) override { + diagnostic_ = L"ICLRDataTarget::SetTLSValue was rejected because this bridge is read-only."; + return E_ACCESSDENIED; + } + + HRESULT STDMETHODCALLTYPE GetCurrentThreadID(ULONG32* thread_id) override { + if (thread_id == nullptr || system_objects_.get() == nullptr) { + diagnostic_ = L"ICLRDataTarget::GetCurrentThreadID received invalid arguments or has no IDebugSystemObjects4."; + return E_POINTER; + } + ULONG native_thread_id = 0; + const HRESULT result = system_objects_.get()->GetCurrentThreadSystemId(&native_thread_id); + *thread_id = native_thread_id; + wchar_t message[256]{}; + swprintf_s( + message, + L"ICLRDataTarget::GetCurrentThreadID returned 0x%08X (%u).", + static_cast(result), + native_thread_id); + diagnostic_ = message; + return result; + } + + HRESULT STDMETHODCALLTYPE GetThreadContext( + ULONG32 thread_id, + ULONG32 context_flags, + ULONG32 context_size, + BYTE* context) override { + if (context == nullptr || advanced_.get() == nullptr) { + diagnostic_ = L"ICLRDataTarget::GetThreadContext received invalid arguments or has no IDebugAdvanced3."; + return E_POINTER; + } + + ULONG32 current_thread = 0; + HRESULT result = GetCurrentThreadID(¤t_thread); + if (FAILED(result)) { + diagnostic_ = format_hresult(L"ICLRDataTarget::GetCurrentThreadID", result); + return result; + } + if (thread_id != current_thread || context_size < sizeof(CONTEXT)) { + diagnostic_ = L"ICLRDataTarget::GetThreadContext only supports the current x64 thread and a full CONTEXT."; + return E_INVALIDARG; + } + + auto* native_context = reinterpret_cast(context); + native_context->ContextFlags = context_flags; + result = advanced_.get()->GetThreadContext(native_context, context_size); + diagnostic_ = format_hresult(L"ICLRDataTarget::GetThreadContext", result); + return result; + } + + HRESULT STDMETHODCALLTYPE SetThreadContext(ULONG32, ULONG32, BYTE*) override { + diagnostic_ = L"ICLRDataTarget::SetThreadContext was rejected because this bridge is read-only."; + return E_ACCESSDENIED; + } + + HRESULT STDMETHODCALLTYPE Request(ULONG32, ULONG32, BYTE*, ULONG32, BYTE*) override { + diagnostic_ = L"ICLRDataTarget::Request is not implemented."; + return E_NOTIMPL; + } + +private: + ~DbgEngDataTarget() = default; + + LONG references_ = 1; + ComReference client_; + ComReference data_spaces_; + ComReference symbols_; + ComReference system_objects_; + ComReference advanced_; + bool allow_target_writes_; + std::wstring diagnostic_; +}; + +struct WindbgDacBridge { + WindbgDacBridge(IDebugClient5* client, bool allow_target_writes) + : target(new DbgEngDataTarget(client, allow_target_writes)) {} + + ~WindbgDacBridge() { + method.reset(); + process.reset(); + target->Release(); + if (dac_module != nullptr) { + FreeLibrary(dac_module); + } + } + + DbgEngDataTarget* target; + ComReference process; + ComReference method; + HMODULE dac_module = nullptr; + std::wstring dac_path; +}; + +void populate_method_info(IXCLRDataMethodDefinition* method, WindbgDacMethodInfo* info) { + memset(info, 0, sizeof(*info)); + + std::array name{}; + ULONG32 name_length = 0; + if (SUCCEEDED(method->GetName(0, static_cast(name.size()), &name_length, name.data()))) { + copy_string(info->resolved_method, std::size(info->resolved_method), name.data()); + } + + ComReference scope; + method->GetTokenAndScope(&info->method_token, scope.put()); + info->code_notification_flags = CLRDATA_METHNOTIFY_GENERATED | CLRDATA_METHNOTIFY_DISCARDED; + + CLRDATA_ADDRESS entry_address = 0; + if (SUCCEEDED(method->GetRepresentativeEntryAddress(&entry_address)) && entry_address != 0) { + info->representative_entry_address = entry_address; + info->code_available = 1; + } +} + +bool module_paths_match(const std::wstring& expected, const std::wstring& actual) { + if (_wcsicmp(expected.c_str(), actual.c_str()) == 0) { + return true; + } + + const wchar_t* expected_name = wcsrchr(expected.c_str(), L'\\'); + expected_name = expected_name == nullptr ? expected.c_str() : expected_name + 1; + const wchar_t* actual_name = wcsrchr(actual.c_str(), L'\\'); + actual_name = actual_name == nullptr ? actual.c_str() : actual_name + 1; + return _wcsicmp(expected_name, actual_name) == 0; +} + +WindbgDacStatus find_module_by_path( + IXCLRDataProcess* process, + const wchar_t* managed_module_path, + ComReference* module) { + const HRESULT flush_result = process->Flush(); + if (FAILED(flush_result)) { + return fail(WINDBG_DAC_ERROR, format_hresult(L"Refreshing the DAC process state", flush_result)); + } + + CLRDATA_ENUM enumeration = 0; + HRESULT result = process->StartEnumModules(&enumeration); + if (FAILED(result) || result == S_FALSE) { + return fail(WINDBG_DAC_NOT_FOUND, L"The DAC did not enumerate any managed modules."); + } + + const std::wstring expected_path(managed_module_path); + std::vector observed_module_paths; + while (true) { + ComReference candidate; + result = process->EnumModule(&enumeration, candidate.put()); + if (result == S_FALSE) { + break; + } + if (FAILED(result)) { + process->EndEnumModules(enumeration); + return fail(WINDBG_DAC_ERROR, format_hresult(L"Enumerating DAC modules", result)); + } + + std::array file_name{}; + ULONG32 file_name_length = 0; + result = candidate.get()->GetFileName( + static_cast(file_name.size()), + &file_name_length, + file_name.data()); + if (SUCCEEDED(result) && file_name[0] != L'\0' && observed_module_paths.size() < 16) { + observed_module_paths.emplace_back(file_name.data()); + } + if (SUCCEEDED(result) && module_paths_match(expected_path, file_name.data())) { + process->EndEnumModules(enumeration); + module->reset(candidate.detach()); + return WINDBG_DAC_OK; + } + } + process->EndEnumModules(enumeration); + std::wstring observed = L" No module file names were available."; + if (!observed_module_paths.empty()) { + observed = L" Observed modules: "; + for (size_t index = 0; index < observed_module_paths.size(); ++index) { + if (index != 0) { + observed += L"; "; + } + observed += observed_module_paths[index]; + } + observed += L"."; + } + return fail( + WINDBG_DAC_NOT_FOUND, + L"The DbgEng-selected managed module is not present in the matching DAC module enumeration." + + observed); +} + +extern "C" WindbgDacStatus windbg_dac_create( + void* debug_client, + const wchar_t* coreclr_path, + uint8_t allow_target_writes, + WindbgDacBridge** bridge, + WindbgDacRuntimeInfo* runtime_info) { + g_last_error.clear(); + if (debug_client == nullptr || coreclr_path == nullptr || bridge == nullptr || runtime_info == nullptr) { + return fail(WINDBG_DAC_INVALID_ARGUMENT, L"A DbgEng client, CoreCLR path, bridge output, and runtime output are required."); + } + *bridge = nullptr; + memset(runtime_info, 0, sizeof(*runtime_info)); + + if (sizeof(void*) != sizeof(uint64_t)) { + return fail(WINDBG_DAC_ERROR, L"The CoreCLR DAC bridge supports only x64 debugger hosts."); + } + + const std::wstring coreclr(coreclr_path); + const std::wstring dac_path = sibling_dac_path(coreclr_path); + if (dac_path.empty() || GetFileAttributesW(coreclr_path) == INVALID_FILE_ATTRIBUTES || + GetFileAttributesW(dac_path.c_str()) == INVALID_FILE_ATTRIBUTES) { + return fail(WINDBG_DAC_NOT_FOUND, L"An exact CoreCLR sibling mscordaccore.dll was not found."); + } + + if (!read_file_version( + coreclr_path, + &runtime_info->coreclr_version_ms, + &runtime_info->coreclr_version_ls) || + !read_file_version( + dac_path.c_str(), + &runtime_info->dac_version_ms, + &runtime_info->dac_version_ls)) { + return fail(WINDBG_DAC_ERROR, L"The CoreCLR or DAC file version could not be read."); + } + + copy_string(runtime_info->coreclr_path, std::size(runtime_info->coreclr_path), coreclr); + copy_string(runtime_info->dac_path, std::size(runtime_info->dac_path), dac_path); + + if (runtime_info->coreclr_version_ms != runtime_info->dac_version_ms || + runtime_info->coreclr_version_ls != runtime_info->dac_version_ls) { + return fail(WINDBG_DAC_ERROR, L"CoreCLR and mscordaccore.dll file versions do not match exactly."); + } + + auto native_bridge = std::make_unique( + reinterpret_cast(debug_client), + allow_target_writes != 0); + const HMODULE dac_module = LoadLibraryExW( + dac_path.c_str(), + nullptr, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32); + if (dac_module == nullptr) { + wchar_t message[256]{}; + swprintf_s(message, L"Loading the matching DAC failed with Win32 error %lu.", GetLastError()); + return fail(WINDBG_DAC_ERROR, message); + } + + const auto create_instance = reinterpret_cast( + GetProcAddress(dac_module, "CLRDataCreateInstance")); + if (create_instance == nullptr) { + FreeLibrary(dac_module); + return fail(WINDBG_DAC_ERROR, L"The matching DAC does not export CLRDataCreateInstance."); + } + + native_bridge->dac_module = dac_module; + const HRESULT result = create_instance( + IID_IXCLRDataProcess, + native_bridge->target, + reinterpret_cast(native_bridge->process.put())); + if (FAILED(result)) { + return fail( + WINDBG_DAC_ERROR, + format_hresult(L"CLRDataCreateInstance", result) + L" DAC callback diagnostics: " + + native_bridge->target->diagnostic()); + } + if (native_bridge->process.get() == nullptr) { + return fail( + WINDBG_DAC_ERROR, + L"CLRDataCreateInstance reported success without returning IXCLRDataProcess."); + } + + native_bridge->dac_path = dac_path; + *bridge = native_bridge.release(); + return WINDBG_DAC_OK; +} + +extern "C" void windbg_dac_destroy(WindbgDacBridge* bridge) { + delete bridge; +} + +extern "C" WindbgDacStatus windbg_dac_enable_module_load_notifications(WindbgDacBridge* bridge) { + g_last_error.clear(); + if (bridge == nullptr) { + return fail(WINDBG_DAC_INVALID_ARGUMENT, L"A bridge is required."); + } + + const HRESULT result = + bridge->process.get()->SetOtherNotificationFlags(CLRDATA_NOTIFY_ON_MODULE_LOAD); + if (FAILED(result)) { + return fail( + WINDBG_DAC_ERROR, + format_hresult(L"Requesting CLR managed-module load notifications", result) + + L" DAC callback diagnostics: " + bridge->target->diagnostic()); + } + return WINDBG_DAC_OK; +} + +extern "C" WindbgDacStatus windbg_dac_is_module_loaded( + WindbgDacBridge* bridge, + const wchar_t* managed_module_path, + uint8_t* loaded) { + g_last_error.clear(); + if (bridge == nullptr || managed_module_path == nullptr || loaded == nullptr) { + return fail( + WINDBG_DAC_INVALID_ARGUMENT, + L"A bridge, managed module path, and loaded output are required."); + } + + *loaded = 0; + ComReference module; + const WindbgDacStatus status = + find_module_by_path(bridge->process.get(), managed_module_path, &module); + if (status == WINDBG_DAC_NOT_FOUND) { + g_last_error.clear(); + return WINDBG_DAC_OK; + } + if (status != WINDBG_DAC_OK) { + return status; + } + + *loaded = 1; + return WINDBG_DAC_OK; +} + +extern "C" WindbgDacStatus windbg_dac_resolve_and_notify( + WindbgDacBridge* bridge, + const wchar_t* managed_module_path, + const wchar_t* fully_qualified_method, + WindbgDacMethodInfo* method_info) { + g_last_error.clear(); + if (bridge == nullptr || managed_module_path == nullptr || fully_qualified_method == nullptr || + method_info == nullptr) { + return fail( + WINDBG_DAC_INVALID_ARGUMENT, + L"A bridge, managed module path, fully-qualified method name, and method output are required."); + } + memset(method_info, 0, sizeof(*method_info)); + + ComReference module; + const WindbgDacStatus module_status = + find_module_by_path(bridge->process.get(), managed_module_path, &module); + if (module_status != WINDBG_DAC_OK) { + return module_status; + } + + CLRDATA_ENUM enumeration = 0; + HRESULT result = + module.get()->StartEnumMethodDefinitionsByName(fully_qualified_method, 0, &enumeration); + if (FAILED(result) || result == S_FALSE) { + return fail(WINDBG_DAC_NOT_FOUND, L"The requested managed method was not found in the selected module."); + } + + ComReference selected; + uint32_t count = 0; + while (true) { + ComReference candidate; + result = module.get()->EnumMethodDefinitionByName(&enumeration, candidate.put()); + if (result == S_FALSE) { + break; + } + if (FAILED(result)) { + module.get()->EndEnumMethodDefinitionsByName(enumeration); + return fail(WINDBG_DAC_ERROR, format_hresult(L"Enumerating managed method definitions", result)); + } + + ++count; + if (count == 1) { + selected.reset(candidate.detach()); + } + } + module.get()->EndEnumMethodDefinitionsByName(enumeration); + + method_info->matching_method_count = count; + if (count == 0) { + return fail(WINDBG_DAC_NOT_FOUND, L"The requested managed method was not found in the selected module."); + } + if (count != 1) { + return fail( + WINDBG_DAC_AMBIGUOUS, + L"The requested managed method is ambiguous. Supply an exact metadata signature once signature selection is available."); + } + + bridge->method.reset(selected.detach()); + populate_method_info(bridge->method.get(), method_info); + result = bridge->method.get()->SetCodeNotification(method_info->code_notification_flags); + if (FAILED(result)) { + bridge->method.reset(); + return fail(WINDBG_DAC_ERROR, format_hresult(L"Requesting CLR code-generation notification", result)); + } + + return WINDBG_DAC_OK; +} + +extern "C" WindbgDacStatus windbg_dac_refresh_method_code( + WindbgDacBridge* bridge, + WindbgDacMethodInfo* method_info) { + g_last_error.clear(); + if (bridge == nullptr || method_info == nullptr) { + return fail(WINDBG_DAC_INVALID_ARGUMENT, L"A bridge and method output are required."); + } + if (bridge->method.get() == nullptr) { + return fail(WINDBG_DAC_NOT_FOUND, L"No managed method has been resolved for this bridge."); + } + + populate_method_info(bridge->method.get(), method_info); + method_info->matching_method_count = 1; + if (method_info->code_available == 0) { + return fail(WINDBG_DAC_CODE_UNAVAILABLE, L"The managed method does not have a representative native entry address yet."); + } + return WINDBG_DAC_OK; +} + +extern "C" const wchar_t* windbg_dac_last_error(void) { + return g_last_error.c_str(); +} diff --git a/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h new file mode 100644 index 0000000..e18b1c9 --- /dev/null +++ b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include + +#ifdef _WIN32 +#define WINDBG_DAC_EXPORT __declspec(dllexport) +#else +#define WINDBG_DAC_EXPORT +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct WindbgDacBridge WindbgDacBridge; + +typedef enum WindbgDacStatus { + WINDBG_DAC_OK = 0, + WINDBG_DAC_ERROR = 1, + WINDBG_DAC_INVALID_ARGUMENT = 2, + WINDBG_DAC_NOT_FOUND = 3, + WINDBG_DAC_AMBIGUOUS = 4, + WINDBG_DAC_CODE_UNAVAILABLE = 5, +} WindbgDacStatus; + +typedef struct WindbgDacRuntimeInfo { + wchar_t coreclr_path[1024]; + wchar_t dac_path[1024]; + uint32_t coreclr_version_ms; + uint32_t coreclr_version_ls; + uint32_t dac_version_ms; + uint32_t dac_version_ls; +} WindbgDacRuntimeInfo; + +typedef struct WindbgDacMethodInfo { + uint32_t method_token; + uint32_t matching_method_count; + uint64_t representative_entry_address; + uint32_t code_notification_flags; + uint8_t code_available; + uint8_t reserved[3]; + wchar_t resolved_method[1024]; +} WindbgDacMethodInfo; + +// The debug_client parameter is an IDebugClient5 pointer retained by the caller for the bridge lifetime. +WINDBG_DAC_EXPORT WindbgDacStatus windbg_dac_create( + void* debug_client, + const wchar_t* coreclr_path, + uint8_t allow_target_writes, + WindbgDacBridge** bridge, + WindbgDacRuntimeInfo* runtime_info); + +WINDBG_DAC_EXPORT void windbg_dac_destroy(WindbgDacBridge* bridge); + +WINDBG_DAC_EXPORT WindbgDacStatus windbg_dac_enable_module_load_notifications( + WindbgDacBridge* bridge); + +WINDBG_DAC_EXPORT WindbgDacStatus windbg_dac_is_module_loaded( + WindbgDacBridge* bridge, + const wchar_t* managed_module_path, + uint8_t* loaded); + +WINDBG_DAC_EXPORT WindbgDacStatus windbg_dac_resolve_and_notify( + WindbgDacBridge* bridge, + const wchar_t* managed_module_path, + const wchar_t* fully_qualified_method, + WindbgDacMethodInfo* method_info); + +WINDBG_DAC_EXPORT WindbgDacStatus windbg_dac_refresh_method_code( + WindbgDacBridge* bridge, + WindbgDacMethodInfo* method_info); + +WINDBG_DAC_EXPORT const wchar_t* windbg_dac_last_error(void); + +#ifdef __cplusplus +} +#endif diff --git a/xtask/src/main.rs b/xtask/src/main.rs index bf6b709..db3fd7b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -9,6 +9,7 @@ const DEBUGGING_PLATFORM_VERSION: &str = "20260319.1511.0"; const DEFAULT_SYMBOL_CACHE: &str = ".ttd-symbol-cache"; const MICROSOFT_SYMBOL_SERVER: &str = "https://msdl.microsoft.com/download/symbols"; const NATIVE_BRIDGE_DLL: &str = "ttd_replay_bridge.dll"; +const CORECLR_DAC_BRIDGE_DLL: &str = "windbg_coreclr_dac_bridge.dll"; const TTD_RUNTIME_FILES: &[&str] = &["TTDReplay.dll", "TTDReplayCPU.dll"]; const DBGENG_RUNTIME_FILES: &[&str] = &[ "dbgeng.dll", @@ -335,6 +336,7 @@ 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 dbgeng_package = package_dir(&packages_dir, "Microsoft.Debugging.Platform.DbgEng")?; let source_dir = root.join("native/ttd-replay-bridge"); let build_dir = native_build_dir(&root, options); fs::create_dir_all(&build_dir).context("creating native bridge build directory")?; @@ -368,6 +370,40 @@ fn native_build(options: &XtaskOptions) -> anyhow::Result<()> { .arg("Release")) .context("building native TTD replay bridge")?; + if options.arch == PackageArch::Amd64 { + let dac_source_dir = root.join("native/coreclr-dac-bridge"); + let dac_build_dir = coreclr_dac_bridge_build_dir(&root, options); + fs::create_dir_all(&dac_build_dir) + .context("creating CoreCLR DAC bridge build directory")?; + + let mut dac_configure = Command::new("cmake"); + dac_configure + .arg("-S") + .arg(&dac_source_dir) + .arg("-B") + .arg(&dac_build_dir) + .arg(format!("-DDBGENG_PACKAGE_DIR={}", dbgeng_package.display())) + .env("Platform", options.arch.msvc_platform()); + + if options.static_crt { + dac_configure.arg("-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded$<$:Debug>"); + } + + if cfg!(windows) && cmake_generator_accepts_platform() { + dac_configure.arg("-A").arg(options.arch.msvc_platform()); + } + + run(&mut dac_configure).context("configuring CoreCLR DAC bridge")?; + run(Command::new("cmake") + .arg("--build") + .arg(&dac_build_dir) + .arg("--config") + .arg("Release")) + .context("building CoreCLR DAC bridge")?; + } else { + println!(" skipping CoreCLR DAC bridge: direct managed breakpoints are x64-only"); + } + Ok(()) } @@ -390,6 +426,12 @@ fn package(options: &XtaskOptions) -> anyhow::Result<()> { native_bridge_candidates(&root, options), &package_dir.join(NATIVE_BRIDGE_DLL), )?; + if options.arch == PackageArch::Amd64 { + copy_first_existing( + coreclr_dac_bridge_candidates(&root, options), + &package_dir.join(CORECLR_DAC_BRIDGE_DLL), + )?; + } copy_runtime_files( &ttd_runtime_dir(&root, options), &package_dir, @@ -655,6 +697,15 @@ fn native_build_dir(root: &Path, options: &XtaskOptions) -> PathBuf { } } +fn coreclr_dac_bridge_build_dir(root: &Path, options: &XtaskOptions) -> PathBuf { + if options.explicit_target_layout { + root.join("target/native") + .join(format!("coreclr-dac-bridge-{}", options.arch.nuget_arch())) + } else { + root.join("target/native/coreclr-dac-bridge") + } +} + fn default_package_dir(root: &Path, options: &XtaskOptions) -> PathBuf { if options.explicit_target_layout { root.join("target/package") @@ -691,6 +742,20 @@ fn native_bridge_candidates(root: &Path, options: &XtaskOptions) -> Vec candidates } +fn coreclr_dac_bridge_candidates(root: &Path, options: &XtaskOptions) -> Vec { + let mut candidates = Vec::new(); + if let Some(path) = env::var_os("WINDBG_CORECLR_DAC_BRIDGE_DLL").map(PathBuf::from) { + candidates.push(path); + } + + let build_dir = coreclr_dac_bridge_build_dir(root, options); + candidates.push(build_dir.join("bin/Release").join(CORECLR_DAC_BRIDGE_DLL)); + candidates.push(build_dir.join("bin/Debug").join(CORECLR_DAC_BRIDGE_DLL)); + candidates.push(build_dir.join("Release").join(CORECLR_DAC_BRIDGE_DLL)); + candidates.push(build_dir.join("Debug").join(CORECLR_DAC_BRIDGE_DLL)); + candidates +} + fn copy_runtime_files( source_dir: &Path, package_dir: &Path, From 9d3612843f203ef545aa3a3494569d8d29f84aec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Jul 2026 11:01:35 -0400 Subject: [PATCH 03/12] Fix direct CoreCLR method breakpoints Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-dbgeng/src/coreclr_dac.rs | 20 ++ crates/windbg-tool/src/cli/platform.rs | 252 +++++++++++---- docs/architecture.md | 2 +- docs/cli.md | 28 +- docs/development.md | 2 +- .../windbg_coreclr_dac_bridge.cpp | 299 +++++++++++++++++- .../windbg_coreclr_dac_bridge.h | 3 + 7 files changed, 541 insertions(+), 65 deletions(-) diff --git a/crates/windbg-dbgeng/src/coreclr_dac.rs b/crates/windbg-dbgeng/src/coreclr_dac.rs index 1e5cd0d..302f0ac 100644 --- a/crates/windbg-dbgeng/src/coreclr_dac.rs +++ b/crates/windbg-dbgeng/src/coreclr_dac.rs @@ -67,6 +67,7 @@ type CreateBridge = unsafe extern "C" fn( ) -> u32; type DestroyBridge = unsafe extern "C" fn(bridge: *mut c_void); type EnableModuleLoadNotifications = unsafe extern "C" fn(bridge: *mut c_void) -> u32; +type DisableModuleLoadNotifications = unsafe extern "C" fn(bridge: *mut c_void) -> u32; type IsModuleLoaded = unsafe extern "C" fn( bridge: *mut c_void, managed_module_path: *const u16, @@ -110,6 +111,7 @@ pub struct CoreClrDacBridge { bridge: NonNull, destroy: DestroyBridge, enable_module_load_notifications: EnableModuleLoadNotifications, + disable_module_load_notifications: DisableModuleLoadNotifications, is_module_loaded: IsModuleLoaded, resolve_and_notify: ResolveAndNotify, refresh_method_code: RefreshMethodCode, @@ -145,6 +147,12 @@ impl CoreClrDacBridge { b"windbg_dac_enable_module_load_notifications\0", )? }; + let disable_module_load_notifications = unsafe { + load_symbol::( + &library, + b"windbg_dac_disable_module_load_notifications\0", + )? + }; let is_module_loaded = unsafe { load_symbol::(&library, b"windbg_dac_is_module_loaded\0")? }; let resolve_and_notify = unsafe { @@ -181,6 +189,7 @@ impl CoreClrDacBridge { bridge, destroy, enable_module_load_notifications, + disable_module_load_notifications, is_module_loaded, resolve_and_notify, refresh_method_code, @@ -204,6 +213,17 @@ impl CoreClrDacBridge { Ok(()) } + pub fn disable_module_load_notifications(&self) -> anyhow::Result<()> { + let status = unsafe { (self.disable_module_load_notifications)(self.bridge.as_ptr()) }; + if status != WINDBG_DAC_OK { + bail!( + "disabling CLR managed-module load notifications failed: {}", + bridge_error(self.last_error) + ); + } + Ok(()) + } + pub fn is_module_loaded(&self, managed_module_path: &Path) -> anyhow::Result { let managed_module_path = to_wide(managed_module_path.as_os_str())?; let mut loaded = 0u8; diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 9e62d90..7434f80 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -215,7 +215,7 @@ pub(super) fn run_live_managed_break( coreclr_wait.name.as_deref() == Some("break") && coreclr_event.event_name == "load_module" && coreclr_event.module_base.is_some(), - "DbgEng did not stop on the requested CoreCLR module-load event" + "DbgEng did not stop on the requested CoreCLR module-load event: wait={coreclr_wait:?}, event={coreclr_event:?}" ); let modules_after_coreclr_load = session.modules()?; @@ -232,30 +232,39 @@ pub(super) fn run_live_managed_break( .with_context(|| { format!("configuring the managed-module load stop for {managed_module}") })?; - let continued_to_managed_module = session.continue_execution()?; - let managed_module_wait = session - .wait_for_event(args.wait_timeout_ms) - .with_context(|| format!("waiting for managed module {managed_module} to load"))?; - let managed_module_event = session - .last_event() - .context("reading the managed module-load event")?; - ensure!( - managed_module_wait.name.as_deref() == Some("break") - && managed_module_event.event_name == "load_module" - && managed_module_event.module_base.is_some(), - "DbgEng did not stop on the requested managed module-load event" - ); - let modules_after_managed_load = session.modules()?; - let loaded_managed_module = - find_loaded_module(&modules_after_managed_load, &managed_module)?; - let managed_module_path = module_image_path(loaded_managed_module, "managed assembly")?; - let managed_module_observation = wait_for_managed_module_in_dac( + let managed_module_load = wait_for_dbgeng_managed_module_load( &session, - &dac, - &managed_module_path, args.wait_timeout_ms, CLR_NOTIFICATION_EXCEPTION, )?; + let modules_after_managed_load = session.modules()?; + let loaded_managed_module = + find_loaded_module(&modules_after_managed_load, &managed_module)?; + let managed_module_path = module_image_path(loaded_managed_module, "managed assembly")?; + let (managed_module_observation, managed_module_notification_pending) = + if dac.is_module_loaded(&managed_module_path)? { + ( + json!({ + "module_available": true, + "source": "immediately_after_dbgeng_load_event", + "notifications": [] + }), + false, + ) + } else { + ( + wait_for_managed_module_in_dac( + &session, + &dac, + &managed_module_path, + args.wait_timeout_ms, + CLR_NOTIFICATION_EXCEPTION, + )?, + true, + ) + }; + dac.disable_module_load_notifications() + .context("disabling CLR managed-module load notifications after module discovery")?; let (resolved_method, availability) = dac .resolve_and_notify(&managed_module_path, &method) .with_context(|| { @@ -264,10 +273,15 @@ pub(super) fn run_live_managed_break( ) })?; - let (code_notification, generated_method) = match availability { - ManagedCodeAvailability::Available => (None, resolved_method.clone()), + let (code_notification, generated_method, clr_notification_pending) = match availability { + ManagedCodeAvailability::Available => ( + None, + resolved_method.clone(), + managed_module_notification_pending, + ), ManagedCodeAvailability::PendingJit => { - let continued = session.continue_execution()?; + let continued = + continue_after_clr_notification(&session, managed_module_notification_pending)?; let wait = session .wait_for_event(args.wait_timeout_ms) .context("waiting for the requested CLR code-generation notification")?; @@ -299,6 +313,7 @@ pub(super) fn run_live_managed_break( "selected_method_code_available": true })), generated, + true, ) } }; @@ -309,27 +324,16 @@ pub(super) fn run_live_managed_break( let native_entry_address = generated_method .representative_entry_address .context("the selected managed method has no generated native entry address")?; - let hardware_breakpoint = session.add_data_breakpoint( + let code_breakpoint = session.add_code_breakpoint(native_entry_address)?; + let (breakpoint_stop, code_breakpoint_hit) = wait_for_managed_code_breakpoint( + &session, + &code_breakpoint, native_entry_address, - 1, - windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_BREAK_EXECUTE, + args.wait_timeout_ms, + CLR_NOTIFICATION_EXCEPTION, + clr_notification_pending, )?; - let continued_to_hardware_breakpoint = if code_notification.is_some() { - session.continue_execution_handled()? - } else { - session.continue_execution()? - }; - let hardware_wait = session - .wait_for_event(args.wait_timeout_ms) - .context("waiting for the managed hardware execute breakpoint")?; - let hardware_event = session - .last_event() - .context("reading the managed hardware execute-breakpoint event")?; let registers = session.core_registers()?; - let hardware_breakpoint_hit = hardware_wait.name.as_deref() == Some("break") - && hardware_event.event_name == "breakpoint" - && hardware_event.breakpoint_id == Some(hardware_breakpoint.id) - && registers.instruction_offset == Some(native_entry_address); let context = live_stop_context(&session, registers, args.max_frames)?; Ok(json!({ "workflow": "live_managed_break_dac", @@ -345,10 +349,9 @@ pub(super) fn run_live_managed_break( }, "managed_module_load": { "managed_module": managed_module, - "continued_to_dbgeng_load_event": continued_to_managed_module, - "dbgeng_load_wait": managed_module_wait, - "dbgeng_load_event": managed_module_event, + "dbgeng_load": managed_module_load, "observation": managed_module_observation, + "notifications_disabled_after_observation": true, "loaded_module": loaded_managed_module }, "managed_resolution": { @@ -365,16 +368,14 @@ pub(super) fn run_live_managed_break( "representative_native_entry_address": format!("0x{native_entry_address:X}") }, "managed_breakpoint": { - "kind": "hardware_execute", - "configured": hardware_breakpoint, - "continued": continued_to_hardware_breakpoint, - "wait": hardware_wait, - "event": hardware_event, - "hit": hardware_breakpoint_hit, - "hit_evidence": if hardware_breakpoint_hit { - "the DbgEng hardware breakpoint event ID and current instruction pointer both match the DAC-mapped native entry for the selected MethodDef" + "kind": "software_code", + "configured": code_breakpoint, + "stop": breakpoint_stop, + "hit": code_breakpoint_hit, + "hit_evidence": if code_breakpoint_hit { + "the DbgEng code breakpoint event ID and current instruction pointer both match the DAC-mapped native entry for the selected MethodDef" } else { - "the selected MethodDef was resolved by the DAC, but DbgEng did not report a matching hardware execute breakpoint hit" + "the selected MethodDef was resolved by the DAC, but DbgEng did not report a matching code breakpoint hit" } }, "context": context, @@ -397,6 +398,85 @@ pub(super) fn run_live_managed_break( } } +fn continue_after_clr_notification( + session: &DebuggerSession, + clr_notification_pending: bool, +) -> anyhow::Result { + if clr_notification_pending { + session.continue_execution_handled() + } else { + session.continue_execution() + } +} + +fn wait_for_managed_code_breakpoint( + session: &DebuggerSession, + breakpoint: &BreakpointInfo, + native_entry_address: u64, + wait_timeout_ms: u32, + clr_notification_exception: u32, + mut clr_notification_pending: bool, +) -> anyhow::Result<(Value, bool)> { + const MAX_PRELIMINARY_NOTIFICATIONS: usize = 64; + + let deadline = Instant::now() + Duration::from_millis(u64::from(wait_timeout_ms)); + let mut preliminary_notifications = Vec::new(); + for attempt in 1..=MAX_PRELIMINARY_NOTIFICATIONS { + let remaining = deadline.saturating_duration_since(Instant::now()); + ensure!( + !remaining.is_zero(), + "timed out waiting for the managed code breakpoint" + ); + let remaining_ms = remaining.as_millis().clamp(1, u128::from(u32::MAX)) as u32; + let continued = continue_after_clr_notification(session, clr_notification_pending)?; + let wait = session + .wait_for_event(remaining_ms) + .context("waiting for the managed code breakpoint")?; + let event = session + .last_event() + .context("reading the managed code breakpoint event")?; + let registers = session.core_registers()?; + let code_breakpoint_hit = wait.name.as_deref() == Some("break") + && event.event_name == "breakpoint" + && event.breakpoint_id == Some(breakpoint.id) + && registers.instruction_offset == Some(native_entry_address); + if code_breakpoint_hit { + return Ok(( + json!({ + "continued": continued, + "wait": wait, + "event": event, + "preliminary_clr_notifications": preliminary_notifications + }), + true, + )); + } + + let is_clr_notification = wait.name.as_deref() == Some("break") + && event.event_name == "exception" + && event + .exception + .as_ref() + .is_some_and(|exception| exception.code == clr_notification_exception); + ensure!( + is_clr_notification, + "DbgEng stopped before the managed code breakpoint: wait={wait:?}, event={event:?}, instruction_pointer={:?}", + registers.instruction_offset + ); + preliminary_notifications.push(json!({ + "attempt": attempt, + "continued": continued, + "wait": wait, + "event": event + })); + clr_notification_pending = true; + } + + bail!( + "the CLR emitted {MAX_PRELIMINARY_NOTIFICATIONS} notifications before the managed code breakpoint" + ) +} + fn validate_module_load_filter(value: &str) -> anyhow::Result { let value = value.trim(); ensure!(!value.is_empty(), "--wait-for-module must not be empty"); @@ -487,6 +567,70 @@ fn module_image_path(module: &ModuleInfo, role: &str) -> anyhow::Result }) } +fn wait_for_dbgeng_managed_module_load( + session: &DebuggerSession, + wait_timeout_ms: u32, + clr_notification_exception: u32, +) -> anyhow::Result { + const MAX_PRELIMINARY_NOTIFICATIONS: usize = 64; + + let deadline = Instant::now() + Duration::from_millis(u64::from(wait_timeout_ms)); + let mut preliminary_notifications = Vec::new(); + let mut continue_as_handled = false; + for attempt in 1..=MAX_PRELIMINARY_NOTIFICATIONS { + let remaining = deadline.saturating_duration_since(Instant::now()); + ensure!( + !remaining.is_zero(), + "timed out waiting for DbgEng's managed module-load event" + ); + let remaining_ms = remaining.as_millis().clamp(1, u128::from(u32::MAX)) as u32; + let continued = if continue_as_handled { + session.continue_execution_handled()? + } else { + session.continue_execution()? + }; + let wait = session + .wait_for_event(remaining_ms) + .context("waiting for the managed module-load event")?; + let event = session + .last_event() + .context("reading the managed module-load event")?; + if wait.name.as_deref() == Some("break") + && event.event_name == "load_module" + && event.module_base.is_some() + { + return Ok(json!({ + "continued": continued, + "wait": wait, + "event": event, + "preliminary_clr_notifications": preliminary_notifications + })); + } + + let is_clr_notification = wait.name.as_deref() == Some("break") + && event.event_name == "exception" + && event + .exception + .as_ref() + .is_some_and(|exception| exception.code == clr_notification_exception); + ensure!( + is_clr_notification, + "DbgEng stopped before the requested managed module-load event: wait={wait:?}, event={event:?}" + ); + preliminary_notifications.push(json!({ + "attempt": attempt, + "continued": continued, + "wait": wait, + "event": event + })); + continue_as_handled = true; + } + + bail!( + "the CLR emitted {MAX_PRELIMINARY_NOTIFICATIONS} notifications before DbgEng reported the managed module-load event" + ) +} + fn wait_for_managed_module_in_dac( session: &DebuggerSession, dac: &windbg_dbgeng::CoreClrDacBridge, diff --git a/docs/architecture.md b/docs/architecture.md index 5f49566..daba587 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ or run [scripts/Get-TtdReplayRuntime.ps1](../scripts/Get-TtdReplayRuntime.ps1) d The native replay 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. -Live managed breakpoints use a separate [CoreCLR DAC bridge](../native/coreclr-dac-bridge/CMakeLists.txt), never the TTD bridge. It is a narrow C ABI that receives the active DbgEng client, hosts a version- and architecture-matched `mscordaccore.dll`, and implements `ICLRDataTarget` using DbgEng module, memory, thread, and context services. It exposes only opaque handles and POD output for module/method resolution, CLR notification registration, and representative generated-code addresses. It does not attach `ICorDebug`, load SOS, invoke debugger extension commands, or inject managed code. The x64 bridge is staged separately under `target/native/coreclr-dac-bridge`; the target DAC remains runtime-provided and is not redistributed. +Live managed breakpoints use a separate [CoreCLR DAC bridge](../native/coreclr-dac-bridge/CMakeLists.txt), never the TTD bridge. It is a narrow C ABI that receives the active DbgEng client, hosts a version- and architecture-matched `mscordaccore.dll`, and implements `ICLRDataTarget` using DbgEng module, memory, thread, and context services. It resolves metadata through a method definition, then obtains the matching method instance after code-generation notification; only that instance supplies the JIT-native breakpoint address. Its opt-in `ICLRDataTarget2` implementation uses DbgEng's active process handle solely for the CLR-requested JIT-notification-table allocation; the CLR then owns that allocation. It exposes only opaque handles and POD output for module/method resolution, CLR notification registration, and representative generated-code addresses. It does not attach `ICorDebug`, load SOS, invoke debugger extension commands, hollow the process, or inject managed code. The x64 bridge is staged separately under `target/native/coreclr-dac-bridge`; the target DAC remains runtime-provided and is not redistributed. ## Symbols diff --git a/docs/cli.md b/docs/cli.md index fe8053b..e38c1a9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -113,11 +113,13 @@ Use `--initial-break` when no reliable code breakpoint is available or the host ## Managed CoreCLR DAC breakpoints -`live managed-break` does not load SOS or execute `!bpmd`. It starts with a DbgEng create-process event, stops on CoreCLR and the requested managed module load, dynamically loads the exact x64 `mscordaccore.dll` that is a sibling of the target's loaded `coreclr.dll`, resolves the selected `MethodDef` through the DAC, requests CLR code-generation notification, then creates a DbgEng **hardware execute** breakpoint at the DAC-mapped native entry. `managed_breakpoint.hit` is true only when both the DbgEng breakpoint ID and current instruction pointer match that DAC-mapped address. +`live managed-break` does not load SOS or execute `!bpmd`. It starts with a DbgEng create-process event, stops on CoreCLR and the requested managed module load, dynamically loads the exact x64 `mscordaccore.dll` that is a sibling of the target's loaded `coreclr.dll`, resolves the selected `MethodDef` through the DAC, requests CLR code-generation notification, then creates a DbgEng **code** breakpoint at the DAC-mapped native entry. `managed_breakpoint.hit` is true only when both the DbgEng breakpoint ID and current instruction pointer match that DAC-mapped address. Build and stage `windbg_coreclr_dac_bridge.dll` with `cargo xtask native-build`; `cargo xtask package` places it beside `windbg-tool.exe`. A development build can set `WINDBG_CORECLR_DAC_BRIDGE_DLL` to the bridge DLL. The target DAC is never bundled: it must exactly match the target CoreCLR architecture and file version. -CLR's notification mechanism writes debugger-notification state into the target. Therefore `--allow-runtime-write` is explicit and should be used only in an approved test VM. Without it the command fails clearly before any target write; it does not disable endpoint protection or silently fall back to SOS. +CLR's notification mechanism writes debugger-notification state into the target. The DAC first requests a small JIT-notification table through `ICLRDataTarget2::AllocVirtual`; the bridge obtains the active target process handle from DbgEng and calls `VirtualAllocEx` only for that CLR-owned allocation. DbgEng then applies its normal code-breakpoint byte at the resolved entry. Therefore `--allow-runtime-write` is explicit and should be used only in an approved test VM. Without it the command fails clearly before any target allocation or write; it does not disable endpoint protection, hollow the process, or silently fall back to SOS. + +The bridge resolves metadata through `IXCLRDataMethodDefinition`, then uses the matching `IXCLRDataMethodInstance` after CLR code-generation notification. A definition's representative address is IL, not executable code; the code breakpoint always uses the instance's JIT-native entry address. ```powershell target\debug\windbg-tool.exe --compact live managed-break ` @@ -128,7 +130,27 @@ target\debug\windbg-tool.exe --compact live managed-break ` --end terminate ``` -The vertical slice resolves regular private methods as normal metadata. It requires a unique fully-qualified metadata name and currently rejects ambiguous overloads; C# signatures, generic instantiations, ReadyToRun indirection, tiered recompilation, re-JIT, and unload transitions remain explicit limits. The structured response keeps CoreCLR/module-load events, DAC runtime/token/entry resolution, CLR notification, hardware breakpoint, and final method-hit context distinct. +The vertical slice resolves regular private methods as normal metadata. It requires a unique fully-qualified metadata name and currently rejects ambiguous overloads; C# signatures, generic instantiations, ReadyToRun indirection, tiered recompilation, re-JIT, and unload transitions remain explicit limits. The structured response keeps CoreCLR/module-load events, DAC runtime/token/entry resolution, CLR notification, code-breakpoint configuration, and final method-hit context distinct. + +### Verified RDM x64 test-VM run + +The approved .NET 10.0.9 x64 test VM used this direct, no-SOS invocation: + +```powershell +$root = 'C:\Users\Public\windbg-tool-rdm-dac-24dd653' +$env:WINDBG_DBGENG_RUNTIME_DIR = "$root\tool" +$env:WINDBG_CORECLR_DAC_BRIDGE_DLL = "$root\tool\windbg_coreclr_dac_bridge.dll" + +& "$root\tool\windbg-tool.exe" --compact live managed-break ` + --command-line "`"$root\rdm\RemoteDesktopManager_x64.exe`" /AutoCloseAfter:60" ` + --managed-module RemoteDesktopManager.dll ` + --method Devolutions.RemoteDesktopManager.Program.Main ` + --allow-runtime-write --initial-break-timeout-ms 30000 --wait-timeout-ms 120000 --end terminate +``` + +That run reported `workflow: "live_managed_break_dac"`, a matching CoreCLR/DAC file version, `managed_resolution.method_after_code_generation.token: 100663305`, `representative_entry_address: 0x7FFC8ABDA8A0`, and `managed_breakpoint: { kind: "software_code", configured.id: 0, hit: true }`. The final context had `instruction_pointer: 0x7FFC8ABDA8A0` and `current_symbol: RemoteDesktopManager!Devolutions.RemoteDesktopManager.Program.Main(System.String[])`. + +The same command with `--method Devolutions.RemoteDesktopManager.Program.ApplyDpiAwarness` proved a private method without reflection or SOS: token `100663306`, native entry and final instruction pointer `0x7FFC8AC09920`, breakpoint ID `0`, and final symbol `RemoteDesktopManager!Devolutions.RemoteDesktopManager.Program.ApplyDpiAwarness()`. ## AI-oriented DbgEng target inspection diff --git a/docs/development.md b/docs/development.md index 2e59cee..3b6e231 100644 --- a/docs/development.md +++ b/docs/development.md @@ -71,7 +71,7 @@ target\debug\windbg-tool.exe live capabilities The live backend securely preloads `dbgeng.dll` from this directory (or from beside `windbg-tool.exe` in a package) so its dependent DLLs resolve from the matching runtime set. It does not alter global DLL-search or security policy. -For a direct managed breakpoint, the DAC bridge loads `mscordaccore.dll` only from the same directory as the CoreCLR module loaded by the target and requires exact file-version equality. It does not bundle a DAC or SOS. Enabling CLR module/code notifications requires `--allow-runtime-write`, which causes the DAC to write CLR debugger-notification state through DbgEng; run that workflow only in an approved test VM. +For a direct managed breakpoint, the DAC bridge loads `mscordaccore.dll` only from the same directory as the CoreCLR module loaded by the target and requires exact file-version equality. It does not bundle a DAC or SOS. Enabling CLR module/code notifications requires `--allow-runtime-write`: the DAC requests a CLR-owned JIT-notification allocation through `ICLRDataTarget2`, and the bridge uses DbgEng's active process handle with `VirtualAllocEx` before writing CLR debugger-notification state. This is not process hollowing or code injection; run the intentionally target-mutating workflow only in an approved test VM. 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. diff --git a/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.cpp b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.cpp index e404688..7a33be6 100644 --- a/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.cpp +++ b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.cpp @@ -18,6 +18,11 @@ using CLRDATA_ADDRESS = ULONG64; using CLRDATA_ENUM = ULONG64; using mdMethodDef = ULONG32; +struct CLRDATA_ADDRESS_RANGE { + CLRDATA_ADDRESS start_address; + CLRDATA_ADDRESS end_address; +}; + struct IXCLRDataProcess; struct IXCLRDataModule; struct IXCLRDataMethodDefinition; @@ -37,6 +42,8 @@ static constexpr ULONG32 CLRDATA_NOTIFY_ON_MODULE_LOAD = 0x00000001; static const IID IID_ICLRDataTarget = {0x3e11ccee, 0xd08b, 0x43e5, {0xaf, 0x01, 0x32, 0x71, 0x7a, 0x64, 0xda, 0x03}}; +static const IID IID_ICLRDataTarget2 = + {0x6d05fae3, 0x189c, 0x4630, {0xa6, 0xdc, 0x1c, 0x25, 0x1e, 0x1c, 0x01, 0xab}}; static const IID IID_IXCLRDataProcess = {0x5c552ab6, 0xfc09, 0x4cb3, {0x8e, 0x36, 0x22, 0xfa, 0x03, 0xc7, 0x98, 0xb7}}; @@ -74,6 +81,19 @@ struct ICLRDataTarget : IUnknown { BYTE* output) = 0; }; +struct ICLRDataTarget2 : ICLRDataTarget { + virtual HRESULT STDMETHODCALLTYPE AllocVirtual( + CLRDATA_ADDRESS address, + ULONG32 size, + ULONG32 type_flags, + ULONG32 protect_flags, + CLRDATA_ADDRESS* allocation) = 0; + virtual HRESULT STDMETHODCALLTYPE FreeVirtual( + CLRDATA_ADDRESS address, + ULONG32 size, + ULONG32 type_flags) = 0; +}; + // Only the prefix used by this bridge is declared. Method ordering is the ABI. struct IXCLRDataProcess : IUnknown { virtual HRESULT STDMETHODCALLTYPE Flush() = 0; @@ -215,6 +235,50 @@ struct IXCLRDataMethodDefinition : IUnknown { virtual HRESULT STDMETHODCALLTYPE GetRepresentativeEntryAddress(CLRDATA_ADDRESS* address) = 0; }; +struct IXCLRDataMethodInstance : IUnknown { + virtual HRESULT STDMETHODCALLTYPE GetTypeInstance(IXCLRDataTypeInstance** type) = 0; + virtual HRESULT STDMETHODCALLTYPE GetDefinition(IXCLRDataMethodDefinition** method) = 0; + virtual HRESULT STDMETHODCALLTYPE GetTokenAndScope(mdMethodDef* token, IXCLRDataModule** module) = 0; + virtual HRESULT STDMETHODCALLTYPE GetName( + ULONG32 flags, + ULONG32 buffer_length, + ULONG32* name_length, + WCHAR* name) = 0; + virtual HRESULT STDMETHODCALLTYPE GetFlags(ULONG32* flags) = 0; + virtual HRESULT STDMETHODCALLTYPE IsSameObject(IXCLRDataMethodInstance* method) = 0; + virtual HRESULT STDMETHODCALLTYPE GetEnCVersion(ULONG32* version) = 0; + virtual HRESULT STDMETHODCALLTYPE GetNumTypeArguments(ULONG32* argument_count) = 0; + virtual HRESULT STDMETHODCALLTYPE GetTypeArgumentByIndex( + ULONG32 index, + IXCLRDataTypeInstance** type) = 0; + virtual HRESULT STDMETHODCALLTYPE GetILOffsetsByAddress( + CLRDATA_ADDRESS address, + ULONG32 offset_count, + ULONG32* offsets_needed, + ULONG32* offsets) = 0; + virtual HRESULT STDMETHODCALLTYPE GetAddressRangesByILOffset( + ULONG32 il_offset, + ULONG32 range_count, + ULONG32* ranges_needed, + CLRDATA_ADDRESS_RANGE* ranges) = 0; + virtual HRESULT STDMETHODCALLTYPE GetILAddressMap( + ULONG32 map_count, + ULONG32* maps_needed, + void* maps) = 0; + virtual HRESULT STDMETHODCALLTYPE StartEnumExtents(CLRDATA_ENUM* handle) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumExtent( + CLRDATA_ENUM* handle, + CLRDATA_ADDRESS_RANGE* extent) = 0; + virtual HRESULT STDMETHODCALLTYPE EndEnumExtents(CLRDATA_ENUM handle) = 0; + virtual HRESULT STDMETHODCALLTYPE Request( + ULONG32 request_code, + ULONG32 input_size, + BYTE* input, + ULONG32 output_size, + BYTE* output) = 0; + virtual HRESULT STDMETHODCALLTYPE GetRepresentativeEntryAddress(CLRDATA_ADDRESS* address) = 0; +}; + using CLRDataCreateInstanceFn = HRESULT(STDAPICALLTYPE*)(REFIID iid, ICLRDataTarget* target, void** interface_pointer); @@ -309,7 +373,7 @@ std::wstring sibling_dac_path(const wchar_t* coreclr_path) { return path; } -class DbgEngDataTarget final : public ICLRDataTarget { +class DbgEngDataTarget final : public ICLRDataTarget2 { public: DbgEngDataTarget(IDebugClient5* client, bool allow_target_writes) : client_(client), allow_target_writes_(allow_target_writes) { @@ -348,6 +412,11 @@ class DbgEngDataTarget final : public ICLRDataTarget { AddRef(); return S_OK; } + if (iid == IID_ICLRDataTarget2) { + *object = static_cast(this); + AddRef(); + return S_OK; + } return E_NOINTERFACE; } @@ -544,7 +613,115 @@ class DbgEngDataTarget final : public ICLRDataTarget { return E_NOTIMPL; } + HRESULT STDMETHODCALLTYPE AllocVirtual( + CLRDATA_ADDRESS address, + ULONG32 size, + ULONG32 type_flags, + ULONG32 protect_flags, + CLRDATA_ADDRESS* allocation) override { + if (allocation == nullptr) { + diagnostic_ = L"ICLRDataTarget2::AllocVirtual received a null allocation output pointer."; + return E_POINTER; + } + if (!allow_target_writes_) { + diagnostic_ = + L"ICLRDataTarget2::AllocVirtual was rejected because runtime writes were not explicitly enabled."; + return E_ACCESSDENIED; + } + + HANDLE process_handle = nullptr; + HRESULT result = current_process_handle(&process_handle); + if (FAILED(result)) { + return result; + } + void* const allocated = + VirtualAllocEx(process_handle, reinterpret_cast(address), size, type_flags, protect_flags); + if (allocated == nullptr) { + const DWORD error = GetLastError(); + result = HRESULT_FROM_WIN32(error); + wchar_t message[320]{}; + swprintf_s( + message, + L"ICLRDataTarget2::AllocVirtual(0x%llX, %u, 0x%X, 0x%X) failed with Win32 error %lu.", + static_cast(address), + size, + type_flags, + protect_flags, + error); + diagnostic_ = message; + return result; + } + *allocation = reinterpret_cast(allocated); + wchar_t message[320]{}; + swprintf_s( + message, + L"ICLRDataTarget2::AllocVirtual(0x%llX, %u, 0x%X, 0x%X) allocated 0x%llX.", + static_cast(address), + size, + type_flags, + protect_flags, + static_cast(*allocation)); + diagnostic_ = message; + return S_OK; + } + + HRESULT STDMETHODCALLTYPE FreeVirtual( + CLRDATA_ADDRESS address, + ULONG32 size, + ULONG32 type_flags) override { + if (!allow_target_writes_) { + diagnostic_ = + L"ICLRDataTarget2::FreeVirtual was rejected because runtime writes were not explicitly enabled."; + return E_ACCESSDENIED; + } + + HANDLE process_handle = nullptr; + HRESULT result = current_process_handle(&process_handle); + if (FAILED(result)) { + return result; + } + if (!VirtualFreeEx(process_handle, reinterpret_cast(address), size, type_flags)) { + const DWORD error = GetLastError(); + result = HRESULT_FROM_WIN32(error); + wchar_t message[256]{}; + swprintf_s( + message, + L"ICLRDataTarget2::FreeVirtual(0x%llX, %u, 0x%X) failed with Win32 error %lu.", + static_cast(address), + size, + type_flags, + error); + diagnostic_ = message; + return result; + } + wchar_t message[256]{}; + swprintf_s( + message, + L"ICLRDataTarget2::FreeVirtual(0x%llX, %u, 0x%X) succeeded.", + static_cast(address), + size, + type_flags); + diagnostic_ = message; + return S_OK; + } + private: + HRESULT current_process_handle(HANDLE* process_handle) { + if (process_handle == nullptr || system_objects_.get() == nullptr) { + diagnostic_ = + L"ICLRDataTarget2 requires an output process handle and IDebugSystemObjects4."; + return E_POINTER; + } + ULONG64 raw_process_handle = 0; + const HRESULT result = system_objects_.get()->GetCurrentProcessHandle(&raw_process_handle); + if (FAILED(result)) { + diagnostic_ = format_hresult(L"IDebugSystemObjects4::GetCurrentProcessHandle", result); + return result; + } + *process_handle = reinterpret_cast(raw_process_handle); + return S_OK; + } + ~DbgEngDataTarget() = default; LONG references_ = 1; @@ -562,6 +739,7 @@ struct WindbgDacBridge { : target(new DbgEngDataTarget(client, allow_target_writes)) {} ~WindbgDacBridge() { + method_instance.reset(); method.reset(); process.reset(); target->Release(); @@ -573,11 +751,40 @@ struct WindbgDacBridge { DbgEngDataTarget* target; ComReference process; ComReference method; + ComReference method_instance; HMODULE dac_module = nullptr; std::wstring dac_path; + std::wstring managed_module_path; + mdMethodDef method_token = 0; }; -void populate_method_info(IXCLRDataMethodDefinition* method, WindbgDacMethodInfo* info) { +HRESULT find_method_instance( + IXCLRDataMethodDefinition* method, + ComReference* instance) { + instance->reset(); + CLRDATA_ENUM enumeration = 0; + HRESULT result = method->StartEnumInstances(nullptr, &enumeration); + if (result == S_FALSE || FAILED(result)) { + return result; + } + + ComReference selected; + result = method->EnumInstance(&enumeration, selected.put()); + const HRESULT end_result = method->EndEnumInstances(enumeration); + if (FAILED(result) || result == S_FALSE) { + return result; + } + if (FAILED(end_result)) { + return end_result; + } + instance->reset(selected.detach()); + return S_OK; +} + +HRESULT populate_method_info( + IXCLRDataMethodDefinition* method, + ComReference* method_instance, + WindbgDacMethodInfo* info) { memset(info, 0, sizeof(*info)); std::array name{}; @@ -590,11 +797,30 @@ void populate_method_info(IXCLRDataMethodDefinition* method, WindbgDacMethodInfo method->GetTokenAndScope(&info->method_token, scope.put()); info->code_notification_flags = CLRDATA_METHNOTIFY_GENERATED | CLRDATA_METHNOTIFY_DISCARDED; + // Method definitions expose IL extents; only a method instance exposes JIT-native code. + const HRESULT instance_result = find_method_instance(method, method_instance); + if (instance_result == S_FALSE) { + return S_OK; + } + if (FAILED(instance_result)) { + return instance_result; + } + CLRDATA_ADDRESS entry_address = 0; - if (SUCCEEDED(method->GetRepresentativeEntryAddress(&entry_address)) && entry_address != 0) { + const HRESULT entry_result = + method_instance->get()->GetRepresentativeEntryAddress(&entry_address); + if (entry_result == E_UNEXPECTED) { + method_instance->reset(); + return S_OK; + } + if (FAILED(entry_result)) { + return entry_result; + } + if (entry_address != 0) { info->representative_entry_address = entry_address; info->code_available = 1; } + return S_OK; } bool module_paths_match(const std::wstring& expected, const std::wstring& actual) { @@ -776,6 +1002,22 @@ extern "C" WindbgDacStatus windbg_dac_enable_module_load_notifications(WindbgDac return WINDBG_DAC_OK; } +extern "C" WindbgDacStatus windbg_dac_disable_module_load_notifications(WindbgDacBridge* bridge) { + g_last_error.clear(); + if (bridge == nullptr) { + return fail(WINDBG_DAC_INVALID_ARGUMENT, L"A bridge is required."); + } + + const HRESULT result = bridge->process.get()->SetOtherNotificationFlags(0); + if (FAILED(result)) { + return fail( + WINDBG_DAC_ERROR, + format_hresult(L"Disabling CLR managed-module load notifications", result) + + L" DAC callback diagnostics: " + bridge->target->diagnostic()); + } + return WINDBG_DAC_OK; +} + extern "C" WindbgDacStatus windbg_dac_is_module_loaded( WindbgDacBridge* bridge, const wchar_t* managed_module_path, @@ -862,11 +1104,28 @@ extern "C" WindbgDacStatus windbg_dac_resolve_and_notify( } bridge->method.reset(selected.detach()); - populate_method_info(bridge->method.get(), method_info); + bridge->method_instance.reset(); + result = populate_method_info( + bridge->method.get(), + &bridge->method_instance, + method_info); + if (FAILED(result)) { + bridge->method.reset(); + bridge->method_instance.reset(); + return fail( + WINDBG_DAC_ERROR, + format_hresult(L"Resolving a native code instance for the managed method", result)); + } + method_info->matching_method_count = count; + bridge->managed_module_path = managed_module_path; + bridge->method_token = method_info->method_token; result = bridge->method.get()->SetCodeNotification(method_info->code_notification_flags); if (FAILED(result)) { bridge->method.reset(); - return fail(WINDBG_DAC_ERROR, format_hresult(L"Requesting CLR code-generation notification", result)); + return fail( + WINDBG_DAC_ERROR, + format_hresult(L"Requesting CLR code-generation notification", result) + + L" DAC callback diagnostics: " + bridge->target->diagnostic()); } return WINDBG_DAC_OK; @@ -883,7 +1142,35 @@ extern "C" WindbgDacStatus windbg_dac_refresh_method_code( return fail(WINDBG_DAC_NOT_FOUND, L"No managed method has been resolved for this bridge."); } - populate_method_info(bridge->method.get(), method_info); + if (bridge->managed_module_path.empty() || bridge->method_token == 0) { + return fail( + WINDBG_DAC_ERROR, + L"The resolved managed method does not retain a module path and metadata token."); + } + ComReference module; + const WindbgDacStatus module_status = + find_module_by_path(bridge->process.get(), bridge->managed_module_path.c_str(), &module); + if (module_status != WINDBG_DAC_OK) { + return module_status; + } + bridge->method_instance.reset(); + bridge->method.reset(); + const HRESULT definition_result = + module.get()->GetMethodDefinitionByToken(bridge->method_token, bridge->method.put()); + if (FAILED(definition_result) || bridge->method.get() == nullptr) { + return fail( + WINDBG_DAC_ERROR, + format_hresult(L"Reopening the managed method definition after CLR code generation", definition_result)); + } + const HRESULT result = populate_method_info( + bridge->method.get(), + &bridge->method_instance, + method_info); + if (FAILED(result)) { + return fail( + WINDBG_DAC_ERROR, + format_hresult(L"Refreshing the managed method native code instance", result)); + } method_info->matching_method_count = 1; if (method_info->code_available == 0) { return fail(WINDBG_DAC_CODE_UNAVAILABLE, L"The managed method does not have a representative native entry address yet."); diff --git a/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h index e18b1c9..50ca275 100644 --- a/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h +++ b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h @@ -56,6 +56,9 @@ WINDBG_DAC_EXPORT void windbg_dac_destroy(WindbgDacBridge* bridge); WINDBG_DAC_EXPORT WindbgDacStatus windbg_dac_enable_module_load_notifications( WindbgDacBridge* bridge); +WINDBG_DAC_EXPORT WindbgDacStatus windbg_dac_disable_module_load_notifications( + WindbgDacBridge* bridge); + WINDBG_DAC_EXPORT WindbgDacStatus windbg_dac_is_module_loaded( WindbgDacBridge* bridge, const wchar_t* managed_module_path, From 0e9e8d0248590f9e150f14a096da92cfde94c772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Jul 2026 11:24:45 -0400 Subject: [PATCH 04/12] Add direct managed overload breakpoints Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-dbgeng/src/coreclr_dac.rs | 89 +++- crates/windbg-dbgeng/src/lib.rs | 187 ++++++-- crates/windbg-tool/src/cli.rs | 6 + crates/windbg-tool/src/cli/platform.rs | 75 +++- .../ManagedBreakpointFixture.csproj | 11 + .../ManagedBreakpointFixture/Program.cs | 32 ++ .../ManagedBreakpointFixture.deps.json | 24 + ...anagedBreakpointFixture.runtimeconfig.json | 13 + ...BreakpointFixture.csproj.nuget.dgspec.json | 356 +++++++++++++++ ...agedBreakpointFixture.csproj.nuget.g.props | 16 + ...edBreakpointFixture.csproj.nuget.g.targets | 2 + ...oreApp,Version=v10.0.AssemblyAttributes.cs | 4 + .../ManagedBreakpointFixture.AssemblyInfo.cs | 22 + ...BreakpointFixture.AssemblyInfoInputs.cache | 1 + ....GeneratedMSBuildEditorConfig.editorconfig | 18 + ...ManagedBreakpointFixture.GlobalUsings.g.cs | 8 + .../ManagedBreakpointFixture.assets.cache | Bin 0 -> 231 bytes ...ointFixture.csproj.CoreCompileInputs.cache | 1 + ...akpointFixture.csproj.FileListAbsolute.txt | 14 + ...edBreakpointFixture.genruntimeconfig.cache | 1 + .../obj/project.assets.json | 363 +++++++++++++++ .../obj/project.nuget.cache | 8 + docs/architecture.md | 2 +- docs/cli.md | 44 +- docs/development.md | 12 +- .../windbg_coreclr_dac_bridge.cpp | 420 +++++++++++++++++- .../windbg_coreclr_dac_bridge.h | 19 + 27 files changed, 1700 insertions(+), 48 deletions(-) create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/ManagedBreakpointFixture.csproj create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.deps.json create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.runtimeconfig.json create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.dgspec.json create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.props create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.targets create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfo.cs create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfoInputs.cache create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GeneratedMSBuildEditorConfig.editorconfig create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GlobalUsings.g.cs create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.assets.cache create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.csproj.CoreCompileInputs.cache create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.csproj.FileListAbsolute.txt create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.genruntimeconfig.cache create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/project.assets.json create mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/project.nuget.cache diff --git a/crates/windbg-dbgeng/src/coreclr_dac.rs b/crates/windbg-dbgeng/src/coreclr_dac.rs index 302f0ac..b49040b 100644 --- a/crates/windbg-dbgeng/src/coreclr_dac.rs +++ b/crates/windbg-dbgeng/src/coreclr_dac.rs @@ -20,6 +20,8 @@ const WINDBG_DAC_NOT_FOUND: u32 = 3; const WINDBG_DAC_AMBIGUOUS: u32 = 4; const WINDBG_DAC_CODE_UNAVAILABLE: u32 = 5; const MAX_WIDE_CHARS: usize = 1024; +const MAX_SIGNATURE_HEX_CHARS: usize = 1024; +const MAX_METHOD_CANDIDATES: usize = 8; #[repr(C)] #[derive(Clone, Copy)] @@ -39,6 +41,15 @@ impl Default for NativeRuntimeInfo { } } +#[repr(C)] +#[derive(Clone, Copy)] +struct NativeMethodCandidate { + method_token: u32, + signature_truncated: u8, + reserved: [u8; 3], + signature_hex: [u16; MAX_SIGNATURE_HEX_CHARS], +} + #[repr(C)] #[derive(Clone, Copy)] struct NativeMethodInfo { @@ -49,6 +60,11 @@ struct NativeMethodInfo { code_available: u8, reserved: [u8; 3], resolved_method: [u16; MAX_WIDE_CHARS], + resolved_signature_hex: [u16; MAX_SIGNATURE_HEX_CHARS], + reported_candidate_count: u32, + candidates_truncated: u8, + reserved2: [u8; 3], + candidates: [NativeMethodCandidate; MAX_METHOD_CANDIDATES], } impl Default for NativeMethodInfo { @@ -77,6 +93,8 @@ type ResolveAndNotify = unsafe extern "C" fn( bridge: *mut c_void, managed_module_path: *const u16, fully_qualified_method: *const u16, + signature_blob: *const u8, + signature_blob_length: u32, method_info: *mut NativeMethodInfo, ) -> u32; type RefreshMethodCode = @@ -91,11 +109,21 @@ pub struct ManagedRuntimeInfo { pub dac_file_version: (u32, u32), } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManagedMethodCandidate { + pub token: u32, + pub signature_hex: String, + pub signature_truncated: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ManagedMethodInfo { pub token: u32, pub matching_method_count: u32, + pub matching_method_candidates: Vec, + pub matching_method_candidates_truncated: bool, pub resolved_method: String, + pub signature_hex: String, pub code_notification_flags: u32, pub representative_entry_address: Option, } @@ -247,15 +275,28 @@ impl CoreClrDacBridge { &mut self, managed_module_path: &Path, fully_qualified_method: &str, + signature_blob: Option<&[u8]>, ) -> anyhow::Result<(ManagedMethodInfo, ManagedCodeAvailability)> { let managed_module_path = to_wide(managed_module_path.as_os_str())?; let method = to_wide(OsStr::new(fully_qualified_method))?; + let (signature_blob, signature_blob_length) = signature_blob + .map(|signature| { + ensure!( + !signature.is_empty(), + "the CoreCLR DAC bridge signature selector must not be empty" + ); + Ok((signature.as_ptr(), signature.len() as u32)) + }) + .transpose()? + .unwrap_or((std::ptr::null(), 0)); let mut native_method = NativeMethodInfo::default(); let status = unsafe { (self.resolve_and_notify)( self.bridge.as_ptr(), managed_module_path.as_ptr(), method.as_ptr(), + signature_blob, + signature_blob_length, &mut native_method, ) }; @@ -286,8 +327,9 @@ impl CoreClrDacBridge { WINDBG_DAC_OK => Ok(()), WINDBG_DAC_NOT_FOUND => bail!("{operation} failed: {}", bridge_error(self.last_error)), WINDBG_DAC_AMBIGUOUS => bail!( - "{operation} failed because {} method definitions matched; exact signature selection is required", - native_method.matching_method_count + "{operation} failed because {} method definitions matched: {}. Supply --signature with an exact ECMA-335 MethodDef signature blob", + native_method.matching_method_count, + method_candidate_summary(native_method) ), WINDBG_DAC_CODE_UNAVAILABLE => { bail!("{operation} failed: {}", bridge_error(self.last_error)) @@ -375,7 +417,13 @@ fn managed_method_info(native: &NativeMethodInfo) -> anyhow::Result anyhow::Result anyhow::Result> { + let count = native.reported_candidate_count as usize; + ensure!( + count <= MAX_METHOD_CANDIDATES, + "the native bridge reported too many managed method candidates" + ); + native.candidates[..count] + .iter() + .map(|candidate| { + Ok(ManagedMethodCandidate { + token: candidate.method_token, + signature_hex: utf16_field( + &candidate.signature_hex, + "managed method candidate metadata signature", + )?, + signature_truncated: candidate.signature_truncated != 0, + }) + }) + .collect() +} + +fn method_candidate_summary(native: &NativeMethodInfo) -> String { + match native_method_candidates(native) { + Ok(candidates) if candidates.is_empty() => { + "no candidate signatures were returned".to_string() + } + Ok(candidates) => candidates + .into_iter() + .map(|candidate| format!("0x{:08X}:{}", candidate.token, candidate.signature_hex)) + .collect::>() + .join(", "), + Err(error) => format!("candidate signature diagnostics were unavailable: {error}"), + } +} + fn utf16_field(value: &[u16], name: &str) -> anyhow::Result { let length = value .iter() diff --git a/crates/windbg-dbgeng/src/lib.rs b/crates/windbg-dbgeng/src/lib.rs index 4640335..a811bc4 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -22,6 +22,13 @@ pub const NT_SYMCACHE_PATH_ENV: &str = "_NT_SYMCACHE_PATH"; pub const DBGENG_RUNTIME_DIR_ENV: &str = "WINDBG_DBGENG_RUNTIME_DIR"; const DEFAULT_DBGENG_SYMBOL_CACHE: &str = ".windbg-symbol-cache"; const DBGENG_DLL_NAME: &str = "dbgeng.dll"; +#[cfg(windows)] +const DBGENG_RUNTIME_COMPONENTS: [&str; 4] = [ + "dbgcore.dll", + "dbghelp.dll", + "dbgmodel.dll", + DBGENG_DLL_NAME, +]; const DBGENG_WAIT_TIMEOUT_HRESULT: i32 = 1; #[derive(Debug, Clone, PartialEq, Eq)] @@ -123,44 +130,82 @@ fn dbgeng_runtime_dll( } #[cfg(windows)] -fn ensure_dbgeng_runtime_loaded() -> anyhow::Result<()> { +fn load_library_from_path( + path: &Path, + component: &str, +) -> anyhow::Result { use windows::core::PCWSTR; use windows::Win32::System::LibraryLoader::{ LoadLibraryExW, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, }; - static LOAD_RESULT: OnceLock> = OnceLock::new(); + let mut path_wide = path.as_os_str().encode_wide().collect::>(); + path_wide.push(0); + unsafe { + LoadLibraryExW( + PCWSTR(path_wide.as_ptr()), + None, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, + ) + } + .with_context(|| format!("loading {component} {}", path.display())) +} + +#[cfg(windows)] +fn ensure_dbgeng_runtime_loaded() -> anyhow::Result { + use windows::core::PCWSTR; + use windows::Win32::Foundation::HMODULE; + use windows::Win32::System::LibraryLoader::{ + LoadLibraryExW, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, LOAD_LIBRARY_SEARCH_SYSTEM32, + }; + + static LOAD_RESULT: OnceLock> = OnceLock::new(); let result = LOAD_RESULT.get_or_init(|| { - (|| -> anyhow::Result<()> { + (|| -> anyhow::Result { let explicit_runtime_dir = env::var_os(DBGENG_RUNTIME_DIR_ENV).map(PathBuf::from); let executable_path = env::current_exe().ok(); let dll = dbgeng_runtime_dll(explicit_runtime_dir.as_deref(), executable_path.as_deref())?; let Some(dll) = dll else { - return Ok(()); + let mut component_wide = "dbgeng.dll".encode_utf16().collect::>(); + component_wide.push(0); + let module = unsafe { + LoadLibraryExW( + PCWSTR(component_wide.as_ptr()), + None, + LOAD_LIBRARY_SEARCH_SYSTEM32 | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, + ) + } + .context("loading DbgEng from the system runtime")?; + return Ok(module.0 as usize); }; - let mut dll_wide = dll.as_os_str().encode_wide().collect::>(); - dll_wide.push(0); - // Keep the explicitly loaded module alive so DbgEng resolves all of its runtime - // dependencies from its own directory before DebugCreate is invoked. - unsafe { - LoadLibraryExW( - PCWSTR(dll_wide.as_ptr()), - None, - LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, - ) + let runtime_dir = dll + .parent() + .context("the selected DbgEng runtime DLL has no parent directory")?; + // Load the version-matched companions before DbgEng so its imports resolve from the + // staged runtime set instead of depending on ambient DLL-search state. + for component_name in DBGENG_RUNTIME_COMPONENTS { + let component = runtime_dir.join(component_name); + ensure!( + component.is_file(), + "the selected DbgEng runtime is missing required component {}", + component.display() + ); + let module = load_library_from_path(&component, "DbgEng runtime component")?; + if component_name == DBGENG_DLL_NAME { + return Ok(module.0 as usize); + } } - .with_context(|| format!("loading DbgEng runtime {}", dll.display()))?; - Ok(()) + unreachable!("the DbgEng runtime component list must include dbgeng.dll") })() .map_err(|error| format!("{error:#}")) }); - if let Err(error) = result { - bail!("{error}"); + match result { + Ok(module) => Ok(HMODULE(*module as *mut _)), + Err(error) => bail!("{error}"), } - Ok(()) } #[cfg(windows)] @@ -187,10 +232,31 @@ fn enable_create_process_stop( #[cfg(windows)] fn create_debug_client( ) -> anyhow::Result { - use windows::Win32::System::Diagnostics::Debug::Extensions::DebugCreate; - - ensure_dbgeng_runtime_loaded()?; - unsafe { DebugCreate() }.context("DbgEng DebugCreate failed") + use std::ffi::c_void; + use windows::core::{Interface, PCSTR}; + use windows::Win32::System::Diagnostics::Debug::Extensions::IDebugClient5; + use windows::Win32::System::LibraryLoader::GetProcAddress; + + type DebugCreateFn = unsafe extern "system" fn( + *const windows::core::GUID, + *mut *mut c_void, + ) -> windows::core::HRESULT; + + let module = ensure_dbgeng_runtime_loaded()?; + let procedure = unsafe { GetProcAddress(module, PCSTR(c"DebugCreate".as_ptr().cast())) } + .context("the selected DbgEng runtime does not export DebugCreate")?; + // GetProcAddress returns an untyped module export. DebugCreate has the documented + // DbgEng ABI and the module is retained by ensure_dbgeng_runtime_loaded. + let debug_create: DebugCreateFn = unsafe { std::mem::transmute(procedure) }; + let mut client = std::ptr::null_mut(); + unsafe { debug_create(&IDebugClient5::IID, &mut client) } + .ok() + .context("DbgEng DebugCreate failed")?; + ensure!( + !client.is_null(), + "DbgEng DebugCreate succeeded without returning an IDebugClient5" + ); + Ok(unsafe { IDebugClient5::from_raw(client) }) } #[cfg(windows)] @@ -1766,6 +1832,42 @@ fn encode_hex(bytes: &[u8]) -> String { result } +#[cfg(windows)] +fn load_dbghelp_module() -> anyhow::Result { + use windows::core::PCWSTR; + use windows::Win32::System::LibraryLoader::{ + LoadLibraryExW, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, LOAD_LIBRARY_SEARCH_SYSTEM32, + }; + + let explicit_runtime_dir = env::var_os(DBGENG_RUNTIME_DIR_ENV).map(PathBuf::from); + let executable_path = env::current_exe().ok(); + if let Some(dbgeng) = + dbgeng_runtime_dll(explicit_runtime_dir.as_deref(), executable_path.as_deref())? + { + let runtime_dir = dbgeng + .parent() + .context("the selected DbgEng runtime DLL has no parent directory")?; + let dbghelp = runtime_dir.join("dbghelp.dll"); + ensure!( + dbghelp.is_file(), + "the selected DbgEng runtime is missing required component {}", + dbghelp.display() + ); + return load_library_from_path(&dbghelp, "DbgHelp runtime component"); + } + + let mut component_wide = "dbghelp.dll".encode_utf16().collect::>(); + component_wide.push(0); + unsafe { + LoadLibraryExW( + PCWSTR(component_wide.as_ptr()), + None, + LOAD_LIBRARY_SEARCH_SYSTEM32 | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, + ) + } + .context("loading DbgHelp from the system runtime") +} + #[cfg(windows)] fn write_process_dump_file( process_id: u32, @@ -1773,14 +1875,17 @@ fn write_process_dump_file( detached: bool, options: DumpWriteOptions, ) -> anyhow::Result { + use std::ffi::c_void; use std::fs::OpenOptions; use std::os::windows::io::AsRawHandle; - use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::core::{Error, PCSTR}; + use windows::Win32::Foundation::{CloseHandle, BOOL, HANDLE}; use windows::Win32::System::Diagnostics::Debug::{ MiniDumpWithDataSegs, MiniDumpWithFullMemory, MiniDumpWithFullMemoryInfo, MiniDumpWithHandleData, MiniDumpWithProcessThreadData, MiniDumpWithThreadInfo, - MiniDumpWithUnloadedModules, MiniDumpWriteDump, MINIDUMP_TYPE, + MiniDumpWithUnloadedModules, MINIDUMP_TYPE, }; + use windows::Win32::System::LibraryLoader::GetProcAddress; use windows::Win32::System::Threading::{ OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ, }; @@ -1817,23 +1922,41 @@ fn write_process_dump_file( } }; + type MiniDumpWriteDumpFn = unsafe extern "system" fn( + HANDLE, + u32, + HANDLE, + MINIDUMP_TYPE, + *const c_void, + *const c_void, + *const c_void, + ) -> BOOL; + + let dbghelp = load_dbghelp_module()?; + let procedure = unsafe { GetProcAddress(dbghelp, PCSTR(c"MiniDumpWriteDump".as_ptr().cast())) } + .context("the selected DbgHelp runtime does not export MiniDumpWriteDump")?; + // GetProcAddress returns an untyped module export. MiniDumpWriteDump has the documented + // DbgHelp ABI and the module remains loaded for the duration of this call. + let mini_dump_write_dump: MiniDumpWriteDumpFn = unsafe { std::mem::transmute(procedure) }; let write_result = unsafe { - MiniDumpWriteDump( + mini_dump_write_dump( process, process_id, HANDLE(file.as_raw_handle()), dump_type, - None, - None, - None, + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), ) }; unsafe { CloseHandle(process)?; } - if let Err(error) = write_result { - return Err(error).context("MiniDumpWriteDump failed"); - } + ensure!( + write_result.as_bool(), + "MiniDumpWriteDump failed: {}", + Error::from_win32() + ); drop(file); let metadata = std::fs::metadata(&options.path) .with_context(|| format!("dump file was not created: {}", options.path.display()))?; diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 9930986..9f3b59f 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -729,6 +729,12 @@ struct LiveManagedBreakArgs { help = "Fully qualified managed metadata method name, for example Namespace.Type.Method" )] method: String, + #[arg( + long, + value_name = "HEX", + help = "Optional exact ECMA-335 MethodDef signature bytes in hexadecimal, for example 00010E0E; required to select an overload" + )] + signature: Option, #[arg( long, help = "Allow the matching DAC to write CLR debugger-notification state; use only in an approved test VM" diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 7434f80..4679cc0 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -189,6 +189,7 @@ pub(super) fn run_live_managed_break( let end = parse_live_launch_end(&args.end)?; let managed_module = validate_managed_module(&args.managed_module)?; let method = validate_managed_breakpoint_token(&args.method, "--method")?; + let signature = parse_managed_method_signature(args.signature.as_deref())?; let session = launch_live_session(LiveLaunchSessionOptions { command_line: args.command_line.clone(), initial_break_timeout_ms: args.initial_break_timeout_ms, @@ -266,7 +267,7 @@ pub(super) fn run_live_managed_break( dac.disable_module_load_notifications() .context("disabling CLR managed-module load notifications after module discovery")?; let (resolved_method, availability) = dac - .resolve_and_notify(&managed_module_path, &method) + .resolve_and_notify(&managed_module_path, &method, signature.as_deref()) .with_context(|| { format!( "resolving {method} in the selected managed module {managed_module} through the DAC" @@ -356,10 +357,11 @@ pub(super) fn run_live_managed_break( }, "managed_resolution": { "method_request": method, + "signature_request_hex": signature.as_deref().map(format_managed_method_signature), "resolved_method": resolved_method, "method_after_code_generation": generated_method, "runtime_writes_explicitly_allowed": args.allow_runtime_write, - "exact_overload_signature_selection": false, + "exact_overload_signature_selection": signature.is_some(), "private_methods_supported": true }, "code_generation": { @@ -380,7 +382,7 @@ pub(super) fn run_live_managed_break( }, "context": context, "limitations": [ - "This vertical slice requires one unambiguous metadata method name; exact overload signature selection is not yet implemented.", + "Overloads require --signature with an exact ECMA-335 MethodDef signature blob; generic instantiations are not selected separately.", "The breakpoint covers the DAC representative entry address. Generic instantiations, tiered recompilation, ReadyToRun entry indirection, and re-JIT/unload transitions require additional validation." ], "end": end @@ -539,6 +541,54 @@ fn validate_managed_breakpoint_token(value: &str, argument: &str) -> anyhow::Res Ok(value.to_string()) } +fn parse_managed_method_signature(value: Option<&str>) -> anyhow::Result>> { + let Some(value) = value else { + return Ok(None); + }; + + let value = value.trim(); + ensure!(!value.is_empty(), "--signature must not be empty"); + ensure!( + value.chars().all(|character| { + character.is_ascii_hexdigit() + || character.is_ascii_whitespace() + || matches!(character, '-' | '_' | ':') + }), + "--signature must contain hexadecimal byte pairs separated only by whitespace, '-', '_', or ':'" + ); + let hexadecimal = value + .chars() + .filter(|character| character.is_ascii_hexdigit()) + .collect::(); + ensure!( + hexadecimal.len() % 2 == 0, + "--signature must contain complete hexadecimal byte pairs" + ); + ensure!( + hexadecimal.len() <= 1022, + "--signature exceeds the 511-byte direct DAC selector limit" + ); + + let signature = hexadecimal + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let high = (pair[0] as char) + .to_digit(16) + .context("parsing the high nibble of --signature")?; + let low = (pair[1] as char) + .to_digit(16) + .context("parsing the low nibble of --signature")?; + Ok((high << 4 | low) as u8) + }) + .collect::>>()?; + Ok(Some(signature)) +} + +fn format_managed_method_signature(signature: &[u8]) -> String { + signature.iter().map(|byte| format!("{byte:02X}")).collect() +} + fn validate_managed_module(value: &str) -> anyhow::Result { let path = validate_module_load_filter(value)?; ensure!( @@ -1589,7 +1639,7 @@ pub(super) fn live_capabilities() -> Value { { "feature": "managed method breakpoint workflow", "status": "x64_coreclr_dac_vertical_slice", - "notes": "Uses a matching CoreCLR DAC through the active DbgEng client, resolves one unambiguous metadata method, requests CLR code generation, and then sets a DbgEng hardware execute breakpoint. CLR notification writes require --allow-runtime-write and an approved test VM." + "notes": "Uses a matching CoreCLR DAC through the active DbgEng client, resolves a metadata method by name and optional exact signature, requests CLR code generation, and then sets a DbgEng software code breakpoint. CLR notification writes require --allow-runtime-write and an approved test VM." }, { "feature": "dump creation", @@ -2123,6 +2173,23 @@ mod tests { assert!(validate_managed_breakpoint_token(r#"Program.Main""#, "--method").is_err()); } + #[test] + fn parses_exact_managed_method_signature_bytes() { + assert_eq!( + parse_managed_method_signature(Some("00-01:0E 0e")).unwrap(), + Some(vec![0x00, 0x01, 0x0e, 0x0e]) + ); + assert_eq!( + format_managed_method_signature(&[0x00, 0x01, 0x0e, 0x0e]), + "00010E0E" + ); + assert_eq!(parse_managed_method_signature(None).unwrap(), None); + assert!(parse_managed_method_signature(Some("")).is_err()); + assert!(parse_managed_method_signature(Some("001")).is_err()); + assert!(parse_managed_method_signature(Some("00zz")).is_err()); + assert!(parse_managed_method_signature(Some(&"AA".repeat(512))).is_err()); + } + #[test] fn validates_managed_module_basename() { assert_eq!( diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/ManagedBreakpointFixture.csproj b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/ManagedBreakpointFixture.csproj new file mode 100644 index 0000000..e11a9b0 --- /dev/null +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/ManagedBreakpointFixture.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + win-x64 + x64 + false + enable + enable + + diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs new file mode 100644 index 0000000..bafd32c --- /dev/null +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs @@ -0,0 +1,32 @@ +using System.Runtime.CompilerServices; + +namespace ManagedBreakpointFixture; + +public static class Program +{ + public static int Main() + { + Console.WriteLine(ManagedTargets.PublicEntry()); + Console.WriteLine(ManagedTargets.InvokePrivateEntry()); + Console.WriteLine(ManagedTargets.Overload("selected")); + return 0; + } +} + +public static class ManagedTargets +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public static string PublicEntry() => "public"; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static string InvokePrivateEntry() => PrivateEntry(); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static string PrivateEntry() => "private"; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static string Overload() => "no-arguments"; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static string Overload(string value) => $"string:{value}"; +} diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.deps.json b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.deps.json new file mode 100644 index 0000000..d947cce --- /dev/null +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.deps.json @@ -0,0 +1,24 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0/win-x64", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": {}, + ".NETCoreApp,Version=v10.0/win-x64": { + "ManagedBreakpointFixture/1.0.0": { + "runtime": { + "ManagedBreakpointFixture.dll": {} + } + } + } + }, + "libraries": { + "ManagedBreakpointFixture/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.runtimeconfig.json b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.runtimeconfig.json new file mode 100644 index 0000000..f730443 --- /dev/null +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.runtimeconfig.json @@ -0,0 +1,13 @@ +{ + "runtimeOptions": { + "tfm": "net10.0", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "10.0.0" + }, + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false + } + } +} \ No newline at end of file diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.dgspec.json b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.dgspec.json new file mode 100644 index 0000000..c096bba --- /dev/null +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.dgspec.json @@ -0,0 +1,356 @@ +{ + "format": 1, + "restore": { + "D:\\dev\\.copilot\\copilot-worktrees\\windbg-tool\\stunning-garbanzo\\crates\\windbg-tool\\tests\\fixtures\\ManagedBreakpointFixture\\ManagedBreakpointFixture.csproj": {} + }, + "projects": { + "D:\\dev\\.copilot\\copilot-worktrees\\windbg-tool\\stunning-garbanzo\\crates\\windbg-tool\\tests\\fixtures\\ManagedBreakpointFixture\\ManagedBreakpointFixture.csproj": { + "version": "1.0.0", + "restore": { + "projectUniqueName": "D:\\dev\\.copilot\\copilot-worktrees\\windbg-tool\\stunning-garbanzo\\crates\\windbg-tool\\tests\\fixtures\\ManagedBreakpointFixture\\ManagedBreakpointFixture.csproj", + "projectName": "ManagedBreakpointFixture", + "projectPath": "D:\\dev\\.copilot\\copilot-worktrees\\windbg-tool\\stunning-garbanzo\\crates\\windbg-tool\\tests\\fixtures\\ManagedBreakpointFixture\\ManagedBreakpointFixture.csproj", + "packagesPath": "C:\\Users\\mamoreau\\.nuget\\packages\\", + "outputPath": "D:\\dev\\.copilot\\copilot-worktrees\\windbg-tool\\stunning-garbanzo\\crates\\windbg-tool\\tests\\fixtures\\ManagedBreakpointFixture\\obj\\", + "projectStyle": "PackageReference", + "fallbackFolders": [ + "C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages" + ], + "configFilePaths": [ + "C:\\Users\\mamoreau\\AppData\\Roaming\\NuGet\\NuGet.Config", + "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config", + "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config" + ], + "originalTargetFrameworks": [ + "net10.0" + ], + "sources": { + "C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {}, + "https://api.nuget.org/v3/index.json": {}, + "https://devolutions.jfrog.io/devolutions/api/nuget/nuget": {}, + "https://nuget.devexpress.com/reRAWVoyhMG9tk6NU8TrUpsEDVUXP1g3MwI4h7ZWIJEw9o5ow6/api/v3/index.json": {} + }, + "frameworks": { + "net10.0": { + "framework": "net10.0", + "targetAlias": "net10.0", + "projectReferences": {} + } + }, + "warningProperties": { + "warnAsError": [ + "NU1605" + ] + }, + "restoreAuditProperties": { + "enableAudit": "true", + "auditLevel": "low", + "auditMode": "all" + }, + "SdkAnalysisLevel": "10.0.300" + }, + "frameworks": { + "net10.0": { + "framework": "net10.0", + "targetAlias": "net10.0", + "imports": [ + "net461", + "net462", + "net47", + "net471", + "net472", + "net48", + "net481" + ], + "assetTargetFallback": true, + "warn": true, + "frameworkReferences": { + "Microsoft.NETCore.App": { + "privateAssets": "all" + } + }, + "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.301/PortableRuntimeIdentifierGraph.json", + "packagesToPrune": { + "Microsoft.CSharp": "(,4.7.32767]", + "Microsoft.VisualBasic": "(,10.4.32767]", + "Microsoft.Win32.Primitives": "(,4.3.32767]", + "Microsoft.Win32.Registry": "(,5.0.32767]", + "runtime.any.System.Collections": "(,4.3.32767]", + "runtime.any.System.Diagnostics.Tools": "(,4.3.32767]", + "runtime.any.System.Diagnostics.Tracing": "(,4.3.32767]", + "runtime.any.System.Globalization": "(,4.3.32767]", + "runtime.any.System.Globalization.Calendars": "(,4.3.32767]", + "runtime.any.System.IO": "(,4.3.32767]", + "runtime.any.System.Reflection": "(,4.3.32767]", + "runtime.any.System.Reflection.Extensions": "(,4.3.32767]", + "runtime.any.System.Reflection.Primitives": "(,4.3.32767]", + "runtime.any.System.Resources.ResourceManager": "(,4.3.32767]", + "runtime.any.System.Runtime": "(,4.3.32767]", + "runtime.any.System.Runtime.Handles": "(,4.3.32767]", + "runtime.any.System.Runtime.InteropServices": "(,4.3.32767]", + "runtime.any.System.Text.Encoding": "(,4.3.32767]", + "runtime.any.System.Text.Encoding.Extensions": "(,4.3.32767]", + "runtime.any.System.Threading.Tasks": "(,4.3.32767]", + "runtime.any.System.Threading.Timer": "(,4.3.32767]", + "runtime.aot.System.Collections": "(,4.3.32767]", + "runtime.aot.System.Diagnostics.Tools": "(,4.3.32767]", + "runtime.aot.System.Diagnostics.Tracing": "(,4.3.32767]", + "runtime.aot.System.Globalization": "(,4.3.32767]", + "runtime.aot.System.Globalization.Calendars": "(,4.3.32767]", + "runtime.aot.System.IO": "(,4.3.32767]", + "runtime.aot.System.Reflection": "(,4.3.32767]", + "runtime.aot.System.Reflection.Extensions": "(,4.3.32767]", + "runtime.aot.System.Reflection.Primitives": "(,4.3.32767]", + "runtime.aot.System.Resources.ResourceManager": "(,4.3.32767]", + "runtime.aot.System.Runtime": "(,4.3.32767]", + "runtime.aot.System.Runtime.Handles": "(,4.3.32767]", + "runtime.aot.System.Runtime.InteropServices": "(,4.3.32767]", + "runtime.aot.System.Text.Encoding": "(,4.3.32767]", + "runtime.aot.System.Text.Encoding.Extensions": "(,4.3.32767]", + "runtime.aot.System.Threading.Tasks": "(,4.3.32767]", + "runtime.aot.System.Threading.Timer": "(,4.3.32767]", + "runtime.debian.8-x64.runtime.native.System": "(,4.3.32767]", + "runtime.debian.8-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.debian.8-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.debian.8-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.debian.8-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", + "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", + "runtime.debian.9-x64.runtime.native.System": "(,4.3.32767]", + "runtime.debian.9-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.debian.9-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.debian.9-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.fedora.23-x64.runtime.native.System": "(,4.3.32767]", + "runtime.fedora.23-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.fedora.23-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.fedora.23-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", + "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", + "runtime.fedora.24-x64.runtime.native.System": "(,4.3.32767]", + "runtime.fedora.24-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.fedora.24-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.fedora.24-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", + "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", + "runtime.fedora.27-x64.runtime.native.System": "(,4.3.32767]", + "runtime.fedora.27-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.fedora.27-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.fedora.27-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.fedora.28-x64.runtime.native.System": "(,4.3.32767]", + "runtime.fedora.28-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.fedora.28-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.fedora.28-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.opensuse.13.2-x64.runtime.native.System": "(,4.3.32767]", + "runtime.opensuse.13.2-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.opensuse.13.2-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.opensuse.13.2-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", + "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", + "runtime.opensuse.42.1-x64.runtime.native.System": "(,4.3.32767]", + "runtime.opensuse.42.1-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.opensuse.42.1-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.opensuse.42.1-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", + "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", + "runtime.opensuse.42.3-x64.runtime.native.System": "(,4.3.32767]", + "runtime.opensuse.42.3-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.opensuse.42.3-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.opensuse.42.3-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.osx.10.10-x64.runtime.native.System": "(,4.3.32767]", + "runtime.osx.10.10-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.osx.10.10-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.osx.10.10-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "(,4.3.32767]", + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", + "runtime.rhel.7-x64.runtime.native.System": "(,4.3.32767]", + "runtime.rhel.7-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.rhel.7-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.rhel.7-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", + "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", + "runtime.ubuntu.14.04-x64.runtime.native.System": "(,4.3.32767]", + "runtime.ubuntu.14.04-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.ubuntu.14.04-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.ubuntu.14.04-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", + "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", + "runtime.ubuntu.16.04-x64.runtime.native.System": "(,4.3.32767]", + "runtime.ubuntu.16.04-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.ubuntu.16.04-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.ubuntu.16.04-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", + "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", + "runtime.ubuntu.16.10-x64.runtime.native.System": "(,4.3.32767]", + "runtime.ubuntu.16.10-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.ubuntu.16.10-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.ubuntu.16.10-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", + "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", + "runtime.ubuntu.18.04-x64.runtime.native.System": "(,4.3.32767]", + "runtime.ubuntu.18.04-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.ubuntu.18.04-x64.runtime.native.System.Net.Http": "(,4.3.32767]", + "runtime.ubuntu.18.04-x64.runtime.native.System.Net.Security": "(,4.3.32767]", + "runtime.unix.Microsoft.Win32.Primitives": "(,4.3.32767]", + "runtime.unix.System.Console": "(,4.3.32767]", + "runtime.unix.System.Diagnostics.Debug": "(,4.3.32767]", + "runtime.unix.System.IO.FileSystem": "(,4.3.32767]", + "runtime.unix.System.Net.Primitives": "(,4.3.32767]", + "runtime.unix.System.Net.Sockets": "(,4.3.32767]", + "runtime.unix.System.Private.Uri": "(,4.3.32767]", + "runtime.unix.System.Runtime.Extensions": "(,4.3.32767]", + "runtime.win.Microsoft.Win32.Primitives": "(,4.3.32767]", + "runtime.win.System.Console": "(,4.3.32767]", + "runtime.win.System.Diagnostics.Debug": "(,4.3.32767]", + "runtime.win.System.IO.FileSystem": "(,4.3.32767]", + "runtime.win.System.Net.Primitives": "(,4.3.32767]", + "runtime.win.System.Net.Sockets": "(,4.3.32767]", + "runtime.win.System.Runtime.Extensions": "(,4.3.32767]", + "runtime.win10-arm-aot.runtime.native.System.IO.Compression": "(,4.0.32767]", + "runtime.win10-arm64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.win10-x64-aot.runtime.native.System.IO.Compression": "(,4.0.32767]", + "runtime.win10-x86-aot.runtime.native.System.IO.Compression": "(,4.0.32767]", + "runtime.win7-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.win7-x86.runtime.native.System.IO.Compression": "(,4.3.32767]", + "runtime.win7.System.Private.Uri": "(,4.3.32767]", + "runtime.win8-arm.runtime.native.System.IO.Compression": "(,4.3.32767]", + "System.AppContext": "(,4.3.32767]", + "System.Buffers": "(,5.0.32767]", + "System.Collections": "(,4.3.32767]", + "System.Collections.Concurrent": "(,4.3.32767]", + "System.Collections.Immutable": "(,10.0.32767]", + "System.Collections.NonGeneric": "(,4.3.32767]", + "System.Collections.Specialized": "(,4.3.32767]", + "System.ComponentModel": "(,4.3.32767]", + "System.ComponentModel.Annotations": "(,4.3.32767]", + "System.ComponentModel.EventBasedAsync": "(,4.3.32767]", + "System.ComponentModel.Primitives": "(,4.3.32767]", + "System.ComponentModel.TypeConverter": "(,4.3.32767]", + "System.Console": "(,4.3.32767]", + "System.Data.Common": "(,4.3.32767]", + "System.Data.DataSetExtensions": "(,4.4.32767]", + "System.Diagnostics.Contracts": "(,4.3.32767]", + "System.Diagnostics.Debug": "(,4.3.32767]", + "System.Diagnostics.DiagnosticSource": "(,10.0.32767]", + "System.Diagnostics.FileVersionInfo": "(,4.3.32767]", + "System.Diagnostics.Process": "(,4.3.32767]", + "System.Diagnostics.StackTrace": "(,4.3.32767]", + "System.Diagnostics.TextWriterTraceListener": "(,4.3.32767]", + "System.Diagnostics.Tools": "(,4.3.32767]", + "System.Diagnostics.TraceSource": "(,4.3.32767]", + "System.Diagnostics.Tracing": "(,4.3.32767]", + "System.Drawing.Primitives": "(,4.3.32767]", + "System.Dynamic.Runtime": "(,4.3.32767]", + "System.Formats.Asn1": "(,10.0.32767]", + "System.Formats.Tar": "(,10.0.32767]", + "System.Globalization": "(,4.3.32767]", + "System.Globalization.Calendars": "(,4.3.32767]", + "System.Globalization.Extensions": "(,4.3.32767]", + "System.IO": "(,4.3.32767]", + "System.IO.Compression": "(,4.3.32767]", + "System.IO.Compression.ZipFile": "(,4.3.32767]", + "System.IO.FileSystem": "(,4.3.32767]", + "System.IO.FileSystem.AccessControl": "(,4.4.32767]", + "System.IO.FileSystem.DriveInfo": "(,4.3.32767]", + "System.IO.FileSystem.Primitives": "(,4.3.32767]", + "System.IO.FileSystem.Watcher": "(,4.3.32767]", + "System.IO.IsolatedStorage": "(,4.3.32767]", + "System.IO.MemoryMappedFiles": "(,4.3.32767]", + "System.IO.Pipelines": "(,10.0.32767]", + "System.IO.Pipes": "(,4.3.32767]", + "System.IO.Pipes.AccessControl": "(,5.0.32767]", + "System.IO.UnmanagedMemoryStream": "(,4.3.32767]", + "System.Linq": "(,4.3.32767]", + "System.Linq.AsyncEnumerable": "(,10.0.32767]", + "System.Linq.Expressions": "(,4.3.32767]", + "System.Linq.Parallel": "(,4.3.32767]", + "System.Linq.Queryable": "(,4.3.32767]", + "System.Memory": "(,5.0.32767]", + "System.Net.Http": "(,4.3.32767]", + "System.Net.Http.Json": "(,10.0.32767]", + "System.Net.NameResolution": "(,4.3.32767]", + "System.Net.NetworkInformation": "(,4.3.32767]", + "System.Net.Ping": "(,4.3.32767]", + "System.Net.Primitives": "(,4.3.32767]", + "System.Net.Requests": "(,4.3.32767]", + "System.Net.Security": "(,4.3.32767]", + "System.Net.ServerSentEvents": "(,10.0.32767]", + "System.Net.Sockets": "(,4.3.32767]", + "System.Net.WebHeaderCollection": "(,4.3.32767]", + "System.Net.WebSockets": "(,4.3.32767]", + "System.Net.WebSockets.Client": "(,4.3.32767]", + "System.Numerics.Vectors": "(,5.0.32767]", + "System.ObjectModel": "(,4.3.32767]", + "System.Private.DataContractSerialization": "(,4.3.32767]", + "System.Private.Uri": "(,4.3.32767]", + "System.Reflection": "(,4.3.32767]", + "System.Reflection.DispatchProxy": "(,6.0.32767]", + "System.Reflection.Emit": "(,4.7.32767]", + "System.Reflection.Emit.ILGeneration": "(,4.7.32767]", + "System.Reflection.Emit.Lightweight": "(,4.7.32767]", + "System.Reflection.Extensions": "(,4.3.32767]", + "System.Reflection.Metadata": "(,10.0.32767]", + "System.Reflection.Primitives": "(,4.3.32767]", + "System.Reflection.TypeExtensions": "(,4.3.32767]", + "System.Resources.Reader": "(,4.3.32767]", + "System.Resources.ResourceManager": "(,4.3.32767]", + "System.Resources.Writer": "(,4.3.32767]", + "System.Runtime": "(,4.3.32767]", + "System.Runtime.CompilerServices.Unsafe": "(,7.0.32767]", + "System.Runtime.CompilerServices.VisualC": "(,4.3.32767]", + "System.Runtime.Extensions": "(,4.3.32767]", + "System.Runtime.Handles": "(,4.3.32767]", + "System.Runtime.InteropServices": "(,4.3.32767]", + "System.Runtime.InteropServices.RuntimeInformation": "(,4.3.32767]", + "System.Runtime.Loader": "(,4.3.32767]", + "System.Runtime.Numerics": "(,4.3.32767]", + "System.Runtime.Serialization.Formatters": "(,4.3.32767]", + "System.Runtime.Serialization.Json": "(,4.3.32767]", + "System.Runtime.Serialization.Primitives": "(,4.3.32767]", + "System.Runtime.Serialization.Xml": "(,4.3.32767]", + "System.Security.AccessControl": "(,6.0.32767]", + "System.Security.Claims": "(,4.3.32767]", + "System.Security.Cryptography.Algorithms": "(,4.3.32767]", + "System.Security.Cryptography.Cng": "(,5.0.32767]", + "System.Security.Cryptography.Csp": "(,4.3.32767]", + "System.Security.Cryptography.Encoding": "(,4.3.32767]", + "System.Security.Cryptography.OpenSsl": "(,5.0.32767]", + "System.Security.Cryptography.Primitives": "(,4.3.32767]", + "System.Security.Cryptography.X509Certificates": "(,4.3.32767]", + "System.Security.Principal": "(,4.3.32767]", + "System.Security.Principal.Windows": "(,5.0.32767]", + "System.Security.SecureString": "(,4.3.32767]", + "System.Text.Encoding": "(,4.3.32767]", + "System.Text.Encoding.CodePages": "(,10.0.32767]", + "System.Text.Encoding.Extensions": "(,4.3.32767]", + "System.Text.Encodings.Web": "(,10.0.32767]", + "System.Text.Json": "(,10.0.32767]", + "System.Text.RegularExpressions": "(,4.3.32767]", + "System.Threading": "(,4.3.32767]", + "System.Threading.AccessControl": "(,10.0.32767]", + "System.Threading.Channels": "(,10.0.32767]", + "System.Threading.Overlapped": "(,4.3.32767]", + "System.Threading.Tasks": "(,4.3.32767]", + "System.Threading.Tasks.Dataflow": "(,10.0.32767]", + "System.Threading.Tasks.Extensions": "(,5.0.32767]", + "System.Threading.Tasks.Parallel": "(,4.3.32767]", + "System.Threading.Thread": "(,4.3.32767]", + "System.Threading.ThreadPool": "(,4.3.32767]", + "System.Threading.Timer": "(,4.3.32767]", + "System.ValueTuple": "(,4.5.32767]", + "System.Xml.ReaderWriter": "(,4.3.32767]", + "System.Xml.XDocument": "(,4.3.32767]", + "System.Xml.XmlDocument": "(,4.3.32767]", + "System.Xml.XmlSerializer": "(,4.3.32767]", + "System.Xml.XPath": "(,4.3.32767]", + "System.Xml.XPath.XDocument": "(,5.0.32767]" + } + } + }, + "runtimes": { + "win-x64": { + "#import": [] + } + } + } + } +} \ No newline at end of file diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.props b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.props new file mode 100644 index 0000000..25b8107 --- /dev/null +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.props @@ -0,0 +1,16 @@ + + + + True + NuGet + $(MSBuildThisFileDirectory)project.assets.json + $(UserProfile)\.nuget\packages\ + C:\Users\mamoreau\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages + PackageReference + 7.0.0 + + + + + + \ No newline at end of file diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.targets b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.targets new file mode 100644 index 0000000..3dc06ef --- /dev/null +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.targets @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs new file mode 100644 index 0000000..925b135 --- /dev/null +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs @@ -0,0 +1,4 @@ +// +using System; +using System.Reflection; +[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v10.0", FrameworkDisplayName = ".NET 10.0")] diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfo.cs b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfo.cs new file mode 100644 index 0000000..112ddf7 --- /dev/null +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfo.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +using System; +using System.Reflection; + +[assembly: System.Reflection.AssemblyCompanyAttribute("ManagedBreakpointFixture")] +[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] +[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+92be4cb5409ad26dddcaecccc85717fe65582111")] +[assembly: System.Reflection.AssemblyProductAttribute("ManagedBreakpointFixture")] +[assembly: System.Reflection.AssemblyTitleAttribute("ManagedBreakpointFixture")] +[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] + +// Generated by the MSBuild WriteCodeFragment class. + diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfoInputs.cache b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfoInputs.cache new file mode 100644 index 0000000..f4c9920 --- /dev/null +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfoInputs.cache @@ -0,0 +1 @@ +67312a1e3eb73378d844046fa75d312fe3eb9540fd3d708698aec361957e04c6 diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GeneratedMSBuildEditorConfig.editorconfig b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GeneratedMSBuildEditorConfig.editorconfig new file mode 100644 index 0000000..d3dfa63 --- /dev/null +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GeneratedMSBuildEditorConfig.editorconfig @@ -0,0 +1,18 @@ +is_global = true +build_property.TargetFramework = net10.0 +build_property.TargetFrameworkIdentifier = .NETCoreApp +build_property.TargetFrameworkVersion = v10.0 +build_property.TargetPlatformMinVersion = +build_property.UsingMicrosoftNETSdkWeb = +build_property.ProjectTypeGuids = +build_property.InvariantGlobalization = +build_property.PlatformNeutralAssembly = +build_property.EnforceExtendedAnalyzerRules = +build_property.EntryPointFilePath = +build_property._SupportedPlatformList = Linux,macOS,Windows +build_property.RootNamespace = ManagedBreakpointFixture +build_property.ProjectDir = D:\dev\.copilot\copilot-worktrees\windbg-tool\stunning-garbanzo\crates\windbg-tool\tests\fixtures\ManagedBreakpointFixture\ +build_property.EnableComHosting = +build_property.EnableGeneratedComInterfaceComImportInterop = +build_property.EffectiveAnalysisLevelStyle = 10.0 +build_property.EnableCodeStyleSeverity = diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GlobalUsings.g.cs b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GlobalUsings.g.cs new file mode 100644 index 0000000..d12bcbc --- /dev/null +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GlobalUsings.g.cs @@ -0,0 +1,8 @@ +// +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using System.Net.Http; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.assets.cache b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.assets.cache new file mode 100644 index 0000000000000000000000000000000000000000..6ac3751b8cdb79c64432e8a12e52913397a6c32c GIT binary patch literal 231 zcmWIWc6a1qU|@)h?yjz^*0X1vCBB4B`^1+x(|Z;3*Od0`{CGt*ZpE3*$3Qj20w$nB zC1* metadata) { + if (managed_module_path == nullptr || metadata == nullptr) { + return E_INVALIDARG; + } + *metadata = nullptr; + + auto result = std::make_unique(); + result->mscoree_ = LoadLibraryExW( + L"mscoree.dll", + nullptr, + LOAD_LIBRARY_SEARCH_SYSTEM32); + if (result->mscoree_ == nullptr) { + return HRESULT_FROM_WIN32(GetLastError()); + } + const auto get_dispenser = reinterpret_cast( + GetProcAddress(result->mscoree_, "MetaDataGetDispenser")); + if (get_dispenser == nullptr) { + return HRESULT_FROM_WIN32(ERROR_PROC_NOT_FOUND); + } + + HRESULT open_result = get_dispenser( + CLSID_CorMetaDataDispenser, + IID_IMetaDataDispenser, + reinterpret_cast(result->dispenser_.put())); + if (FAILED(open_result)) { + return open_result; + } + open_result = result->dispenser_.get()->OpenScope( + managed_module_path, + 0, + IID_IMetaDataImport, + reinterpret_cast(result->importer_.put())); + if (FAILED(open_result)) { + return open_result; + } + + *metadata = std::move(result); + return S_OK; + } + + HRESULT GetMethodSignature(mdMethodDef method, std::vector* signature) const { + if (signature == nullptr) { + return E_POINTER; + } + signature->clear(); + PCCOR_SIGNATURE signature_data = nullptr; + ULONG signature_length = 0; + const HRESULT result = importer_.get()->GetMethodProps( + method, + nullptr, + nullptr, + 0, + nullptr, + nullptr, + &signature_data, + &signature_length, + nullptr, + nullptr); + if (FAILED(result)) { + return result; + } + if (signature_data == nullptr || signature_length == 0) { + return E_UNEXPECTED; + } + signature->assign(signature_data, signature_data + signature_length); + return S_OK; + } + +private: + HMODULE mscoree_ = nullptr; + ComReference dispenser_; + ComReference importer_; +}; + thread_local std::wstring g_last_error; void set_error(const std::wstring& error) { @@ -362,6 +641,53 @@ void copy_string(wchar_t* destination, size_t destination_count, const std::wstr wcsncpy_s(destination, destination_count, source.c_str(), _TRUNCATE); } +void copy_signature_hex( + wchar_t* destination, + size_t destination_count, + const std::vector& signature, + uint8_t* truncated) { + static constexpr wchar_t hexadecimal[] = L"0123456789ABCDEF"; + if (destination == nullptr || destination_count == 0 || truncated == nullptr) { + return; + } + + const size_t maximum_bytes = (destination_count - 1) / 2; + const size_t copied_bytes = std::min(signature.size(), maximum_bytes); + for (size_t index = 0; index < copied_bytes; ++index) { + destination[index * 2] = hexadecimal[signature[index] >> 4]; + destination[index * 2 + 1] = hexadecimal[signature[index] & 0x0f]; + } + destination[copied_bytes * 2] = L'\0'; + *truncated = static_cast(copied_bytes != signature.size()); +} + +void record_method_candidate( + WindbgDacMethodInfo* method_info, + mdMethodDef method_token, + const std::vector& signature) { + if (method_info->reported_candidate_count >= WINDBG_DAC_MAX_METHOD_CANDIDATES) { + method_info->candidates_truncated = 1; + return; + } + + auto* candidate = &method_info->candidates[method_info->reported_candidate_count++]; + candidate->method_token = method_token; + copy_signature_hex( + candidate->signature_hex, + std::size(candidate->signature_hex), + signature, + &candidate->signature_truncated); +} + +void record_resolved_signature(WindbgDacMethodInfo* method_info, const std::vector& signature) { + uint8_t ignored = 0; + copy_signature_hex( + method_info->resolved_signature_hex, + std::size(method_info->resolved_signature_hex), + signature, + &ignored); +} + std::wstring sibling_dac_path(const wchar_t* coreclr_path) { std::wstring path(coreclr_path); const size_t separator = path.find_last_of(L"\\/"); @@ -756,6 +1082,11 @@ struct WindbgDacBridge { std::wstring dac_path; std::wstring managed_module_path; mdMethodDef method_token = 0; + std::vector method_signature; + uint32_t matching_method_count = 0; + uint32_t reported_candidate_count = 0; + uint8_t candidates_truncated = 0; + std::array method_candidates{}; }; HRESULT find_method_instance( @@ -785,8 +1116,6 @@ HRESULT populate_method_info( IXCLRDataMethodDefinition* method, ComReference* method_instance, WindbgDacMethodInfo* info) { - memset(info, 0, sizeof(*info)); - std::array name{}; ULONG32 name_length = 0; if (SUCCEEDED(method->GetName(0, static_cast(name.size()), &name_length, name.data()))) { @@ -1049,6 +1378,8 @@ extern "C" WindbgDacStatus windbg_dac_resolve_and_notify( WindbgDacBridge* bridge, const wchar_t* managed_module_path, const wchar_t* fully_qualified_method, + const uint8_t* signature_blob, + uint32_t signature_blob_length, WindbgDacMethodInfo* method_info) { g_last_error.clear(); if (bridge == nullptr || managed_module_path == nullptr || fully_qualified_method == nullptr || @@ -1058,6 +1389,11 @@ extern "C" WindbgDacStatus windbg_dac_resolve_and_notify( L"A bridge, managed module path, fully-qualified method name, and method output are required."); } memset(method_info, 0, sizeof(*method_info)); + if ((signature_blob == nullptr) != (signature_blob_length == 0)) { + return fail( + WINDBG_DAC_INVALID_ARGUMENT, + L"An exact metadata signature must provide both a non-null byte buffer and a non-zero length."); + } ComReference module; const WindbgDacStatus module_status = @@ -1066,6 +1402,14 @@ extern "C" WindbgDacStatus windbg_dac_resolve_and_notify( return module_status; } + std::unique_ptr metadata; + const HRESULT metadata_result = MetadataImport::Open(managed_module_path, &metadata); + if (FAILED(metadata_result)) { + return fail( + WINDBG_DAC_ERROR, + format_hresult(L"Opening the selected managed module metadata", metadata_result)); + } + CLRDATA_ENUM enumeration = 0; HRESULT result = module.get()->StartEnumMethodDefinitionsByName(fully_qualified_method, 0, &enumeration); @@ -1075,6 +1419,8 @@ extern "C" WindbgDacStatus windbg_dac_resolve_and_notify( ComReference selected; uint32_t count = 0; + uint32_t selected_count = 0; + std::vector selected_signature; while (true) { ComReference candidate; result = module.get()->EnumMethodDefinitionByName(&enumeration, candidate.put()); @@ -1087,8 +1433,36 @@ extern "C" WindbgDacStatus windbg_dac_resolve_and_notify( } ++count; - if (count == 1) { - selected.reset(candidate.detach()); + mdMethodDef candidate_token = 0; + ComReference candidate_scope; + result = candidate.get()->GetTokenAndScope(&candidate_token, candidate_scope.put()); + if (FAILED(result) || candidate_token == 0) { + module.get()->EndEnumMethodDefinitionsByName(enumeration); + return fail( + WINDBG_DAC_ERROR, + format_hresult(L"Obtaining a managed method definition token", result)); + } + + std::vector candidate_signature; + result = metadata->GetMethodSignature(candidate_token, &candidate_signature); + if (FAILED(result)) { + module.get()->EndEnumMethodDefinitionsByName(enumeration); + return fail( + WINDBG_DAC_ERROR, + format_hresult(L"Reading a managed MethodDef signature", result)); + } + record_method_candidate(method_info, candidate_token, candidate_signature); + + const bool selected_by_name = signature_blob == nullptr; + const bool selected_by_signature = signature_blob != nullptr && + candidate_signature.size() == signature_blob_length && + std::equal(candidate_signature.begin(), candidate_signature.end(), signature_blob); + if (selected_by_name || selected_by_signature) { + ++selected_count; + if (selected_count == 1) { + selected_signature = std::move(candidate_signature); + selected.reset(candidate.detach()); + } } } module.get()->EndEnumMethodDefinitionsByName(enumeration); @@ -1097,10 +1471,20 @@ extern "C" WindbgDacStatus windbg_dac_resolve_and_notify( if (count == 0) { return fail(WINDBG_DAC_NOT_FOUND, L"The requested managed method was not found in the selected module."); } - if (count != 1) { + if (signature_blob == nullptr && count != 1) { + return fail( + WINDBG_DAC_AMBIGUOUS, + L"The requested managed method is ambiguous. Supply --signature with the exact ECMA-335 MethodDef signature bytes."); + } + if (signature_blob != nullptr && selected_count == 0) { + return fail( + WINDBG_DAC_NOT_FOUND, + L"No managed method definition with the requested name has the supplied exact metadata signature."); + } + if (selected_count != 1) { return fail( WINDBG_DAC_AMBIGUOUS, - L"The requested managed method is ambiguous. Supply an exact metadata signature once signature selection is available."); + L"More than one managed method definition matched the supplied exact metadata signature."); } bridge->method.reset(selected.detach()); @@ -1117,11 +1501,25 @@ extern "C" WindbgDacStatus windbg_dac_resolve_and_notify( format_hresult(L"Resolving a native code instance for the managed method", result)); } method_info->matching_method_count = count; + record_resolved_signature(method_info, selected_signature); bridge->managed_module_path = managed_module_path; bridge->method_token = method_info->method_token; + bridge->method_signature = std::move(selected_signature); + bridge->matching_method_count = method_info->matching_method_count; + bridge->reported_candidate_count = method_info->reported_candidate_count; + bridge->candidates_truncated = method_info->candidates_truncated; + memcpy( + bridge->method_candidates.data(), + method_info->candidates, + sizeof(method_info->candidates)); result = bridge->method.get()->SetCodeNotification(method_info->code_notification_flags); if (FAILED(result)) { bridge->method.reset(); + bridge->method_signature.clear(); + bridge->matching_method_count = 0; + bridge->reported_candidate_count = 0; + bridge->candidates_truncated = 0; + bridge->method_candidates.fill({}); return fail( WINDBG_DAC_ERROR, format_hresult(L"Requesting CLR code-generation notification", result) + @@ -1147,6 +1545,15 @@ extern "C" WindbgDacStatus windbg_dac_refresh_method_code( WINDBG_DAC_ERROR, L"The resolved managed method does not retain a module path and metadata token."); } + memset(method_info, 0, sizeof(*method_info)); + method_info->matching_method_count = bridge->matching_method_count; + method_info->reported_candidate_count = bridge->reported_candidate_count; + method_info->candidates_truncated = bridge->candidates_truncated; + memcpy( + method_info->candidates, + bridge->method_candidates.data(), + sizeof(method_info->candidates)); + record_resolved_signature(method_info, bridge->method_signature); ComReference module; const WindbgDacStatus module_status = find_module_by_path(bridge->process.get(), bridge->managed_module_path.c_str(), &module); @@ -1171,7 +1578,6 @@ extern "C" WindbgDacStatus windbg_dac_refresh_method_code( WINDBG_DAC_ERROR, format_hresult(L"Refreshing the managed method native code instance", result)); } - method_info->matching_method_count = 1; if (method_info->code_available == 0) { return fail(WINDBG_DAC_CODE_UNAVAILABLE, L"The managed method does not have a representative native entry address yet."); } diff --git a/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h index 50ca275..4184a36 100644 --- a/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h +++ b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h @@ -33,6 +33,18 @@ typedef struct WindbgDacRuntimeInfo { uint32_t dac_version_ls; } WindbgDacRuntimeInfo; +enum { + WINDBG_DAC_MAX_METHOD_CANDIDATES = 8, + WINDBG_DAC_MAX_SIGNATURE_HEX_CHARS = 1024, +}; + +typedef struct WindbgDacMethodCandidate { + uint32_t method_token; + uint8_t signature_truncated; + uint8_t reserved[3]; + wchar_t signature_hex[WINDBG_DAC_MAX_SIGNATURE_HEX_CHARS]; +} WindbgDacMethodCandidate; + typedef struct WindbgDacMethodInfo { uint32_t method_token; uint32_t matching_method_count; @@ -41,6 +53,11 @@ typedef struct WindbgDacMethodInfo { uint8_t code_available; uint8_t reserved[3]; wchar_t resolved_method[1024]; + wchar_t resolved_signature_hex[WINDBG_DAC_MAX_SIGNATURE_HEX_CHARS]; + uint32_t reported_candidate_count; + uint8_t candidates_truncated; + uint8_t reserved2[3]; + WindbgDacMethodCandidate candidates[WINDBG_DAC_MAX_METHOD_CANDIDATES]; } WindbgDacMethodInfo; // The debug_client parameter is an IDebugClient5 pointer retained by the caller for the bridge lifetime. @@ -68,6 +85,8 @@ WINDBG_DAC_EXPORT WindbgDacStatus windbg_dac_resolve_and_notify( WindbgDacBridge* bridge, const wchar_t* managed_module_path, const wchar_t* fully_qualified_method, + const uint8_t* signature_blob, + uint32_t signature_blob_length, WindbgDacMethodInfo* method_info); WINDBG_DAC_EXPORT WindbgDacStatus windbg_dac_refresh_method_code( From d1913de6d0b101d3e1f50247ffa01dfd6f2bff05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Jul 2026 11:25:15 -0400 Subject: [PATCH 05/12] Ignore managed fixture build outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitignore | 2 + .../ManagedBreakpointFixture.deps.json | 24 -- ...anagedBreakpointFixture.runtimeconfig.json | 13 - ...BreakpointFixture.csproj.nuget.dgspec.json | 356 ----------------- ...agedBreakpointFixture.csproj.nuget.g.props | 16 - ...edBreakpointFixture.csproj.nuget.g.targets | 2 - ...oreApp,Version=v10.0.AssemblyAttributes.cs | 4 - .../ManagedBreakpointFixture.AssemblyInfo.cs | 22 -- ...BreakpointFixture.AssemblyInfoInputs.cache | 1 - ....GeneratedMSBuildEditorConfig.editorconfig | 18 - ...ManagedBreakpointFixture.GlobalUsings.g.cs | 8 - .../ManagedBreakpointFixture.assets.cache | Bin 231 -> 0 bytes ...ointFixture.csproj.CoreCompileInputs.cache | 1 - ...akpointFixture.csproj.FileListAbsolute.txt | 14 - ...edBreakpointFixture.genruntimeconfig.cache | 1 - .../obj/project.assets.json | 363 ------------------ .../obj/project.nuget.cache | 8 - 17 files changed, 2 insertions(+), 851 deletions(-) delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.deps.json delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.runtimeconfig.json delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.dgspec.json delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.props delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.targets delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfo.cs delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfoInputs.cache delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GeneratedMSBuildEditorConfig.editorconfig delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GlobalUsings.g.cs delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.assets.cache delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.csproj.CoreCompileInputs.cache delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.csproj.FileListAbsolute.txt delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.genruntimeconfig.cache delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/project.assets.json delete mode 100644 crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/project.nuget.cache diff --git a/.gitignore b/.gitignore index a391bc0..63406a6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ target/ traces/*/ !traces/*.7z traces/**/*.out +crates/windbg-tool/tests/fixtures/**/bin/ +crates/windbg-tool/tests/fixtures/**/obj/ native/**/packages/ native/**/x64/ native/**/x86/ diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.deps.json b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.deps.json deleted file mode 100644 index d947cce..0000000 --- a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.deps.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "runtimeTarget": { - "name": ".NETCoreApp,Version=v10.0/win-x64", - "signature": "" - }, - "compilationOptions": {}, - "targets": { - ".NETCoreApp,Version=v10.0": {}, - ".NETCoreApp,Version=v10.0/win-x64": { - "ManagedBreakpointFixture/1.0.0": { - "runtime": { - "ManagedBreakpointFixture.dll": {} - } - } - } - }, - "libraries": { - "ManagedBreakpointFixture/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - } - } -} \ No newline at end of file diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.runtimeconfig.json b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.runtimeconfig.json deleted file mode 100644 index f730443..0000000 --- a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/bin/Release/net10.0/win-x64/ManagedBreakpointFixture.runtimeconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "runtimeOptions": { - "tfm": "net10.0", - "framework": { - "name": "Microsoft.NETCore.App", - "version": "10.0.0" - }, - "configProperties": { - "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, - "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false - } - } -} \ No newline at end of file diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.dgspec.json b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.dgspec.json deleted file mode 100644 index c096bba..0000000 --- a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.dgspec.json +++ /dev/null @@ -1,356 +0,0 @@ -{ - "format": 1, - "restore": { - "D:\\dev\\.copilot\\copilot-worktrees\\windbg-tool\\stunning-garbanzo\\crates\\windbg-tool\\tests\\fixtures\\ManagedBreakpointFixture\\ManagedBreakpointFixture.csproj": {} - }, - "projects": { - "D:\\dev\\.copilot\\copilot-worktrees\\windbg-tool\\stunning-garbanzo\\crates\\windbg-tool\\tests\\fixtures\\ManagedBreakpointFixture\\ManagedBreakpointFixture.csproj": { - "version": "1.0.0", - "restore": { - "projectUniqueName": "D:\\dev\\.copilot\\copilot-worktrees\\windbg-tool\\stunning-garbanzo\\crates\\windbg-tool\\tests\\fixtures\\ManagedBreakpointFixture\\ManagedBreakpointFixture.csproj", - "projectName": "ManagedBreakpointFixture", - "projectPath": "D:\\dev\\.copilot\\copilot-worktrees\\windbg-tool\\stunning-garbanzo\\crates\\windbg-tool\\tests\\fixtures\\ManagedBreakpointFixture\\ManagedBreakpointFixture.csproj", - "packagesPath": "C:\\Users\\mamoreau\\.nuget\\packages\\", - "outputPath": "D:\\dev\\.copilot\\copilot-worktrees\\windbg-tool\\stunning-garbanzo\\crates\\windbg-tool\\tests\\fixtures\\ManagedBreakpointFixture\\obj\\", - "projectStyle": "PackageReference", - "fallbackFolders": [ - "C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages" - ], - "configFilePaths": [ - "C:\\Users\\mamoreau\\AppData\\Roaming\\NuGet\\NuGet.Config", - "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config", - "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config" - ], - "originalTargetFrameworks": [ - "net10.0" - ], - "sources": { - "C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {}, - "https://api.nuget.org/v3/index.json": {}, - "https://devolutions.jfrog.io/devolutions/api/nuget/nuget": {}, - "https://nuget.devexpress.com/reRAWVoyhMG9tk6NU8TrUpsEDVUXP1g3MwI4h7ZWIJEw9o5ow6/api/v3/index.json": {} - }, - "frameworks": { - "net10.0": { - "framework": "net10.0", - "targetAlias": "net10.0", - "projectReferences": {} - } - }, - "warningProperties": { - "warnAsError": [ - "NU1605" - ] - }, - "restoreAuditProperties": { - "enableAudit": "true", - "auditLevel": "low", - "auditMode": "all" - }, - "SdkAnalysisLevel": "10.0.300" - }, - "frameworks": { - "net10.0": { - "framework": "net10.0", - "targetAlias": "net10.0", - "imports": [ - "net461", - "net462", - "net47", - "net471", - "net472", - "net48", - "net481" - ], - "assetTargetFallback": true, - "warn": true, - "frameworkReferences": { - "Microsoft.NETCore.App": { - "privateAssets": "all" - } - }, - "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.301/PortableRuntimeIdentifierGraph.json", - "packagesToPrune": { - "Microsoft.CSharp": "(,4.7.32767]", - "Microsoft.VisualBasic": "(,10.4.32767]", - "Microsoft.Win32.Primitives": "(,4.3.32767]", - "Microsoft.Win32.Registry": "(,5.0.32767]", - "runtime.any.System.Collections": "(,4.3.32767]", - "runtime.any.System.Diagnostics.Tools": "(,4.3.32767]", - "runtime.any.System.Diagnostics.Tracing": "(,4.3.32767]", - "runtime.any.System.Globalization": "(,4.3.32767]", - "runtime.any.System.Globalization.Calendars": "(,4.3.32767]", - "runtime.any.System.IO": "(,4.3.32767]", - "runtime.any.System.Reflection": "(,4.3.32767]", - "runtime.any.System.Reflection.Extensions": "(,4.3.32767]", - "runtime.any.System.Reflection.Primitives": "(,4.3.32767]", - "runtime.any.System.Resources.ResourceManager": "(,4.3.32767]", - "runtime.any.System.Runtime": "(,4.3.32767]", - "runtime.any.System.Runtime.Handles": "(,4.3.32767]", - "runtime.any.System.Runtime.InteropServices": "(,4.3.32767]", - "runtime.any.System.Text.Encoding": "(,4.3.32767]", - "runtime.any.System.Text.Encoding.Extensions": "(,4.3.32767]", - "runtime.any.System.Threading.Tasks": "(,4.3.32767]", - "runtime.any.System.Threading.Timer": "(,4.3.32767]", - "runtime.aot.System.Collections": "(,4.3.32767]", - "runtime.aot.System.Diagnostics.Tools": "(,4.3.32767]", - "runtime.aot.System.Diagnostics.Tracing": "(,4.3.32767]", - "runtime.aot.System.Globalization": "(,4.3.32767]", - "runtime.aot.System.Globalization.Calendars": "(,4.3.32767]", - "runtime.aot.System.IO": "(,4.3.32767]", - "runtime.aot.System.Reflection": "(,4.3.32767]", - "runtime.aot.System.Reflection.Extensions": "(,4.3.32767]", - "runtime.aot.System.Reflection.Primitives": "(,4.3.32767]", - "runtime.aot.System.Resources.ResourceManager": "(,4.3.32767]", - "runtime.aot.System.Runtime": "(,4.3.32767]", - "runtime.aot.System.Runtime.Handles": "(,4.3.32767]", - "runtime.aot.System.Runtime.InteropServices": "(,4.3.32767]", - "runtime.aot.System.Text.Encoding": "(,4.3.32767]", - "runtime.aot.System.Text.Encoding.Extensions": "(,4.3.32767]", - "runtime.aot.System.Threading.Tasks": "(,4.3.32767]", - "runtime.aot.System.Threading.Timer": "(,4.3.32767]", - "runtime.debian.8-x64.runtime.native.System": "(,4.3.32767]", - "runtime.debian.8-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.debian.8-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.debian.8-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", - "runtime.debian.9-x64.runtime.native.System": "(,4.3.32767]", - "runtime.debian.9-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.debian.9-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.debian.9-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.fedora.23-x64.runtime.native.System": "(,4.3.32767]", - "runtime.fedora.23-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.fedora.23-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.fedora.23-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", - "runtime.fedora.24-x64.runtime.native.System": "(,4.3.32767]", - "runtime.fedora.24-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.fedora.24-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.fedora.24-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", - "runtime.fedora.27-x64.runtime.native.System": "(,4.3.32767]", - "runtime.fedora.27-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.fedora.27-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.fedora.27-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.fedora.28-x64.runtime.native.System": "(,4.3.32767]", - "runtime.fedora.28-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.fedora.28-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.fedora.28-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.opensuse.13.2-x64.runtime.native.System": "(,4.3.32767]", - "runtime.opensuse.13.2-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.opensuse.13.2-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.opensuse.13.2-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", - "runtime.opensuse.42.1-x64.runtime.native.System": "(,4.3.32767]", - "runtime.opensuse.42.1-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.opensuse.42.1-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.opensuse.42.1-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", - "runtime.opensuse.42.3-x64.runtime.native.System": "(,4.3.32767]", - "runtime.opensuse.42.3-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.opensuse.42.3-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.opensuse.42.3-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.osx.10.10-x64.runtime.native.System": "(,4.3.32767]", - "runtime.osx.10.10-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.osx.10.10-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.osx.10.10-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "(,4.3.32767]", - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", - "runtime.rhel.7-x64.runtime.native.System": "(,4.3.32767]", - "runtime.rhel.7-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.rhel.7-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.rhel.7-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", - "runtime.ubuntu.14.04-x64.runtime.native.System": "(,4.3.32767]", - "runtime.ubuntu.14.04-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.ubuntu.14.04-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.ubuntu.14.04-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", - "runtime.ubuntu.16.04-x64.runtime.native.System": "(,4.3.32767]", - "runtime.ubuntu.16.04-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.ubuntu.16.04-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.ubuntu.16.04-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", - "runtime.ubuntu.16.10-x64.runtime.native.System": "(,4.3.32767]", - "runtime.ubuntu.16.10-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.ubuntu.16.10-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.ubuntu.16.10-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography": "(,4.3.32767]", - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "(,4.3.32767]", - "runtime.ubuntu.18.04-x64.runtime.native.System": "(,4.3.32767]", - "runtime.ubuntu.18.04-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.ubuntu.18.04-x64.runtime.native.System.Net.Http": "(,4.3.32767]", - "runtime.ubuntu.18.04-x64.runtime.native.System.Net.Security": "(,4.3.32767]", - "runtime.unix.Microsoft.Win32.Primitives": "(,4.3.32767]", - "runtime.unix.System.Console": "(,4.3.32767]", - "runtime.unix.System.Diagnostics.Debug": "(,4.3.32767]", - "runtime.unix.System.IO.FileSystem": "(,4.3.32767]", - "runtime.unix.System.Net.Primitives": "(,4.3.32767]", - "runtime.unix.System.Net.Sockets": "(,4.3.32767]", - "runtime.unix.System.Private.Uri": "(,4.3.32767]", - "runtime.unix.System.Runtime.Extensions": "(,4.3.32767]", - "runtime.win.Microsoft.Win32.Primitives": "(,4.3.32767]", - "runtime.win.System.Console": "(,4.3.32767]", - "runtime.win.System.Diagnostics.Debug": "(,4.3.32767]", - "runtime.win.System.IO.FileSystem": "(,4.3.32767]", - "runtime.win.System.Net.Primitives": "(,4.3.32767]", - "runtime.win.System.Net.Sockets": "(,4.3.32767]", - "runtime.win.System.Runtime.Extensions": "(,4.3.32767]", - "runtime.win10-arm-aot.runtime.native.System.IO.Compression": "(,4.0.32767]", - "runtime.win10-arm64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.win10-x64-aot.runtime.native.System.IO.Compression": "(,4.0.32767]", - "runtime.win10-x86-aot.runtime.native.System.IO.Compression": "(,4.0.32767]", - "runtime.win7-x64.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.win7-x86.runtime.native.System.IO.Compression": "(,4.3.32767]", - "runtime.win7.System.Private.Uri": "(,4.3.32767]", - "runtime.win8-arm.runtime.native.System.IO.Compression": "(,4.3.32767]", - "System.AppContext": "(,4.3.32767]", - "System.Buffers": "(,5.0.32767]", - "System.Collections": "(,4.3.32767]", - "System.Collections.Concurrent": "(,4.3.32767]", - "System.Collections.Immutable": "(,10.0.32767]", - "System.Collections.NonGeneric": "(,4.3.32767]", - "System.Collections.Specialized": "(,4.3.32767]", - "System.ComponentModel": "(,4.3.32767]", - "System.ComponentModel.Annotations": "(,4.3.32767]", - "System.ComponentModel.EventBasedAsync": "(,4.3.32767]", - "System.ComponentModel.Primitives": "(,4.3.32767]", - "System.ComponentModel.TypeConverter": "(,4.3.32767]", - "System.Console": "(,4.3.32767]", - "System.Data.Common": "(,4.3.32767]", - "System.Data.DataSetExtensions": "(,4.4.32767]", - "System.Diagnostics.Contracts": "(,4.3.32767]", - "System.Diagnostics.Debug": "(,4.3.32767]", - "System.Diagnostics.DiagnosticSource": "(,10.0.32767]", - "System.Diagnostics.FileVersionInfo": "(,4.3.32767]", - "System.Diagnostics.Process": "(,4.3.32767]", - "System.Diagnostics.StackTrace": "(,4.3.32767]", - "System.Diagnostics.TextWriterTraceListener": "(,4.3.32767]", - "System.Diagnostics.Tools": "(,4.3.32767]", - "System.Diagnostics.TraceSource": "(,4.3.32767]", - "System.Diagnostics.Tracing": "(,4.3.32767]", - "System.Drawing.Primitives": "(,4.3.32767]", - "System.Dynamic.Runtime": "(,4.3.32767]", - "System.Formats.Asn1": "(,10.0.32767]", - "System.Formats.Tar": "(,10.0.32767]", - "System.Globalization": "(,4.3.32767]", - "System.Globalization.Calendars": "(,4.3.32767]", - "System.Globalization.Extensions": "(,4.3.32767]", - "System.IO": "(,4.3.32767]", - "System.IO.Compression": "(,4.3.32767]", - "System.IO.Compression.ZipFile": "(,4.3.32767]", - "System.IO.FileSystem": "(,4.3.32767]", - "System.IO.FileSystem.AccessControl": "(,4.4.32767]", - "System.IO.FileSystem.DriveInfo": "(,4.3.32767]", - "System.IO.FileSystem.Primitives": "(,4.3.32767]", - "System.IO.FileSystem.Watcher": "(,4.3.32767]", - "System.IO.IsolatedStorage": "(,4.3.32767]", - "System.IO.MemoryMappedFiles": "(,4.3.32767]", - "System.IO.Pipelines": "(,10.0.32767]", - "System.IO.Pipes": "(,4.3.32767]", - "System.IO.Pipes.AccessControl": "(,5.0.32767]", - "System.IO.UnmanagedMemoryStream": "(,4.3.32767]", - "System.Linq": "(,4.3.32767]", - "System.Linq.AsyncEnumerable": "(,10.0.32767]", - "System.Linq.Expressions": "(,4.3.32767]", - "System.Linq.Parallel": "(,4.3.32767]", - "System.Linq.Queryable": "(,4.3.32767]", - "System.Memory": "(,5.0.32767]", - "System.Net.Http": "(,4.3.32767]", - "System.Net.Http.Json": "(,10.0.32767]", - "System.Net.NameResolution": "(,4.3.32767]", - "System.Net.NetworkInformation": "(,4.3.32767]", - "System.Net.Ping": "(,4.3.32767]", - "System.Net.Primitives": "(,4.3.32767]", - "System.Net.Requests": "(,4.3.32767]", - "System.Net.Security": "(,4.3.32767]", - "System.Net.ServerSentEvents": "(,10.0.32767]", - "System.Net.Sockets": "(,4.3.32767]", - "System.Net.WebHeaderCollection": "(,4.3.32767]", - "System.Net.WebSockets": "(,4.3.32767]", - "System.Net.WebSockets.Client": "(,4.3.32767]", - "System.Numerics.Vectors": "(,5.0.32767]", - "System.ObjectModel": "(,4.3.32767]", - "System.Private.DataContractSerialization": "(,4.3.32767]", - "System.Private.Uri": "(,4.3.32767]", - "System.Reflection": "(,4.3.32767]", - "System.Reflection.DispatchProxy": "(,6.0.32767]", - "System.Reflection.Emit": "(,4.7.32767]", - "System.Reflection.Emit.ILGeneration": "(,4.7.32767]", - "System.Reflection.Emit.Lightweight": "(,4.7.32767]", - "System.Reflection.Extensions": "(,4.3.32767]", - "System.Reflection.Metadata": "(,10.0.32767]", - "System.Reflection.Primitives": "(,4.3.32767]", - "System.Reflection.TypeExtensions": "(,4.3.32767]", - "System.Resources.Reader": "(,4.3.32767]", - "System.Resources.ResourceManager": "(,4.3.32767]", - "System.Resources.Writer": "(,4.3.32767]", - "System.Runtime": "(,4.3.32767]", - "System.Runtime.CompilerServices.Unsafe": "(,7.0.32767]", - "System.Runtime.CompilerServices.VisualC": "(,4.3.32767]", - "System.Runtime.Extensions": "(,4.3.32767]", - "System.Runtime.Handles": "(,4.3.32767]", - "System.Runtime.InteropServices": "(,4.3.32767]", - "System.Runtime.InteropServices.RuntimeInformation": "(,4.3.32767]", - "System.Runtime.Loader": "(,4.3.32767]", - "System.Runtime.Numerics": "(,4.3.32767]", - "System.Runtime.Serialization.Formatters": "(,4.3.32767]", - "System.Runtime.Serialization.Json": "(,4.3.32767]", - "System.Runtime.Serialization.Primitives": "(,4.3.32767]", - "System.Runtime.Serialization.Xml": "(,4.3.32767]", - "System.Security.AccessControl": "(,6.0.32767]", - "System.Security.Claims": "(,4.3.32767]", - "System.Security.Cryptography.Algorithms": "(,4.3.32767]", - "System.Security.Cryptography.Cng": "(,5.0.32767]", - "System.Security.Cryptography.Csp": "(,4.3.32767]", - "System.Security.Cryptography.Encoding": "(,4.3.32767]", - "System.Security.Cryptography.OpenSsl": "(,5.0.32767]", - "System.Security.Cryptography.Primitives": "(,4.3.32767]", - "System.Security.Cryptography.X509Certificates": "(,4.3.32767]", - "System.Security.Principal": "(,4.3.32767]", - "System.Security.Principal.Windows": "(,5.0.32767]", - "System.Security.SecureString": "(,4.3.32767]", - "System.Text.Encoding": "(,4.3.32767]", - "System.Text.Encoding.CodePages": "(,10.0.32767]", - "System.Text.Encoding.Extensions": "(,4.3.32767]", - "System.Text.Encodings.Web": "(,10.0.32767]", - "System.Text.Json": "(,10.0.32767]", - "System.Text.RegularExpressions": "(,4.3.32767]", - "System.Threading": "(,4.3.32767]", - "System.Threading.AccessControl": "(,10.0.32767]", - "System.Threading.Channels": "(,10.0.32767]", - "System.Threading.Overlapped": "(,4.3.32767]", - "System.Threading.Tasks": "(,4.3.32767]", - "System.Threading.Tasks.Dataflow": "(,10.0.32767]", - "System.Threading.Tasks.Extensions": "(,5.0.32767]", - "System.Threading.Tasks.Parallel": "(,4.3.32767]", - "System.Threading.Thread": "(,4.3.32767]", - "System.Threading.ThreadPool": "(,4.3.32767]", - "System.Threading.Timer": "(,4.3.32767]", - "System.ValueTuple": "(,4.5.32767]", - "System.Xml.ReaderWriter": "(,4.3.32767]", - "System.Xml.XDocument": "(,4.3.32767]", - "System.Xml.XmlDocument": "(,4.3.32767]", - "System.Xml.XmlSerializer": "(,4.3.32767]", - "System.Xml.XPath": "(,4.3.32767]", - "System.Xml.XPath.XDocument": "(,5.0.32767]" - } - } - }, - "runtimes": { - "win-x64": { - "#import": [] - } - } - } - } -} \ No newline at end of file diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.props b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.props deleted file mode 100644 index 25b8107..0000000 --- a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.props +++ /dev/null @@ -1,16 +0,0 @@ - - - - True - NuGet - $(MSBuildThisFileDirectory)project.assets.json - $(UserProfile)\.nuget\packages\ - C:\Users\mamoreau\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages - PackageReference - 7.0.0 - - - - - - \ No newline at end of file diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.targets b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.targets deleted file mode 100644 index 3dc06ef..0000000 --- a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/ManagedBreakpointFixture.csproj.nuget.g.targets +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs deleted file mode 100644 index 925b135..0000000 --- a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +++ /dev/null @@ -1,4 +0,0 @@ -// -using System; -using System.Reflection; -[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v10.0", FrameworkDisplayName = ".NET 10.0")] diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfo.cs b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfo.cs deleted file mode 100644 index 112ddf7..0000000 --- a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfo.cs +++ /dev/null @@ -1,22 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -using System; -using System.Reflection; - -[assembly: System.Reflection.AssemblyCompanyAttribute("ManagedBreakpointFixture")] -[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] -[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+92be4cb5409ad26dddcaecccc85717fe65582111")] -[assembly: System.Reflection.AssemblyProductAttribute("ManagedBreakpointFixture")] -[assembly: System.Reflection.AssemblyTitleAttribute("ManagedBreakpointFixture")] -[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] - -// Generated by the MSBuild WriteCodeFragment class. - diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfoInputs.cache b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfoInputs.cache deleted file mode 100644 index f4c9920..0000000 --- a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.AssemblyInfoInputs.cache +++ /dev/null @@ -1 +0,0 @@ -67312a1e3eb73378d844046fa75d312fe3eb9540fd3d708698aec361957e04c6 diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GeneratedMSBuildEditorConfig.editorconfig b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GeneratedMSBuildEditorConfig.editorconfig deleted file mode 100644 index d3dfa63..0000000 --- a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GeneratedMSBuildEditorConfig.editorconfig +++ /dev/null @@ -1,18 +0,0 @@ -is_global = true -build_property.TargetFramework = net10.0 -build_property.TargetFrameworkIdentifier = .NETCoreApp -build_property.TargetFrameworkVersion = v10.0 -build_property.TargetPlatformMinVersion = -build_property.UsingMicrosoftNETSdkWeb = -build_property.ProjectTypeGuids = -build_property.InvariantGlobalization = -build_property.PlatformNeutralAssembly = -build_property.EnforceExtendedAnalyzerRules = -build_property.EntryPointFilePath = -build_property._SupportedPlatformList = Linux,macOS,Windows -build_property.RootNamespace = ManagedBreakpointFixture -build_property.ProjectDir = D:\dev\.copilot\copilot-worktrees\windbg-tool\stunning-garbanzo\crates\windbg-tool\tests\fixtures\ManagedBreakpointFixture\ -build_property.EnableComHosting = -build_property.EnableGeneratedComInterfaceComImportInterop = -build_property.EffectiveAnalysisLevelStyle = 10.0 -build_property.EnableCodeStyleSeverity = diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GlobalUsings.g.cs b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GlobalUsings.g.cs deleted file mode 100644 index d12bcbc..0000000 --- a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.GlobalUsings.g.cs +++ /dev/null @@ -1,8 +0,0 @@ -// -global using System; -global using System.Collections.Generic; -global using System.IO; -global using System.Linq; -global using System.Net.Http; -global using System.Threading; -global using System.Threading.Tasks; diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.assets.cache b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/obj/Release/net10.0/win-x64/ManagedBreakpointFixture.assets.cache deleted file mode 100644 index 6ac3751b8cdb79c64432e8a12e52913397a6c32c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 231 zcmWIWc6a1qU|@)h?yjz^*0X1vCBB4B`^1+x(|Z;3*Od0`{CGt*ZpE3*$3Qj20w$nB zC1 Date: Fri, 10 Jul 2026 11:37:02 -0400 Subject: [PATCH 06/12] Add non-invasive hardware breakpoints Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-dbgeng/src/coreclr_dac.rs | 49 ++- crates/windbg-dbgeng/src/lib.rs | 22 ++ crates/windbg-tool/src/cli.rs | 11 + crates/windbg-tool/src/cli/platform.rs | 343 +++++++++++++++++- docs/architecture.md | 2 +- docs/cli.md | 46 ++- docs/development.md | 2 +- .../windbg_coreclr_dac_bridge.cpp | 45 ++- .../windbg_coreclr_dac_bridge.h | 10 + 9 files changed, 512 insertions(+), 18 deletions(-) diff --git a/crates/windbg-dbgeng/src/coreclr_dac.rs b/crates/windbg-dbgeng/src/coreclr_dac.rs index b49040b..2a29422 100644 --- a/crates/windbg-dbgeng/src/coreclr_dac.rs +++ b/crates/windbg-dbgeng/src/coreclr_dac.rs @@ -89,7 +89,7 @@ type IsModuleLoaded = unsafe extern "C" fn( managed_module_path: *const u16, loaded: *mut u8, ) -> u32; -type ResolveAndNotify = unsafe extern "C" fn( +type ResolveMethod = unsafe extern "C" fn( bridge: *mut c_void, managed_module_path: *const u16, fully_qualified_method: *const u16, @@ -141,7 +141,8 @@ pub struct CoreClrDacBridge { enable_module_load_notifications: EnableModuleLoadNotifications, disable_module_load_notifications: DisableModuleLoadNotifications, is_module_loaded: IsModuleLoaded, - resolve_and_notify: ResolveAndNotify, + resolve_and_notify: ResolveMethod, + resolve_read_only: ResolveMethod, refresh_method_code: RefreshMethodCode, last_error: LastError, runtime_info: ManagedRuntimeInfo, @@ -183,9 +184,10 @@ impl CoreClrDacBridge { }; let is_module_loaded = unsafe { load_symbol::(&library, b"windbg_dac_is_module_loaded\0")? }; - let resolve_and_notify = unsafe { - load_symbol::(&library, b"windbg_dac_resolve_and_notify\0")? - }; + let resolve_and_notify = + unsafe { load_symbol::(&library, b"windbg_dac_resolve_and_notify\0")? }; + let resolve_read_only = + unsafe { load_symbol::(&library, b"windbg_dac_resolve_read_only\0")? }; let refresh_method_code = unsafe { load_symbol::(&library, b"windbg_dac_refresh_method_code\0")? }; @@ -220,6 +222,7 @@ impl CoreClrDacBridge { disable_module_load_notifications, is_module_loaded, resolve_and_notify, + resolve_read_only, refresh_method_code, last_error, runtime_info: managed_runtime_info(&native_runtime)?, @@ -276,6 +279,38 @@ impl CoreClrDacBridge { managed_module_path: &Path, fully_qualified_method: &str, signature_blob: Option<&[u8]>, + ) -> anyhow::Result<(ManagedMethodInfo, ManagedCodeAvailability)> { + self.resolve_method( + self.resolve_and_notify, + managed_module_path, + fully_qualified_method, + signature_blob, + "resolving the managed method", + ) + } + + pub fn resolve_read_only( + &mut self, + managed_module_path: &Path, + fully_qualified_method: &str, + signature_blob: Option<&[u8]>, + ) -> anyhow::Result<(ManagedMethodInfo, ManagedCodeAvailability)> { + self.resolve_method( + self.resolve_read_only, + managed_module_path, + fully_qualified_method, + signature_blob, + "resolving the managed method without CLR notification registration", + ) + } + + fn resolve_method( + &mut self, + resolve_method: ResolveMethod, + managed_module_path: &Path, + fully_qualified_method: &str, + signature_blob: Option<&[u8]>, + operation: &str, ) -> anyhow::Result<(ManagedMethodInfo, ManagedCodeAvailability)> { let managed_module_path = to_wide(managed_module_path.as_os_str())?; let method = to_wide(OsStr::new(fully_qualified_method))?; @@ -291,7 +326,7 @@ impl CoreClrDacBridge { .unwrap_or((std::ptr::null(), 0)); let mut native_method = NativeMethodInfo::default(); let status = unsafe { - (self.resolve_and_notify)( + resolve_method( self.bridge.as_ptr(), managed_module_path.as_ptr(), method.as_ptr(), @@ -300,7 +335,7 @@ impl CoreClrDacBridge { &mut native_method, ) }; - self.handle_method_status(status, &native_method, "resolving the managed method")?; + self.handle_method_status(status, &native_method, operation)?; let availability = if native_method.code_available == 0 { ManagedCodeAvailability::PendingJit } else { diff --git a/crates/windbg-dbgeng/src/lib.rs b/crates/windbg-dbgeng/src/lib.rs index a811bc4..52dcd2d 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -1172,6 +1172,28 @@ impl DebuggerSession { self.breakpoint_info(&breakpoint) } + pub fn add_hardware_execute_breakpoint(&self, address: u64) -> anyhow::Result { + use windows::Win32::System::Diagnostics::Debug::Extensions::{ + DEBUG_BREAKPOINT_DATA, DEBUG_BREAKPOINT_ENABLED, DEBUG_BREAK_EXECUTE, + }; + + let breakpoint = self + .add_compatible_breakpoint(DEBUG_BREAKPOINT_DATA) + .context("DbgEng could not create a processor execute breakpoint")?; + unsafe { + breakpoint.SetOffset(address).with_context(|| { + format!("DbgEng could not set processor execute breakpoint offset 0x{address:X}") + })?; + breakpoint + .SetDataParameters(1, DEBUG_BREAK_EXECUTE) + .context("DbgEng could not configure a one-byte processor execute breakpoint")?; + breakpoint + .AddFlags(DEBUG_BREAKPOINT_ENABLED) + .context("DbgEng could not enable the processor execute breakpoint")?; + } + self.breakpoint_info(&breakpoint) + } + pub fn remove_breakpoint(&self, breakpoint_id: u32) -> anyhow::Result<()> { let breakpoint = unsafe { self.control.GetBreakpointById2(breakpoint_id)? }; unsafe { diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 9f3b59f..004805d 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -680,6 +680,11 @@ struct LiveStartupBreakArgs { help = "Capture the initial DbgEng break without setting a code breakpoint" )] initial_break: bool, + #[arg( + long, + help = "Use a one-byte DbgEng processor execute breakpoint instead of a software code breakpoint; uses a create-process initial stop and cannot be combined with --initial-break" + )] + hardware_execute: bool, #[arg(long, help = "Absolute code address for the breakpoint")] address: Option, #[arg( @@ -740,6 +745,12 @@ struct LiveManagedBreakArgs { help = "Allow the matching DAC to write CLR debugger-notification state; use only in an approved test VM" )] allow_runtime_write: bool, + #[arg( + long, + conflicts_with = "allow_runtime_write", + help = "Use only read-only DAC queries and a DbgEng processor execute breakpoint; does not register CLR notifications or use a software breakpoint" + )] + hardware_execute: bool, #[arg(long, default_value_t = 30000)] initial_break_timeout_ms: u32, #[arg(long, default_value_t = 60000)] diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 4679cc0..1a04dc6 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -78,10 +78,15 @@ pub(super) fn run_live_startup_break( .as_deref() .map(validate_module_load_filter) .transpose()?; + validate_startup_breakpoint_mode(args.hardware_execute, &breakpoint_spec)?; let session = launch_live_session(LiveLaunchSessionOptions { command_line: args.command_line.clone(), initial_break_timeout_ms: args.initial_break_timeout_ms, - initial_stop: LiveInitialStop::SoftwareBreakpoint, + initial_stop: if args.hardware_execute { + LiveInitialStop::CreateProcessEvent + } else { + LiveInitialStop::SoftwareBreakpoint + }, })?; let result = (|| { @@ -111,6 +116,10 @@ pub(super) fn run_live_startup_break( .transpose()?; let requested_breakpoint = match breakpoint_spec { StartupBreakpointSpec::InitialBreak => None, + _ if args.hardware_execute => Some(set_startup_hardware_execute_breakpoint( + &session, + &breakpoint_spec, + )?), _ => Some(set_startup_breakpoint(&session, &breakpoint_spec)?), }; let continued_to_breakpoint = requested_breakpoint @@ -122,6 +131,10 @@ pub(super) fn run_live_startup_break( .then(|| session.wait_for_event(args.wait_timeout_ms)) .transpose()? .unwrap_or_else(|| session.execution_status()); + let breakpoint_event = requested_breakpoint + .is_some() + .then(|| session.last_event()) + .transpose()?; let registers = session.core_registers()?; let instruction_offset = registers.instruction_offset; let context = live_stop_context(&session, registers, args.max_frames)?; @@ -139,13 +152,28 @@ pub(super) fn run_live_startup_break( .map(|breakpoint| breakpoint.offset), ) .is_some_and(|(instruction_offset, breakpoint_offset)| { - event.name.as_deref() == Some("break") && instruction_offset == breakpoint_offset + event.name.as_deref() == Some("break") + && breakpoint_event + .as_ref() + .is_some_and(|event| event.event_name == "breakpoint") + && breakpoint_event + .as_ref() + .and_then(|event| event.breakpoint_id) + == configured_breakpoint + .as_ref() + .map(|breakpoint| breakpoint.id) + && instruction_offset == breakpoint_offset }); Ok(json!({ "workflow": "live_startup_break", "command_line": args.command_line, "breakpoint_spec": breakpoint_spec, + "breakpoint_mode": if args.hardware_execute { "hardware_execute" } else { "software_code" }, + "target_memory_writes": { + "requested": false, + "operations": [] + }, "initial_event": initial_event, "module_load": module_load, "continued_to_breakpoint": continued_to_breakpoint, @@ -153,13 +181,14 @@ pub(super) fn run_live_startup_break( "breakpoint": { "requested": requested_breakpoint, "configured": configured_breakpoint, + "event": breakpoint_event, "hit": breakpoint_hit, "hit_evidence": if breakpoint_hit { - "current instruction pointer equals the configured breakpoint offset" + "DbgEng reported the configured breakpoint ID and the current instruction pointer equals its 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" + "DbgEng did not report the configured breakpoint ID at its configured instruction pointer" } }, "context": context, @@ -182,6 +211,10 @@ pub(super) fn run_live_managed_break( args: LiveManagedBreakArgs, output: &OutputOptions, ) -> anyhow::Result<()> { + if args.hardware_execute { + return run_live_managed_hardware_break(args, output); + } + const CORECLR_MODULE: &str = "coreclr.dll"; const CLR_NOTIFICATION_EXCEPTION: u32 = 0xe044_4143; const CLR_NOTIFICATION_FILTER: &str = "clrn"; @@ -400,6 +433,241 @@ pub(super) fn run_live_managed_break( } } +fn run_live_managed_hardware_break( + args: LiveManagedBreakArgs, + output: &OutputOptions, +) -> anyhow::Result<()> { + const CORECLR_MODULE: &str = "coreclr.dll"; + + ensure!( + !args.allow_runtime_write, + "--hardware-execute cannot be combined with --allow-runtime-write" + ); + let end = parse_live_launch_end(&args.end)?; + let managed_module = validate_managed_module(&args.managed_module)?; + let method = validate_managed_breakpoint_token(&args.method, "--method")?; + let signature = parse_managed_method_signature(args.signature.as_deref())?; + let session = launch_live_session(LiveLaunchSessionOptions { + command_line: args.command_line.clone(), + initial_break_timeout_ms: args.initial_break_timeout_ms, + initial_stop: LiveInitialStop::CreateProcessEvent, + })?; + + let result = (|| { + let initial_event = session.summary(); + session + .execute_command(&format!("sxe ld:{CORECLR_MODULE}")) + .context("configuring the non-invasive CoreCLR module-load stop")?; + let coreclr_wait = continue_to_dbgeng_module_load( + &session, + args.wait_timeout_ms, + "CoreCLR module-load event", + )?; + let modules_after_coreclr_load = session.modules()?; + let coreclr_module = find_loaded_module(&modules_after_coreclr_load, CORECLR_MODULE)?; + let coreclr_path = module_image_path(coreclr_module, "CoreCLR")?; + let mut dac = session + .open_coreclr_dac_bridge(&coreclr_path, false) + .context("initializing the exact-version read-only CoreCLR DAC bridge")?; + + session + .execute_command(&format!("sxe ld:{managed_module}")) + .with_context(|| { + format!("configuring the managed-module load stop for {managed_module}") + })?; + let managed_module_load = continue_to_dbgeng_module_load( + &session, + args.wait_timeout_ms, + "managed module-load event", + )?; + let modules_after_managed_load = session.modules()?; + let loaded_managed_module = + find_loaded_module(&modules_after_managed_load, &managed_module)?; + let managed_module_path = module_image_path(loaded_managed_module, "managed assembly")?; + let module_available = dac.is_module_loaded(&managed_module_path)?; + + let ( + resolved_method, + availability, + configured_breakpoint, + breakpoint_stop, + hit, + binding_state, + ) = if !module_available { + ( + None, + None, + None, + None, + false, + "module_not_observable_without_runtime_write", + ) + } else { + let (resolved_method, availability) = dac + .resolve_read_only(&managed_module_path, &method, signature.as_deref()) + .with_context(|| { + format!( + "resolving {method} in the selected managed module {managed_module} through the read-only DAC" + ) + })?; + match availability { + ManagedCodeAvailability::PendingJit => ( + Some(resolved_method), + Some("pending_jit"), + None, + None, + false, + "pending_jit_unobservable_without_runtime_write", + ), + ManagedCodeAvailability::Available => { + let native_entry_address = + resolved_method.representative_entry_address.context( + "the selected managed method has no generated native entry address", + )?; + let breakpoint = + session.add_hardware_execute_breakpoint(native_entry_address)?; + let (stop, hit) = wait_for_managed_hardware_execute_breakpoint( + &session, + &breakpoint, + native_entry_address, + args.wait_timeout_ms, + )?; + ( + Some(resolved_method), + Some("available"), + Some(breakpoint), + Some(stop), + hit, + if hit { + "hardware_execute_hit" + } else { + "hardware_execute_not_hit" + }, + ) + } + } + }; + + let registers = session.core_registers()?; + let context = live_stop_context(&session, registers, args.max_frames)?; + Ok(json!({ + "workflow": "live_managed_break_dac_hardware_execute", + "command_line": args.command_line, + "initial_event": initial_event, + "target_memory_writes": { + "requested": false, + "dac_notification_registration": false, + "software_code_breakpoint": false, + "operations": [] + }, + "coreclr_module_load": { + "module": CORECLR_MODULE, + "load": coreclr_wait, + "loaded_module": coreclr_module, + "runtime": dac.runtime_info() + }, + "managed_module_load": { + "managed_module": managed_module, + "load": managed_module_load, + "loaded_module": loaded_managed_module, + "module_available_to_read_only_dac": module_available + }, + "managed_resolution": { + "method_request": method, + "signature_request_hex": signature.as_deref().map(format_managed_method_signature), + "resolved_method": resolved_method, + "code_availability": availability, + "exact_overload_signature_selection": signature.is_some(), + "private_methods_resolve_as_metadata": true + }, + "managed_breakpoint": { + "kind": "hardware_execute", + "configured": configured_breakpoint, + "stop": breakpoint_stop, + "hit": hit, + "binding_state": binding_state, + "hit_evidence": if hit { + "DbgEng reported the configured processor breakpoint ID and the current instruction pointer both match the DAC-mapped native entry for the selected MethodDef" + } else { + "No managed hit is claimed: a hardware breakpoint can be set only for native code already visible to the read-only DAC at the managed module-load stop" + } + }, + "limitations": [ + "Without CLR code notifications, a method first JIT-compiled after this module-load event is not observable and cannot be bound.", + "Tiered recompilation, ReadyToRun indirection, re-JIT, generic instantiations, and unload transitions can replace or invalidate a native entry.", + "x64 processor breakpoints use a constrained per-thread debug-register resource; no thread restriction is configured by this command." + ], + "context": context, + "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"), + } +} + +fn continue_to_dbgeng_module_load( + session: &DebuggerSession, + wait_timeout_ms: u32, + description: &str, +) -> anyhow::Result { + let continued = session.continue_execution()?; + let wait = session + .wait_for_event(wait_timeout_ms) + .with_context(|| format!("waiting for the {description}"))?; + let event = session + .last_event() + .with_context(|| format!("reading the {description}"))?; + ensure!( + wait.name.as_deref() == Some("break") + && event.event_name == "load_module" + && event.module_base.is_some(), + "DbgEng did not stop on the requested {description}: wait={wait:?}, event={event:?}" + ); + Ok(json!({ + "continued": continued, + "wait": wait, + "event": event + })) +} + +fn wait_for_managed_hardware_execute_breakpoint( + session: &DebuggerSession, + breakpoint: &BreakpointInfo, + native_entry_address: u64, + wait_timeout_ms: u32, +) -> anyhow::Result<(Value, bool)> { + let continued = session.continue_execution()?; + let wait = session + .wait_for_event(wait_timeout_ms) + .context("waiting for the managed processor execute breakpoint")?; + let event = (wait.name.as_deref() == Some("break")) + .then(|| session.last_event()) + .transpose()?; + let registers = session.core_registers()?; + let hit = event.as_ref().is_some_and(|event| { + event.event_name == "breakpoint" + && event.breakpoint_id == Some(breakpoint.id) + && registers.instruction_offset == Some(native_entry_address) + }); + Ok(( + json!({ + "continued": continued, + "wait": wait, + "event": event, + "instruction_pointer": registers.instruction_offset + }), + hit, + )) +} + fn continue_after_clr_notification( session: &DebuggerSession, clr_notification_pending: bool, @@ -789,6 +1057,17 @@ fn startup_breakpoint_spec(args: &LiveStartupBreakArgs) -> anyhow::Result anyhow::Result<()> { + ensure!( + !hardware_execute || !matches!(spec, StartupBreakpointSpec::InitialBreak), + "--hardware-execute requires --address, --module with --module-offset, or --symbol" + ); + Ok(()) +} + fn set_startup_breakpoint( session: &DebuggerSession, spec: &StartupBreakpointSpec, @@ -813,6 +1092,51 @@ fn set_startup_breakpoint( } } +fn set_startup_hardware_execute_breakpoint( + session: &DebuggerSession, + spec: &StartupBreakpointSpec, +) -> anyhow::Result { + let address = startup_breakpoint_address(session, spec)?; + session.add_hardware_execute_breakpoint(address) +} + +fn startup_breakpoint_address( + session: &DebuggerSession, + spec: &StartupBreakpointSpec, +) -> anyhow::Result { + match spec { + StartupBreakpointSpec::InitialBreak => { + bail!("initial-break capture does not identify a processor execute breakpoint address") + } + StartupBreakpointSpec::Address { address } => Ok(*address), + StartupBreakpointSpec::ModuleOffset { module, offset } => { + let modules = session.modules()?; + let module = find_loaded_module(&modules, module)?; + module + .base_address + .checked_add(*offset) + .context("module base plus breakpoint offset overflowed") + } + StartupBreakpointSpec::Symbol { expression } => { + let evaluation = session + .evaluate(expression) + .with_context(|| format!("evaluating hardware breakpoint symbol '{expression}'"))?; + evaluation + .unsigned_value + .or_else(|| { + evaluation + .signed_value + .and_then(|address| u64::try_from(address).ok()) + }) + .with_context(|| { + format!( + "DbgEng did not resolve hardware breakpoint symbol '{expression}' to an unsigned address" + ) + }) + } + } +} + fn find_loaded_module<'a>( modules: &'a [ModuleInfo], requested_module: &str, @@ -2117,6 +2441,7 @@ mod tests { let args = LiveStartupBreakArgs { command_line: "target.exe".to_string(), initial_break: false, + hardware_execute: false, address: Some("0x140001000".to_string()), module: Some("target.exe".to_string()), module_offset: Some("0x1000".to_string()), @@ -2153,6 +2478,16 @@ mod tests { startup_breakpoint_spec(&initial_break_args).unwrap(), StartupBreakpointSpec::InitialBreak ); + assert!( + validate_startup_breakpoint_mode(true, &StartupBreakpointSpec::InitialBreak).is_err() + ); + assert!(validate_startup_breakpoint_mode( + true, + &StartupBreakpointSpec::Address { + address: 0x140001000 + } + ) + .is_ok()); } #[test] diff --git a/docs/architecture.md b/docs/architecture.md index cc94b16..7e3e898 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ or run [scripts/Get-TtdReplayRuntime.ps1](../scripts/Get-TtdReplayRuntime.ps1) d The native replay 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. -Live managed breakpoints use a separate [CoreCLR DAC bridge](../native/coreclr-dac-bridge/CMakeLists.txt), never the TTD bridge. It is a narrow C ABI that receives the active DbgEng client, hosts a version- and architecture-matched `mscordaccore.dll`, and implements `ICLRDataTarget` using DbgEng module, memory, thread, and context services. It uses the stable CLR metadata import contract to compare exact raw ECMA-335 `MethodDef` signature bytes when the caller selects an overload, while reporting bounded token/signature diagnostics for name matches. It resolves metadata through a method definition, then obtains the matching method instance after code-generation notification; only that instance supplies the JIT-native breakpoint address. Its opt-in `ICLRDataTarget2` implementation uses DbgEng's active process handle solely for the CLR-requested JIT-notification-table allocation; the CLR then owns that allocation. It exposes only opaque handles and POD output for module/method resolution, CLR notification registration, and representative generated-code addresses. It does not attach `ICorDebug`, load SOS, invoke debugger extension commands, hollow the process, or inject managed code. The x64 bridge is staged separately under `target/native/coreclr-dac-bridge`; the target DAC remains runtime-provided and is not redistributed. +Live managed breakpoints use a separate [CoreCLR DAC bridge](../native/coreclr-dac-bridge/CMakeLists.txt), never the TTD bridge. It is a narrow C ABI that receives the active DbgEng client, hosts a version- and architecture-matched `mscordaccore.dll`, and implements `ICLRDataTarget` using DbgEng module, memory, thread, and context services. It uses the stable CLR metadata import contract to compare exact raw ECMA-335 `MethodDef` signature bytes when the caller selects an overload, while reporting bounded token/signature diagnostics for name matches. It resolves metadata through a method definition, then obtains the matching method instance after code-generation notification; only that instance supplies the JIT-native breakpoint address. Its opt-in `ICLRDataTarget2` implementation uses DbgEng's active process handle solely for the CLR-requested JIT-notification-table allocation; the CLR then owns that allocation. A separate read-only resolver never calls notification registration and can report an existing native method instance, but cannot observe a module or method first registered/JIT-compiled after the stopped native load event. It exposes only opaque handles and POD output for module/method resolution, CLR notification registration, and representative generated-code addresses. It does not attach `ICorDebug`, load SOS, invoke debugger extension commands, hollow the process, or inject managed code. The x64 bridge is staged separately under `target/native/coreclr-dac-bridge`; the target DAC remains runtime-provided and is not redistributed. ## Symbols diff --git a/docs/cli.md b/docs/cli.md index ab376c5..afa7d43 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -90,7 +90,7 @@ 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. +`live startup-break` is a one-shot native DbgEng workflow: by default it launches at the initial debug break, configures one software 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. For a development build with DbgEng staged outside the executable directory, set `WINDBG_DBGENG_RUNTIME_DIR` to the matching `dbgeng-runtime` directory. Packaged builds load the matching DbgEng runtime copied beside `windbg-tool.exe`; both paths use a process-local, dependency-safe loader. @@ -111,6 +111,26 @@ 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. +### Non-invasive processor execute breakpoint + +`--hardware-execute` switches `live startup-break` to a DbgEng processor breakpoint. It starts from a create-process event (not a software initial break), waits for the requested native module-load event, then creates `DEBUG_BREAKPOINT_DATA` with `DEBUG_BREAK_EXECUTE` and byte size `1`. It does not open the DAC, request CLR notifications, or place a software breakpoint byte in target memory. + +The .NET 10.0.9 x64 fixture verified this path against CoreCLR's `coreclr_execute_assembly` export (RVA `0x2F690` for that runtime): + +```powershell +$fixture = Resolve-Path crates\windbg-tool\tests\fixtures\ManagedBreakpointFixture +target\debug\windbg-tool.exe --compact live startup-break ` + --command-line "`"$fixture\bin\Release\net10.0\win-x64\ManagedBreakpointFixture.exe`"" ` + --wait-for-module coreclr.dll ` + --module coreclr.dll --module-offset 0x2F690 ` + --hardware-execute --initial-break-timeout-ms 30000 --wait-timeout-ms 30000 ` + --end terminate +``` + +Use `symbols exports --filter coreclr_execute_assembly` to obtain the RVA for a different CoreCLR version. The validated response had `breakpoint_mode: "hardware_execute"`, `configured.break_type: 1`, `configured.data_access_type: 4`, `configured.data_size: 1`, `configured.match_thread: null`, `configured.id: 0`, and `hit: true`. DbgEng then reported breakpoint ID `0` at `coreclr!coreclr_execute_assembly` with the current instruction pointer equal to the configured address. This is a verified **native** processor-breakpoint hit, not a managed-method hit. + +Processor breakpoints are a constrained CPU resource. [Microsoft's processor-breakpoint documentation](https://learn.microsoft.com/windows-hardware/drivers/debugger/processor-breakpoints---ba-breakpoints-) distinguishes them from software breakpoints and permits execute access on code. On x64, DR0 through DR3 provide only four address-register slots per thread context; existing debugger or target users can reduce usable capacity. DbgEng supports an engine-thread filter through [`IDebugBreakpoint2::SetMatchThreadId`](https://learn.microsoft.com/windows-hardware/drivers/ddi/dbgeng/nf-dbgeng-idebugbreakpoint2-setmatchthreadid), but this one-shot command deliberately leaves it unset (`match_thread: null`), so any target thread may trigger the breakpoint. A thread filter would not create additional hardware-register capacity. + ## Managed CoreCLR DAC breakpoints `live managed-break` does not load SOS or execute `!bpmd`. It starts with a DbgEng create-process event, stops on CoreCLR and the requested managed module load, dynamically loads the exact x64 `mscordaccore.dll` that is a sibling of the target's loaded `coreclr.dll`, resolves the selected `MethodDef` through the DAC, requests CLR code-generation notification, then creates a DbgEng **code** breakpoint at the DAC-mapped native entry. `managed_breakpoint.hit` is true only when both the DbgEng breakpoint ID and current instruction pointer match that DAC-mapped address. @@ -121,6 +141,28 @@ CLR's notification mechanism writes debugger-notification state into the target. The bridge resolves metadata through `IXCLRDataMethodDefinition`, then uses the matching `IXCLRDataMethodInstance` after CLR code-generation notification. A definition's representative address is IL, not executable code; the code breakpoint always uses the instance's JIT-native entry address. +### Read-only managed hardware mode + +`live managed-break --hardware-execute` is a separate, deliberately restricted path. It is mutually exclusive with `--allow-runtime-write` and performs only a read-only DAC query plus a DbgEng processor execute breakpoint when a native method instance is already available. It does not request module/code notifications, allocate CLR notification state, use `WriteVirtual` or `VirtualAllocEx`, create a software code breakpoint, use SOS, inject code, or force JIT compilation. + +The command starts from create-process and native module-load events. It resolves exact private/overload metadata only if CoreCLR has already registered the requested module with the DAC at the native image-load stop. If the selected method already has a native instance, it uses a one-byte `DEBUG_BREAK_EXECUTE` breakpoint at that exact DAC-native address and reports a hit only when DbgEng's breakpoint ID and current instruction pointer both match. + +The safe .NET 10.0.9 x64 fixture established that the native `ManagedBreakpointFixture.dll` load event occurs before the module is visible to the read-only DAC. This invocation therefore correctly returned `managed_breakpoint.binding_state: "module_not_observable_without_runtime_write"` and `hit: false`: + +```powershell +$env:WINDBG_CORECLR_DAC_BRIDGE_DLL = ` + (Resolve-Path target\native\coreclr-dac-bridge\bin\Release\windbg_coreclr_dac_bridge.dll) +$fixture = Resolve-Path crates\windbg-tool\tests\fixtures\ManagedBreakpointFixture +target\debug\windbg-tool.exe --compact live managed-break ` + --command-line "`"$fixture\bin\Release\net10.0\win-x64\ManagedBreakpointFixture.exe`"" ` + --managed-module ManagedBreakpointFixture.dll ` + --method ManagedBreakpointFixture.ManagedTargets.PrivateEntry ` + --hardware-execute --initial-break-timeout-ms 30000 --wait-timeout-ms 30000 ` + --end terminate +``` + +This is an intentional non-hit, not a fallback failure: the tool leaves the process at the module-load stop, then ends the session according to `--end`, rather than continuing to wait for JIT or mutating CLR state. Consequently, the current non-invasive mode cannot prove a private managed-method hit. A later JIT event, tiered recompilation, ReadyToRun indirection, re-JIT, generic instantiation, or unload can create or replace the native entry, and the read-only path has no supported event that tracks those transitions. The verified native `coreclr!coreclr_execute_assembly` hit above does not establish managed-method semantics. + ```powershell target\debug\windbg-tool.exe --compact live managed-break ` --command-line '"C:\apps\RemoteDesktopManager_x64.exe" /AutoCloseAfter:10' ` @@ -192,7 +234,7 @@ The same command with `--method Devolutions.RemoteDesktopManager.Program.ApplyDp ### RDM approval requirement -The prior RDM evidence above was collected in an approved test VM. Do not run another RDM `live managed-break` session until the application owner and endpoint-security owner approve a scoped debugger policy/exclusion for the designated test VM and RDM build. That approval must permit DbgEng's normal code breakpoint and the explicitly requested CLR JIT-notification allocation/write enabled by `--allow-runtime-write`. Do not disable, weaken, bypass, or otherwise alter Sophos, HitmanPro.Alert, or RDM protections to obtain a hit. +The prior RDM evidence above was collected in an approved test VM. RDM continuation is currently blocked: do not launch, resume, or attach RDM under either software or hardware breakpoints, and do not seek an exclusion or workaround. Any future RDM proof requires a new explicit direction plus written approval from the application owner and endpoint-security owner for the designated test VM and RDM build. That approval must state the allowed debugger operations; it does not authorize disabling, weakening, bypassing, or otherwise altering Sophos, HitmanPro.Alert, or RDM protections. ## AI-oriented DbgEng target inspection diff --git a/docs/development.md b/docs/development.md index 806767f..f90a9e2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -81,7 +81,7 @@ For a direct managed breakpoint, the DAC bridge loads `mscordaccore.dll` only fr dotnet build crates\windbg-tool\tests\fixtures\ManagedBreakpointFixture\ManagedBreakpointFixture.csproj -c Release ``` -Run the fixture's public/private/overload `live managed-break` commands from [cli.md](cli.md) only in an approved test VM. The overload invocation uses `--signature 00010E0E`, the raw ECMA-335 `MethodDef` signature for `string Overload(string)`. The current endpoint policy blocks further RDM live-debugging validation pending an approved, scoped debugger policy/exclusion for that VM and RDM build; do not change Sophos, HitmanPro.Alert, or RDM protections to work around that block. +Run the fixture's public/private/overload `live managed-break` commands from [cli.md](cli.md) only in an approved test VM. The overload invocation uses `--signature 00010E0E`, the raw ECMA-335 `MethodDef` signature for `string Overload(string)`. The separate `--hardware-execute` mode is non-invasive: it uses a DbgEng `DEBUG_BREAKPOINT_DATA`/`DEBUG_BREAK_EXECUTE` processor breakpoint, opens the DAC read-only, and rejects `--allow-runtime-write`. It was verified for the native `coreclr!coreclr_execute_assembly` fixture hit, but a managed assembly's DbgEng image-load event precedes its read-only DAC visibility, so it cannot currently prove a private managed-method hit. Do not work around that lifecycle boundary with target writes, JIT forcing, injection, protection changes, or unapproved debugging. RDM continuation is blocked under the current policy: do not launch, attach, or resume it under any breakpoint, and do not seek exclusions or workarounds. Any future RDM work requires a new explicit direction and written approval defining the permitted debugger operations for the test VM and build; do not change Sophos, HitmanPro.Alert, or RDM protections. 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. diff --git a/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.cpp b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.cpp index af6f456..9bfa065 100644 --- a/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.cpp +++ b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.cpp @@ -1374,14 +1374,14 @@ extern "C" WindbgDacStatus windbg_dac_is_module_loaded( return WINDBG_DAC_OK; } -extern "C" WindbgDacStatus windbg_dac_resolve_and_notify( +WindbgDacStatus resolve_managed_method( WindbgDacBridge* bridge, const wchar_t* managed_module_path, const wchar_t* fully_qualified_method, const uint8_t* signature_blob, uint32_t signature_blob_length, - WindbgDacMethodInfo* method_info) { - g_last_error.clear(); + WindbgDacMethodInfo* method_info, + bool request_code_notification) { if (bridge == nullptr || managed_module_path == nullptr || fully_qualified_method == nullptr || method_info == nullptr) { return fail( @@ -1512,6 +1512,9 @@ extern "C" WindbgDacStatus windbg_dac_resolve_and_notify( bridge->method_candidates.data(), method_info->candidates, sizeof(method_info->candidates)); + if (!request_code_notification) { + return WINDBG_DAC_OK; + } result = bridge->method.get()->SetCodeNotification(method_info->code_notification_flags); if (FAILED(result)) { bridge->method.reset(); @@ -1529,6 +1532,42 @@ extern "C" WindbgDacStatus windbg_dac_resolve_and_notify( return WINDBG_DAC_OK; } +extern "C" WindbgDacStatus windbg_dac_resolve_and_notify( + WindbgDacBridge* bridge, + const wchar_t* managed_module_path, + const wchar_t* fully_qualified_method, + const uint8_t* signature_blob, + uint32_t signature_blob_length, + WindbgDacMethodInfo* method_info) { + g_last_error.clear(); + return resolve_managed_method( + bridge, + managed_module_path, + fully_qualified_method, + signature_blob, + signature_blob_length, + method_info, + true); +} + +extern "C" WindbgDacStatus windbg_dac_resolve_read_only( + WindbgDacBridge* bridge, + const wchar_t* managed_module_path, + const wchar_t* fully_qualified_method, + const uint8_t* signature_blob, + uint32_t signature_blob_length, + WindbgDacMethodInfo* method_info) { + g_last_error.clear(); + return resolve_managed_method( + bridge, + managed_module_path, + fully_qualified_method, + signature_blob, + signature_blob_length, + method_info, + false); +} + extern "C" WindbgDacStatus windbg_dac_refresh_method_code( WindbgDacBridge* bridge, WindbgDacMethodInfo* method_info) { diff --git a/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h index 4184a36..4b84499 100644 --- a/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h +++ b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h @@ -89,6 +89,16 @@ WINDBG_DAC_EXPORT WindbgDacStatus windbg_dac_resolve_and_notify( uint32_t signature_blob_length, WindbgDacMethodInfo* method_info); +// Resolves metadata and any already-generated native method instance without +// registering CLR notifications or invoking any target-write callback. +WINDBG_DAC_EXPORT WindbgDacStatus windbg_dac_resolve_read_only( + WindbgDacBridge* bridge, + const wchar_t* managed_module_path, + const wchar_t* fully_qualified_method, + const uint8_t* signature_blob, + uint32_t signature_blob_length, + WindbgDacMethodInfo* method_info); + WINDBG_DAC_EXPORT WindbgDacStatus windbg_dac_refresh_method_code( WindbgDacBridge* bridge, WindbgDacMethodInfo* method_info); From 269feef20f19285d6563dcbfc0eaf60a2033700f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Jul 2026 12:07:33 -0400 Subject: [PATCH 07/12] Add non-invasive startup profiling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-tool/src/cli.rs | 90 +++ crates/windbg-tool/src/cli/dispatch.rs | 1 + crates/windbg-tool/src/cli/platform.rs | 893 ++++++++++++++++++++++++- docs/architecture.md | 2 + docs/cli.md | 104 ++- docs/development.md | 8 + 6 files changed, 1093 insertions(+), 5 deletions(-) diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 004805d..0f3386d 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -277,6 +277,10 @@ enum LiveCommand { about = "Launch under DbgEng, set an address/module-RVA/symbol breakpoint, and emit bounded stop context" )] StartupBreak(LiveStartupBreakArgs), + #[command( + about = "Profile bounded DbgEng lifecycle events with host-monotonic wall-clock timing and no breakpoints" + )] + StartupProfile(LiveStartupProfileArgs), #[command( about = "Launch .NET under DbgEng, bind the matching CoreCLR DAC, and emit a managed method hit" )] @@ -719,6 +723,81 @@ struct LiveStartupBreakArgs { end: String, } +#[derive(Debug, Args)] +struct LiveStartupProfileArgs { + #[arg(long, help = "Full command line to launch under DbgEng")] + command_line: String, + #[arg( + long, + default_value_t = 1, + value_parser = clap::value_parser!(u32).range(1..=10), + help = "Independent bounded launches to collect; stops after the first launch failure" + )] + runs: u32, + #[arg( + long, + default_value_t = 30000, + help = "Maximum time to observe the initial DbgEng create-process event" + )] + initial_break_timeout_ms: u32, + #[arg( + long, + default_value_t = 90000, + help = "Maximum target-resumed wall time for each run after the create-process event" + )] + timeout_ms: u32, + #[arg( + long, + default_value_t = 256, + value_parser = clap::value_parser!(u32).range(2..=1024), + help = "Maximum DbgEng lifecycle events retained per run" + )] + max_events: u32, + #[arg( + long, + value_name = "MODULE", + help = "Optional application module basename used for observable module-to-exit phases" + )] + phase_module: Option, + #[arg( + long, + help = "Request first-chance exception stops in addition to lifecycle events; can substantially increase event volume" + )] + include_first_chance_exceptions: bool, + #[arg( + long, + help = "Attach bounded read-only register/module/symbol/stack context to early observed stops" + )] + capture_stop_context: bool, + #[arg( + long, + default_value_t = 8, + value_parser = clap::value_parser!(u32).range(1..=32), + help = "Maximum event contexts captured when --capture-stop-context is set" + )] + max_context_events: u32, + #[arg( + long, + default_value_t = 8, + value_parser = clap::value_parser!(u32).range(1..=32), + help = "Maximum stack frames in each optional stop context" + )] + max_frames: u32, + #[arg( + long, + value_name = "PATH", + help = "Write the complete JSON result to a new file as well as stdout" + )] + output: Option, + #[arg( + long, + default_value = "terminate", + value_parser = ["detach", "terminate"], + help = "Action if the target has not exited at its event or time bound" + )] + end: String, +} + #[derive(Debug, Args)] struct LiveManagedBreakArgs { #[arg(long, help = "Full command line to launch under DbgEng")] @@ -4475,6 +4554,7 @@ fn inferred_command_metadata(command: &Command, path: &[String]) -> Value { | "live capabilities" | "live launch" | "live startup-break" + | "live startup-profile" | "breakpoint capabilities" | "datamodel capabilities" ); @@ -4546,6 +4626,7 @@ fn discover_manifest() -> Value { "live": [ "live capabilities", "live launch --command-line --end detach|terminate", + "live startup-profile --command-line [--runs ] [--phase-module ]", "live start --command-line ", "live attach --process-id " ], @@ -5213,6 +5294,15 @@ fn command_metadata() -> Value { "safety": "live_debugging_changes_target_execution_state", "bounds": ["--initial-break-timeout-ms", "--wait-timeout-ms", "--max-frames", "--end detach|terminate"] }, + { + "command": "live startup-profile", + "requires_daemon": false, + "requires_native_ttd": false, + "session_required": false, + "cost": "launches_process_and_collects_bounded_lifecycle_events", + "safety": "live_debugging_changes_target_execution_state_without_target_memory_writes", + "bounds": ["--runs 1..10", "--initial-break-timeout-ms", "--timeout-ms", "--max-events", "--max-context-events", "--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 af86833..a2e8858 100644 --- a/crates/windbg-tool/src/cli/dispatch.rs +++ b/crates/windbg-tool/src/cli/dispatch.rs @@ -46,6 +46,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::StartupProfile(args) => platform::run_live_startup_profile(args, &output), LiveCommand::ManagedBreak(args) => platform::run_live_managed_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, diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 1a04dc6..ee8ca3d 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -1,7 +1,10 @@ use anyhow::{bail, ensure, Context}; +use serde::Serialize; use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::ffi::OsString; +use std::fs; use std::os::windows::ffi::OsStrExt; use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; @@ -29,8 +32,8 @@ use windows::Win32::System::Threading::{ use super::output::{print_value, OutputOptions}; use super::{ CliDumpKind, DbgEngServerArgs, DumpCreateArgs, DumpInspectArgs, LiveLaunchArgs, - LiveManagedBreakArgs, LiveStartupBreakArgs, TraceRecordArgs, TraceRecordProfile, - TraceReplayCpuSupport, WindbgCommand, + LiveManagedBreakArgs, LiveStartupBreakArgs, LiveStartupProfileArgs, TraceRecordArgs, + TraceRecordProfile, TraceReplayCpuSupport, WindbgCommand, }; pub(super) fn run_dbgeng_server( @@ -207,6 +210,736 @@ pub(super) fn run_live_startup_break( } } +const STARTUP_PROFILE_CORECLR_MODULE: &str = "coreclr.dll"; +const STARTUP_PROFILE_MAX_MODULE_IDENTITIES: usize = 128; + +#[derive(Debug, Clone, Serialize)] +struct StartupProfileModule { + base_address: String, + basename: Option, + module_name: Option, + image_path: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct StartupProfileEvent { + index: usize, + kind: String, + observed_elapsed_ms: u64, + resumed_wall_elapsed_ms: u64, + event: windbg_dbgeng::DebuggerEventInfo, + module: Option, + loaded_module_count: Option, + live_thread_count: Option, + context: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct StartupProfilePhase { + name: String, + status: String, + elapsed_ms: Option, + start_event_index: Option, + end_event_index: Option, + detail: String, +} + +#[derive(Debug, Clone, Serialize)] +struct StartupProfileRun { + run: u32, + status: String, + finish_reason: String, + target: windbg_dbgeng::DebuggerSessionSummary, + timing: Value, + event_filters: Value, + timeline: Vec, + phase_durations: Vec, + counts: Value, + coverage: Value, + cleanup: Value, +} + +pub(super) fn run_live_startup_profile( + args: LiveStartupProfileArgs, + output_options: &OutputOptions, +) -> anyhow::Result<()> { + let end = parse_live_launch_end(&args.end)?; + let phase_module = args + .phase_module + .as_deref() + .map(validate_module_load_filter) + .transpose()?; + ensure!( + args.runs == 1 || matches!(end, LiveLaunchEnd::Terminate), + "--runs greater than one requires --end terminate so a bounded run cannot leave a detached target behind" + ); + if let Some(path) = args.output.as_deref() { + ensure!( + !path.exists(), + "refusing to overwrite startup-profile artifact {}", + path.display() + ); + let parent = path + .parent() + .context("startup-profile artifact path must have a parent directory")?; + ensure!( + parent.is_dir(), + "startup-profile artifact directory does not exist: {}", + parent.display() + ); + } + + let mut runs = Vec::with_capacity(args.runs as usize); + let mut completed_runs = Vec::with_capacity(args.runs as usize); + for run_index in 1..=args.runs { + match collect_startup_profile_run(&args, run_index, phase_module.as_deref(), end) { + Ok(run) => { + if run.status == "completed" { + completed_runs.push(run.clone()); + } + let should_stop = run.status != "completed"; + runs.push(serde_json::to_value(run)?); + if should_stop { + break; + } + } + Err(error) => { + runs.push(json!({ + "run": run_index, + "status": "failed", + "error": error.to_string() + })); + break; + } + } + } + + let mut result = json!({ + "workflow": "live_startup_profile", + "command_line": args.command_line, + "requested_runs": args.runs, + "runs_completed_with_process_exit": completed_runs.len(), + "runs": runs, + "aggregate": startup_profile_aggregate(&completed_runs), + "target_memory_writes": { + "requested": false, + "operations": [], + "software_breakpoints": false, + "hardware_breakpoints": false, + "dac_bridge": false, + "clr_notifications": false, + "injection": false, + "profiler": false + }, + "measurement_semantics": { + "clock": "host_monotonic_instant", + "launch_to_create_ms": "Host elapsed time from before DbgEng session creation until windbg-tool observes the DbgEng create-process stop.", + "phase_elapsed_ms": "Host wall time accumulated only while the target is resumed between observed DbgEng stops. It excludes windbg-tool's intentional stopped-state filter/context work, but includes debugger scheduling and event-delivery latency.", + "event_timestamps": "Observed when DbgEng returns control to windbg-tool, not target-side instruction timestamps.", + "not_cpu_time": true, + "regression_interpretation": "Repeated values can identify wall-clock variability or candidates for comparison with a baseline. They do not attribute CPU use or prove a regression." + }, + "limitations": [ + "DbgEng lifecycle events do not establish managed assembly registration, managed method execution, JIT activity, or CPU attribution.", + "Debuggee output is not captured into structured JSON because this command does not install an output callback; a target can still inherit the invoking console.", + "First-chance exceptions are opt-in because managed startup can generate enough events to consume the bounded timeline." + ] + }); + if let Some(path) = args.output { + fs::write(&path, serde_json::to_vec_pretty(&result)?) + .with_context(|| format!("writing startup-profile artifact {}", path.display()))?; + result["artifact"] = json!({ + "path": path, + "format": "pretty_json", + "written": true + }); + } + print_value(result, output_options) +} + +fn collect_startup_profile_run( + args: &LiveStartupProfileArgs, + run_index: u32, + phase_module: Option<&str>, + end: LiveLaunchEnd, +) -> anyhow::Result { + let command_started = Instant::now(); + let session = launch_live_session(LiveLaunchSessionOptions { + command_line: args.command_line.clone(), + initial_break_timeout_ms: args.initial_break_timeout_ms, + initial_stop: LiveInitialStop::CreateProcessEvent, + })?; + + let result = + collect_startup_profile_stops(&session, args, run_index, phase_module, command_started); + let cleanup = match &result { + Ok(run) if run.finish_reason == "exit_process" => Ok(json!({ + "action": "none", + "status": "not_needed", + "detail": "DbgEng reported exit_process before cleanup." + })), + _ => match end { + LiveLaunchEnd::Detach => session.detach().map(|()| { + json!({ + "action": "detach", + "status": "ok" + }) + }), + LiveLaunchEnd::Terminate => session.terminate().map(|()| { + json!({ + "action": "terminate", + "status": "ok" + }) + }), + }, + }; + + match (result, cleanup) { + (Ok(mut run), Ok(cleanup)) => { + run.cleanup = cleanup; + Ok(run) + } + (Ok(mut run), Err(error)) => { + run.status = "cleanup_failed".to_string(); + run.cleanup = json!({ + "action": end, + "status": "error", + "error": error.to_string() + }); + Ok(run) + } + (Err(error), Ok(_)) => Err(error), + (Err(error), Err(cleanup_error)) => Err(error).context(format!( + "also failed to end the live debug session: {cleanup_error}" + )), + } +} + +fn collect_startup_profile_stops( + session: &DebuggerSession, + args: &LiveStartupProfileArgs, + run_index: u32, + phase_module: Option<&str>, + command_started: Instant, +) -> anyhow::Result { + let initial_event = session + .last_event() + .context("reading the initial DbgEng create-process event")?; + ensure!( + initial_event.event_name == "create_process", + "DbgEng did not stop on create-process: {initial_event:?}" + ); + + let mut recording = StartupProfileRecording { + timeline: Vec::with_capacity(args.max_events as usize), + ..StartupProfileRecording::default() + }; + let mut resumed_elapsed = Duration::ZERO; + record_startup_profile_event( + session, + &mut recording, + args, + initial_event, + (command_started.elapsed(), resumed_elapsed), + ); + + let event_filters = + configure_startup_profile_event_filters(session, args.include_first_chance_exceptions)?; + let deadline = Instant::now() + Duration::from_millis(u64::from(args.timeout_ms)); + let mut timeline_truncated = false; + let mut tail_filter_commands = Vec::new(); + let finish_reason = loop { + if !timeline_truncated && recording.timeline.len() >= args.max_events as usize - 1 { + tail_filter_commands = configure_startup_profile_exit_tail_filters( + session, + args.include_first_chance_exceptions, + )?; + timeline_truncated = true; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break "timeout"; + } + session.continue_execution()?; + let resumed_at = Instant::now(); + let wait_timeout_ms = duration_millis(remaining).clamp(1, u64::from(u32::MAX)) as u32; + let wait = session.wait_for_event(wait_timeout_ms)?; + resumed_elapsed += resumed_at.elapsed(); + if wait.name.as_deref() == Some("timeout") { + break "timeout"; + } + let event = session + .last_event() + .context("reading a DbgEng lifecycle event")?; + let exiting = event.event_name == "exit_process"; + if !timeline_truncated || exiting { + record_startup_profile_event( + session, + &mut recording, + args, + event, + (command_started.elapsed(), resumed_elapsed), + ); + } + if exiting { + break "exit_process"; + } + }; + + let phase_durations = derive_startup_profile_phases(&recording.timeline, phase_module); + let StartupProfileRecording { + timeline, + counts, + module_identities, + captured_contexts, + } = recording; + let module_identities = module_identities + .into_iter() + .take(STARTUP_PROFILE_MAX_MODULE_IDENTITIES) + .collect::>(); + let module_identity_truncated = + counts.unique_module_identity_count > STARTUP_PROFILE_MAX_MODULE_IDENTITIES; + let timeline_len = timeline.len(); + let status = if finish_reason == "exit_process" { + "completed" + } else { + "incomplete" + }; + Ok(StartupProfileRun { + run: run_index, + status: status.to_string(), + finish_reason: finish_reason.to_string(), + target: session.summary(), + timing: json!({ + "command_to_create_observed_ms": timeline + .first() + .map(|event| event.observed_elapsed_ms), + "target_resumed_wall_elapsed_ms": duration_millis(resumed_elapsed), + "timeout_after_create_ms": args.timeout_ms, + "timeout_clock": "host_monotonic_target_resumed_wall_time", + "timeline_retention_limit_reached": timeline_truncated, + "tail_filter_commands": tail_filter_commands + }), + event_filters, + timeline, + phase_durations, + counts: json!({ + "module_load_events": counts.module_load_events, + "module_unload_events": counts.module_unload_events, + "create_thread_events": counts.create_thread_events, + "exit_thread_events": counts.exit_thread_events, + "exception_events": counts.exception_events, + "first_chance_exception_events": counts.first_chance_exception_events, + "peak_observed_live_thread_count": counts.peak_live_thread_count, + "peak_observed_loaded_module_count": counts.peak_loaded_module_count, + "unique_observed_module_identities": module_identities, + "unique_observed_module_identity_count": counts.unique_module_identity_count, + "unique_observed_module_identities_truncated": module_identity_truncated + }), + coverage: json!({ + "timeline_event_limit": args.max_events, + "timeline_events_returned": timeline_len, + "timeline_truncated": timeline_truncated, + "truncation_behavior": if timeline_truncated { + "After retaining max_events - 1 lifecycle entries, windbg-tool disabled high-volume filters and waited only for exit_process so the final exit boundary remains observable." + } else { + "All observed lifecycle events were retained." + }, + "finished_at_process_exit": finish_reason == "exit_process", + "phase_module": phase_module, + "first_chance_exceptions_requested": args.include_first_chance_exceptions, + "stop_context_requested": args.capture_stop_context, + "stop_contexts_returned": captured_contexts + }), + cleanup: Value::Null, + }) +} + +#[derive(Default)] +struct StartupProfileCounts { + module_load_events: u32, + module_unload_events: u32, + create_thread_events: u32, + exit_thread_events: u32, + exception_events: u32, + first_chance_exception_events: u32, + peak_live_thread_count: usize, + peak_loaded_module_count: usize, + unique_module_identity_count: usize, +} + +#[derive(Default)] +struct StartupProfileRecording { + timeline: Vec, + counts: StartupProfileCounts, + module_identities: BTreeSet, + captured_contexts: u32, +} + +fn configure_startup_profile_event_filters( + session: &DebuggerSession, + include_first_chance_exceptions: bool, +) -> anyhow::Result { + let mut commands = Vec::new(); + if include_first_chance_exceptions { + session + .execute_command("sxe *") + .context("configuring DbgEng to stop on first-chance exceptions")?; + commands.push("sxe *"); + } + for (command, event) in [ + ("sxe cpr", "create_process"), + ("sxe epr", "exit_process"), + ("sxe ct", "create_thread"), + ("sxe et", "exit_thread"), + ("sxe ld", "load_module"), + ("sxe ud", "unload_module"), + ] { + session + .execute_command(command) + .with_context(|| format!("configuring DbgEng {event} event filtering"))?; + commands.push(command); + } + Ok(json!({ + "commands": commands, + "first_chance_exceptions": if include_first_chance_exceptions { + "all_requested_with_sxe_wildcard" + } else { + "not_requested; DbgEng's default unhandled-exception behavior remains observable if it stops the target" + } + })) +} + +fn configure_startup_profile_exit_tail_filters( + session: &DebuggerSession, + include_first_chance_exceptions: bool, +) -> anyhow::Result> { + let mut commands = Vec::new(); + if include_first_chance_exceptions { + session + .execute_command("sxd *") + .context("disabling first-chance exception stops after the startup timeline limit")?; + commands.push("sxd *".to_string()); + } + for (command, event) in [ + ("sxd ct", "create_thread"), + ("sxd et", "exit_thread"), + ("sxd ld", "load_module"), + ("sxd ud", "unload_module"), + ("sxe epr", "exit_process"), + ] { + session + .execute_command(command) + .with_context(|| format!("configuring DbgEng exit-tail {event} filtering"))?; + commands.push(command.to_string()); + } + Ok(commands) +} + +fn record_startup_profile_event( + session: &DebuggerSession, + recording: &mut StartupProfileRecording, + args: &LiveStartupProfileArgs, + event: windbg_dbgeng::DebuggerEventInfo, + timing: (Duration, Duration), +) { + let (observed_elapsed, resumed_elapsed) = timing; + let kind = event.event_name.clone(); + let module = event + .module_base + .and_then(|address| session.module_by_offset(address).ok().flatten()) + .map(normalize_startup_profile_module); + let loaded_module_count = if matches!(kind.as_str(), "load_module" | "unload_module") { + session.modules().ok().map(|modules| modules.len()) + } else { + None + }; + let live_thread_count = if matches!( + kind.as_str(), + "create_process" | "create_thread" | "exit_thread" + ) { + session.threads().ok().map(|threads| threads.len()) + } else { + None + }; + match kind.as_str() { + "load_module" => recording.counts.module_load_events += 1, + "unload_module" => recording.counts.module_unload_events += 1, + "create_thread" => recording.counts.create_thread_events += 1, + "exit_thread" => recording.counts.exit_thread_events += 1, + "exception" => { + recording.counts.exception_events += 1; + if event + .exception + .as_ref() + .is_some_and(|exception| exception.first_chance) + { + recording.counts.first_chance_exception_events += 1; + } + } + _ => {} + } + if let Some(count) = loaded_module_count { + recording.counts.peak_loaded_module_count = + recording.counts.peak_loaded_module_count.max(count); + } + if let Some(count) = live_thread_count { + recording.counts.peak_live_thread_count = + recording.counts.peak_live_thread_count.max(count); + } + if let Some(module) = module.as_ref() { + if let Some(identity) = module.basename.as_deref().or(module.module_name.as_deref()) { + recording + .module_identities + .insert(identity.to_ascii_lowercase()); + recording.counts.unique_module_identity_count = recording.module_identities.len(); + } + } + let context = + if args.capture_stop_context && recording.captured_contexts < args.max_context_events { + recording.captured_contexts += 1; + Some(match session.core_registers() { + Ok(registers) => match live_stop_context(session, registers, args.max_frames) { + Ok(context) => json!({ + "status": "ok", + "value": context + }), + Err(error) => json!({ + "status": "error", + "error": error.to_string() + }), + }, + Err(error) => json!({ + "status": "error", + "error": error.to_string() + }), + }) + } else { + None + }; + recording.timeline.push(StartupProfileEvent { + index: recording.timeline.len(), + kind, + observed_elapsed_ms: duration_millis(observed_elapsed), + resumed_wall_elapsed_ms: duration_millis(resumed_elapsed), + event, + module, + loaded_module_count, + live_thread_count, + context, + }); +} + +fn normalize_startup_profile_module(module: ModuleInfo) -> StartupProfileModule { + let image_path = [ + module.loaded_image_name.as_deref(), + module.image_name.as_deref(), + module.module_name.as_deref(), + ] + .into_iter() + .flatten() + .find(|value| !value.trim().is_empty()) + .map(normalize_startup_profile_path); + let basename = image_path + .as_deref() + .and_then(|path| Path::new(path).file_name()) + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned); + StartupProfileModule { + base_address: format!("0x{:X}", module.base_address), + basename, + module_name: module.module_name, + image_path, + } +} + +fn normalize_startup_profile_path(value: &str) -> String { + value.replace('\\', "/") +} + +fn derive_startup_profile_phases( + timeline: &[StartupProfileEvent], + phase_module: Option<&str>, +) -> Vec { + let create = timeline.iter().find(|event| event.kind == "create_process"); + let coreclr = find_startup_profile_module_event(timeline, STARTUP_PROFILE_CORECLR_MODULE); + let selected = + phase_module.and_then(|module| find_startup_profile_module_event(timeline, module)); + let exit = timeline.iter().find(|event| event.kind == "exit_process"); + let mut phases = Vec::with_capacity(5); + phases.push(match create { + Some(event) => StartupProfilePhase { + name: "launch_to_create_process".to_string(), + status: "observed".to_string(), + elapsed_ms: Some(event.observed_elapsed_ms), + start_event_index: None, + end_event_index: Some(event.index), + detail: "Host command-start to observed DbgEng create-process stop.".to_string(), + }, + None => unavailable_startup_profile_phase( + "launch_to_create_process", + "DbgEng did not report a create-process event.", + ), + }); + phases.push(startup_profile_phase_between( + "create_process_to_coreclr_load", + create, + coreclr, + "Requires observed create-process and coreclr.dll load events.", + )); + if let Some(module) = phase_module { + phases.push(startup_profile_phase_between( + "coreclr_load_to_selected_module_load", + coreclr, + selected, + &format!("Requires observed coreclr.dll and selected module ({module}) load events."), + )); + phases.push(startup_profile_phase_between( + "selected_module_load_to_exit_process", + selected, + exit, + &format!("Requires observed selected module ({module}) load and exit-process events."), + )); + } else { + phases.push(unavailable_startup_profile_phase( + "coreclr_load_to_selected_module_load", + "No --phase-module was requested.", + )); + phases.push(unavailable_startup_profile_phase( + "selected_module_load_to_exit_process", + "No --phase-module was requested.", + )); + } + phases.push(startup_profile_phase_between( + "create_process_to_exit_process", + create, + exit, + "Requires observed create-process and exit-process events.", + )); + phases +} + +fn find_startup_profile_module_event<'a>( + timeline: &'a [StartupProfileEvent], + requested_module: &str, +) -> Option<&'a StartupProfileEvent> { + timeline.iter().find(|event| { + event.kind == "load_module" + && event.module.as_ref().is_some_and(|module| { + [ + module.basename.as_deref(), + module.module_name.as_deref(), + module.image_path.as_deref(), + ] + .into_iter() + .flatten() + .any(|candidate| startup_profile_module_name_matches(candidate, requested_module)) + }) + }) +} + +fn startup_profile_module_name_matches(candidate: &str, requested: &str) -> bool { + module_name_matches(candidate, requested) + || Path::new(candidate) + .file_stem() + .zip(Path::new(requested).file_stem()) + .is_some_and(|(candidate, requested)| candidate.eq_ignore_ascii_case(requested)) +} + +fn startup_profile_phase_between( + name: &str, + start: Option<&StartupProfileEvent>, + end: Option<&StartupProfileEvent>, + missing_detail: &str, +) -> StartupProfilePhase { + match (start, end) { + (Some(start), Some(end)) if end.resumed_wall_elapsed_ms >= start.resumed_wall_elapsed_ms => { + StartupProfilePhase { + name: name.to_string(), + status: "observed".to_string(), + elapsed_ms: Some(end.resumed_wall_elapsed_ms - start.resumed_wall_elapsed_ms), + start_event_index: Some(start.index), + end_event_index: Some(end.index), + detail: "Host monotonic wall time accumulated while the target was resumed between observed DbgEng stops.".to_string(), + } + } + (Some(start), Some(end)) => StartupProfilePhase { + name: name.to_string(), + status: "unavailable".to_string(), + elapsed_ms: None, + start_event_index: Some(start.index), + end_event_index: Some(end.index), + detail: "Observed event order was not monotonic; duration is intentionally omitted.".to_string(), + }, + _ => unavailable_startup_profile_phase(name, missing_detail), + } +} + +fn unavailable_startup_profile_phase(name: &str, detail: &str) -> StartupProfilePhase { + StartupProfilePhase { + name: name.to_string(), + status: "unavailable".to_string(), + elapsed_ms: None, + start_event_index: None, + end_event_index: None, + detail: detail.to_string(), + } +} + +fn startup_profile_aggregate(completed_runs: &[StartupProfileRun]) -> Value { + let mut values_by_phase = BTreeMap::>::new(); + let mut phase_occurrences = BTreeMap::::new(); + for run in completed_runs { + for phase in &run.phase_durations { + *phase_occurrences.entry(phase.name.clone()).or_default() += 1; + if phase.status == "observed" { + if let Some(value) = phase.elapsed_ms { + values_by_phase + .entry(phase.name.clone()) + .or_default() + .push(value); + } + } + } + } + let phases = phase_occurrences + .into_iter() + .map(|(name, count)| { + let mut values = values_by_phase.remove(&name).unwrap_or_default(); + values.sort_unstable(); + let sample_count = values.len(); + let median_ms = if sample_count == 0 { + None + } else if sample_count % 2 == 1 { + Some(values[sample_count / 2] as f64) + } else { + Some((values[sample_count / 2 - 1] as f64 + values[sample_count / 2] as f64) / 2.0) + }; + json!({ + "name": name, + "sample_count": sample_count, + "missing_count": count - sample_count, + "min_ms": values.first(), + "median_ms": median_ms, + "max_ms": values.last(), + "regression_assessment": { + "status": "no_baseline", + "detail": "This aggregate has no reference baseline, so it reports wall-clock variability only and does not label a regression or CPU cause." + } + }) + }) + .collect::>(); + json!({ + "completed_run_count": completed_runs.len(), + "phase_wall_time_ms": phases, + "coverage": "Only runs that reached exit_process contribute samples. A missing boundary remains missing rather than inferred." + }) +} + +fn duration_millis(duration: Duration) -> u64 { + duration.as_millis().min(u128::from(u64::MAX)) as u64 +} + pub(super) fn run_live_managed_break( args: LiveManagedBreakArgs, output: &OutputOptions, @@ -1941,6 +2674,7 @@ pub(super) fn live_capabilities() -> Value { "dbgeng server", "live launch --command-line --end detach|terminate", "live startup-break --command-line --initial-break|--address |--module --module-offset |--symbol ", + "live startup-profile --command-line [--runs ] [--phase-module ]", "live start --command-line ", "live attach --process-id ", "dump create --process-id --output ", @@ -1960,6 +2694,11 @@ pub(super) fn live_capabilities() -> Value { "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": "startup profile workflow", + "status": "non_invasive_bounded_lifecycle_collection", + "notes": "Launches at a create-process event, configures DbgEng lifecycle event filters only, and reports host-monotonic wall-time observations. It sets no software/hardware breakpoint, opens no DAC, and performs no target-memory operation." + }, { "feature": "managed method breakpoint workflow", "status": "x64_coreclr_dac_vertical_slice", @@ -1979,7 +2718,7 @@ pub(super) fn live_capabilities() -> Value { "gaps": [ "step-over/step-out controls", "module/symbol reload management", - "exception filtering and event callbacks", + "event callbacks", "richer debugger output capture" ], "safety": [ @@ -2534,4 +3273,152 @@ mod tests { assert!(validate_managed_module("RemoteDesktopManager").is_err()); assert!(validate_managed_module(r#"RemoteDesktopManager.dll;qd"#).is_err()); } + + fn startup_profile_event_for_test( + index: usize, + kind: &str, + observed_elapsed_ms: u64, + resumed_wall_elapsed_ms: u64, + module: Option<&str>, + ) -> StartupProfileEvent { + StartupProfileEvent { + index, + kind: kind.to_string(), + observed_elapsed_ms, + resumed_wall_elapsed_ms, + event: windbg_dbgeng::DebuggerEventInfo { + event_type: 0, + event_name: kind.to_string(), + process_system_id: 1, + thread_system_id: 2, + description: None, + extra_information_size: 0, + breakpoint_id: None, + exception: None, + module_base: module.map(|_| 0x1000), + exit_code: None, + }, + module: module.map(|basename| StartupProfileModule { + base_address: "0x1000".to_string(), + basename: Some(basename.to_string()), + module_name: Some(basename.to_string()), + image_path: Some(format!("C:/fixture/{basename}")), + }), + loaded_module_count: None, + live_thread_count: None, + context: None, + } + } + + #[test] + fn startup_profile_derives_only_observed_lifecycle_phases() { + let timeline = vec![ + startup_profile_event_for_test(0, "create_process", 12, 0, None), + startup_profile_event_for_test(1, "load_module", 20, 5, Some("coreclr.dll")), + startup_profile_event_for_test( + 2, + "load_module", + 30, + 15, + Some("RemoteDesktopManager.dll"), + ), + startup_profile_event_for_test(3, "exit_process", 40, 35, None), + ]; + + let phases = derive_startup_profile_phases(&timeline, Some("RemoteDesktopManager.dll")); + + assert_eq!(phases[0].elapsed_ms, Some(12)); + assert_eq!(phases[1].elapsed_ms, Some(5)); + assert_eq!(phases[2].elapsed_ms, Some(10)); + assert_eq!(phases[3].elapsed_ms, Some(20)); + assert_eq!(phases[4].elapsed_ms, Some(35)); + + let missing = derive_startup_profile_phases(&timeline[..2], Some("app.dll")); + assert_eq!(missing[2].status, "unavailable"); + assert_eq!(missing[2].elapsed_ms, None); + assert_eq!(missing[3].elapsed_ms, None); + assert_eq!(missing[4].elapsed_ms, None); + } + + fn startup_profile_run_for_test(run: u32, phase_values: &[u64]) -> StartupProfileRun { + StartupProfileRun { + run, + status: "completed".to_string(), + finish_reason: "exit_process".to_string(), + target: windbg_dbgeng::DebuggerSessionSummary { + kind: windbg_dbgeng::DebuggerSessionKind::Live, + target: "fixture.exe".to_string(), + process_id: Some(run), + dump_path: None, + processor_type: Some(0x8664), + processor_name: Some("AMD64".to_string()), + execution_status: windbg_dbgeng::DebuggerExecutionStatus { + raw: Some(0), + name: Some("break".to_string()), + }, + symbol_path: "srv*cache*https://msdl.microsoft.com/download/symbols".to_string(), + }, + timing: json!({}), + event_filters: json!({}), + timeline: Vec::new(), + phase_durations: phase_values + .iter() + .map(|elapsed_ms| StartupProfilePhase { + name: "create_process_to_exit_process".to_string(), + status: "observed".to_string(), + elapsed_ms: Some(*elapsed_ms), + start_event_index: Some(0), + end_event_index: Some(1), + detail: "test".to_string(), + }) + .collect(), + counts: json!({}), + coverage: json!({}), + cleanup: json!({}), + } + } + + #[test] + fn startup_profile_aggregate_reports_wall_time_median_without_regression_claim() { + let runs = [ + startup_profile_run_for_test(1, &[10]), + startup_profile_run_for_test(2, &[30]), + startup_profile_run_for_test(3, &[20]), + ]; + + let aggregate = startup_profile_aggregate(&runs); + let phase = &aggregate["phase_wall_time_ms"][0]; + + assert_eq!(aggregate["completed_run_count"], 3); + assert_eq!(phase["sample_count"], 3); + assert_eq!(phase["min_ms"], 10); + assert_eq!(phase["median_ms"], 20.0); + assert_eq!(phase["max_ms"], 30); + assert_eq!(phase["regression_assessment"]["status"], "no_baseline"); + } + + #[test] + fn startup_profile_module_paths_are_normalized_for_output() { + let module = normalize_startup_profile_module(ModuleInfo { + base_address: 0x140000000, + module_name: Some("fixture".to_string()), + image_name: None, + loaded_image_name: Some(r"C:\fixture\bin\fixture.dll".to_string()), + symbol_file: None, + }); + + assert_eq!(module.basename.as_deref(), Some("fixture.dll")); + assert_eq!( + module.image_path.as_deref(), + Some("C:/fixture/bin/fixture.dll") + ); + assert!(startup_profile_module_name_matches( + "coreclr", + "coreclr.dll" + )); + assert!(startup_profile_module_name_matches( + "ManagedBreakpointFixture", + "ManagedBreakpointFixture.dll" + )); + } } diff --git a/docs/architecture.md b/docs/architecture.md index 7e3e898..58caca5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,6 +31,8 @@ The native replay bridge has a CMake project at [native/ttd-replay-bridge/CMakeL Live managed breakpoints use a separate [CoreCLR DAC bridge](../native/coreclr-dac-bridge/CMakeLists.txt), never the TTD bridge. It is a narrow C ABI that receives the active DbgEng client, hosts a version- and architecture-matched `mscordaccore.dll`, and implements `ICLRDataTarget` using DbgEng module, memory, thread, and context services. It uses the stable CLR metadata import contract to compare exact raw ECMA-335 `MethodDef` signature bytes when the caller selects an overload, while reporting bounded token/signature diagnostics for name matches. It resolves metadata through a method definition, then obtains the matching method instance after code-generation notification; only that instance supplies the JIT-native breakpoint address. Its opt-in `ICLRDataTarget2` implementation uses DbgEng's active process handle solely for the CLR-requested JIT-notification-table allocation; the CLR then owns that allocation. A separate read-only resolver never calls notification registration and can report an existing native method instance, but cannot observe a module or method first registered/JIT-compiled after the stopped native load event. It exposes only opaque handles and POD output for module/method resolution, CLR notification registration, and representative generated-code addresses. It does not attach `ICorDebug`, load SOS, invoke debugger extension commands, hollow the process, or inject managed code. The x64 bridge is staged separately under `target/native/coreclr-dac-bridge`; the target DAC remains runtime-provided and is not redistributed. +`live startup-profile` is intentionally outside that DAC path. It owns a short-lived DbgEng session, begins at a create-process event, applies only DbgEng lifecycle event filters, and accumulates host-monotonic wall time while the target is resumed between observed stops. The profiler records bounded process/thread/module/exception event metadata and optional read-only stop context, but does not use DbgEng breakpoint APIs, target-memory APIs, callbacks, CLR notifications, SOS, a DAC, or a second debugging engine. Its phase derivation accepts only explicit observed lifecycle boundaries and labels the result as wall-clock observation rather than CPU attribution or managed execution evidence. + ## Symbols The default symbol path is equivalent to: diff --git a/docs/cli.md b/docs/cli.md index afa7d43..c2b3276 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -212,6 +212,106 @@ The .NET 10.0.9 x64 fixture was validated with the direct DAC bridge: | private `ManagedTargets.PrivateEntry()` | `100663300` | `00000E` | `hit: true`; breakpoint ID `0` and IP matched its native entry | | `ManagedTargets.Overload(string)` | `100663302` | `00010E0E` | `hit: true`; breakpoint ID `0`, IP matched native entry, and the selected candidates were `0x06000005:00000E` and `0x06000006:00010E0E` | +## Non-invasive live startup profiling + +`live startup-profile` is a one-shot DbgEng lifecycle collector for agent-oriented startup evidence. It launches at a DbgEng `create_process` event, enables only create/exit-process, create/exit-thread, load/unload-module event filters, then resumes the target and records bounded stopped events. It does **not** configure a software or hardware breakpoint, open the DAC, request CLR notifications, allocate or write target memory, inject code, or use a profiler. + +The default exception policy does not request first-chance exceptions because managed startup can produce enough expected exceptions to consume the bounded timeline. Use `--include-first-chance-exceptions` only when that additional event volume is justified. Debuggee output is not captured into the structured result because the command does not install a DbgEng output callback, although a target can still inherit the invoking console. + +The command reports two host-monotonic measures: + +- `command_to_create_observed_ms` is the wall time from before DbgEng session creation until windbg-tool observes the create-process event. +- Timeline `resumed_wall_elapsed_ms` and derived `phase_durations` accumulate only while the target is resumed between observed DbgEng stops. This excludes the collector's intentionally stopped filter/configuration/context work, but includes DbgEng scheduling and event-delivery latency. + +Neither measure is target CPU time. A module-load timestamp does not identify managed registration, JIT work, or method execution. Repeated runs report min/median/max wall time only for phases whose two event boundaries were observed in runs that reached `exit_process`; no baseline means the output deliberately does not call a value a regression. + +Build and profile the source-only fixture without any DAC or breakpoint workflow: + +```powershell +$fixture = Resolve-Path crates\windbg-tool\tests\fixtures\ManagedBreakpointFixture +dotnet build "$fixture\ManagedBreakpointFixture.csproj" -c Release +$report = Join-Path $env:TEMP "windbg-tool-fixture-startup-profile.json" + +target\debug\windbg-tool.exe --compact live startup-profile ` + --command-line "`"$fixture\bin\Release\net10.0\win-x64\ManagedBreakpointFixture.exe`"" ` + --runs 3 ` + --phase-module ManagedBreakpointFixture.dll ` + --initial-break-timeout-ms 30000 ` + --timeout-ms 30000 ` + --max-events 256 ` + --output $report ` + --end terminate +``` + +`--max-events` bounds retained timeline payload rather than forcing a healthy target to terminate. After retaining `max_events - 1` entries, windbg-tool disables the high-volume thread/module filters and waits only for `exit_process`, preserving the final exit boundary in the last slot. The result marks this as `coverage.timeline_truncated: true` and lists the DbgEng `timing.tail_filter_commands`; it makes no claim about events omitted during that exit-only tail. `--end` is used only if the time bound is reached before process exit. A successful run has `status: "completed"`, `finish_reason: "exit_process"`, and `cleanup.status: "not_needed"`. An incomplete run records `timeout` and then applies `--end`; it is excluded from aggregate samples, and a repeated collection stops after that incomplete run. The optional `--capture-stop-context` captures bounded read-only register/module/symbol/stack context on early stops; it can increase observer overhead and is disabled by default. + +The important JSON fields are: + +```json +{ + "workflow": "live_startup_profile", + "target_memory_writes": { + "requested": false, + "software_breakpoints": false, + "hardware_breakpoints": false, + "dac_bridge": false + }, + "runs": [{ + "finish_reason": "exit_process", + "timeline": [{ + "kind": "load_module", + "observed_elapsed_ms": 41, + "resumed_wall_elapsed_ms": 10, + "module": { "basename": "coreclr.dll" } + }], + "phase_durations": [{ + "name": "create_process_to_coreclr_load", + "status": "observed", + "elapsed_ms": 10 + }] + }], + "aggregate": { + "phase_wall_time_ms": [{ + "name": "create_process_to_coreclr_load", + "sample_count": 3, + "min_ms": 8, + "median_ms": 10, + "max_ms": 13, + "regression_assessment": { "status": "no_baseline" } + }] + } +} +``` + +For a future RDM observation, use the bounded no-breakpoint form only after the fixture command has completed with the expected no-write report: + +```powershell +$rdm = 'D:\dev\.copilot\copilot-worktrees\RDM\bookish-winner\Windows\RemoteDesktopManager\Program\bin\Release\net10.0-windows10.0.19041\RemoteDesktopManager_x64.exe' +target\debug\windbg-tool.exe --compact live startup-profile ` + --command-line "`"$rdm`" /AutoCloseAfter:30" ` + --phase-module RemoteDesktopManager.dll ` + --initial-break-timeout-ms 30000 ` + --timeout-ms 90000 ` + --max-events 512 ` + --end terminate +``` + +Do not add `live startup-break`, `live managed-break`, DAC options, or an endpoint-security workaround to that RDM command. If the initial safe-mode RDM run is blocked or fails, retain its DbgEng error/event evidence and stop rather than retrying or changing protection policy. + +### Observed local RDM lifecycle evidence + +The local Release x64 RDM build above completed an event-only profile with `exit_process` code `0` and `target_memory_writes.requested: false`. After that initial run established the safe lifecycle path, three bounded repetitions used `--runs 3 --max-events 128`; each retained 128 events, switched to exit-only tail collection, and reached `exit_process` code `0`. + +| Observable host-wall phase | Min ms | Median ms | Max ms | +| --- | ---: | ---: | ---: | +| command start to create-process observation | 24 | 25 | 165 | +| create-process to `coreclr.dll` load | 409 | 444 | 606 | +| `coreclr.dll` load to `RemoteDesktopManager.dll` load | 36 | 41 | 45 | +| `RemoteDesktopManager.dll` load to exit-process | 49,183 | 53,327 | 65,624 | +| create-process to exit-process | 49,633 | 53,807 | 66,275 | + +The first observed run found `coreclr.dll` at event index 22 and `RemoteDesktopManager.dll` at index 34. These are observer wall-clock lifecycle intervals, not CPU samples or proof that a specific RDM phase consumed CPU. The longest observable interval is therefore a follow-up wall-time investigation candidate only. One target-console `HttpListenerException (87)` was printed during the repeated collection, while the structured DbgEng records contained zero exception stops and all three runs exited with code `0`; the profile does not diagnose or attribute that application output. + ### Verified RDM x64 test-VM run The approved .NET 10.0.9 x64 test VM used this direct, no-SOS invocation: @@ -234,7 +334,7 @@ The same command with `--method Devolutions.RemoteDesktopManager.Program.ApplyDp ### RDM approval requirement -The prior RDM evidence above was collected in an approved test VM. RDM continuation is currently blocked: do not launch, resume, or attach RDM under either software or hardware breakpoints, and do not seek an exclusion or workaround. Any future RDM proof requires a new explicit direction plus written approval from the application owner and endpoint-security owner for the designated test VM and RDM build. That approval must state the allowed debugger operations; it does not authorize disabling, weakening, bypassing, or otherwise altering Sophos, HitmanPro.Alert, or RDM protections. +The prior RDM evidence above was collected in an approved test VM. RDM remains blocked for breakpoint and DAC workflows: do not launch, resume, or attach it under software or hardware breakpoints, CLR notifications, or target-memory writes, and do not seek an exclusion or workaround. The separate `live startup-profile` flow above is limited to lifecycle-event filters and has no target-memory operation; it may be attempted once under the current profiling policy after fixture validation. If that safe mode is blocked, stop rather than retrying. Any future breakpoint/DAC RDM proof requires a new explicit direction plus written approval from the application owner and endpoint-security owner for the designated test VM and RDM build. That approval must state the allowed debugger operations; it does not authorize disabling, weakening, bypassing, or otherwise altering Sophos, HitmanPro.Alert, or RDM protections. ## AI-oriented DbgEng target inspection @@ -379,7 +479,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 event`, `target thread`, `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`, `live startup-profile`, `dump create`, `dump open`, `dump inspect`, `target event`, `target thread`, `target dump`, `windbg status` | ## Useful non-replay commands diff --git a/docs/development.md b/docs/development.md index f90a9e2..b15da6e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,6 +83,14 @@ dotnet build crates\windbg-tool\tests\fixtures\ManagedBreakpointFixture\ManagedB Run the fixture's public/private/overload `live managed-break` commands from [cli.md](cli.md) only in an approved test VM. The overload invocation uses `--signature 00010E0E`, the raw ECMA-335 `MethodDef` signature for `string Overload(string)`. The separate `--hardware-execute` mode is non-invasive: it uses a DbgEng `DEBUG_BREAKPOINT_DATA`/`DEBUG_BREAK_EXECUTE` processor breakpoint, opens the DAC read-only, and rejects `--allow-runtime-write`. It was verified for the native `coreclr!coreclr_execute_assembly` fixture hit, but a managed assembly's DbgEng image-load event precedes its read-only DAC visibility, so it cannot currently prove a private managed-method hit. Do not work around that lifecycle boundary with target writes, JIT forcing, injection, protection changes, or unapproved debugging. RDM continuation is blocked under the current policy: do not launch, attach, or resume it under any breakpoint, and do not seek exclusions or workarounds. Any future RDM work requires a new explicit direction and written approval defining the permitted debugger operations for the test VM and build; do not change Sophos, HitmanPro.Alert, or RDM protections. +## Non-invasive startup-profile fixture + +`live startup-profile` validates the event-only DbgEng path without a breakpoint or DAC. It starts at a create-process event and configures only DbgEng lifecycle filters. It performs no target-memory allocation/write, no CLR notification registration, no injection, and no profiling. Run its fixture command from [cli.md](cli.md) before any RDM profile attempt. + +The command's phase values are host-monotonic resumed wall time between DbgEng stops, not CPU time or target-internal timestamps. Preserve `finish_reason`, `coverage`, and incomplete runs when evaluating output. `--capture-stop-context` is read-only but opt-in because its symbol/stack queries add observer overhead. `--include-first-chance-exceptions` is also opt-in and bounded by `--max-events`. + +The RDM policy remains stricter for all breakpoint/DAC workflows. The event-only profile command can make one bounded RDM launch after the fixture proves its no-write result. Only after that run reaches `exit_process` may it make a small bounded repeated collection; if the initial run is blocked, do not retry or seek an exclusion, protection change, or workaround. + 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. ### Publishing a GitHub Release From f0ad2873f1b03019a370b720fcbaf7f6776a8302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Jul 2026 12:45:35 -0400 Subject: [PATCH 08/12] Refine non-invasive startup profiling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-tool/src/cli.rs | 22 +- crates/windbg-tool/src/cli/platform.rs | 716 +++++++++++++++++- .../ManagedBreakpointFixture/Program.cs | 28 +- docs/architecture.md | 2 +- docs/cli.md | 66 +- docs/development.md | 2 +- 6 files changed, 794 insertions(+), 42 deletions(-) diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 0f3386d..3ff3f02 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -759,6 +759,20 @@ struct LiveStartupProfileArgs { help = "Optional application module basename used for observable module-to-exit phases" )] phase_module: Option, + #[arg( + long, + value_name = "MODULE", + help = "Finish the startup observation when this module-load event is observed; does not imply UI readiness" + )] + completion_module: Option, + #[arg( + long, + value_name = "MILLISECONDS", + requires = "completion_module", + value_parser = clap::value_parser!(u32).range(1..=60000), + help = "After --completion-module, require this much target-resumed time without another configured lifecycle stop before completing; only an observed lifecycle quiet interval" + )] + settle_ms: Option, #[arg( long, help = "Request first-chance exception stops in addition to lifecycle events; can substantially increase event volume" @@ -791,9 +805,9 @@ struct LiveStartupProfileArgs { output: Option, #[arg( long, - default_value = "terminate", + default_value = "detach", value_parser = ["detach", "terminate"], - help = "Action if the target has not exited at its event or time bound" + help = "Action if the target has not exited at its completion or time bound; terminate is explicit" )] end: String, } @@ -4626,7 +4640,7 @@ fn discover_manifest() -> Value { "live": [ "live capabilities", "live launch --command-line --end detach|terminate", - "live startup-profile --command-line [--runs ] [--phase-module ]", + "live startup-profile --command-line [--runs ] [--phase-module ] [--completion-module [--settle-ms ]]", "live start --command-line ", "live attach --process-id " ], @@ -5301,7 +5315,7 @@ fn command_metadata() -> Value { "session_required": false, "cost": "launches_process_and_collects_bounded_lifecycle_events", "safety": "live_debugging_changes_target_execution_state_without_target_memory_writes", - "bounds": ["--runs 1..10", "--initial-break-timeout-ms", "--timeout-ms", "--max-events", "--max-context-events", "--end detach|terminate"] + "bounds": ["--runs 1..10", "--initial-break-timeout-ms", "--timeout-ms", "--max-events", "--completion-module ", "--settle-ms 1..60000 (requires --completion-module)", "--max-context-events", "--end detach|terminate"] }, { "command": "live start", diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index ee8ca3d..d04a532 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -212,6 +212,8 @@ pub(super) fn run_live_startup_break( const STARTUP_PROFILE_CORECLR_MODULE: &str = "coreclr.dll"; const STARTUP_PROFILE_MAX_MODULE_IDENTITIES: usize = 128; +const STARTUP_PROFILE_MAX_FIRST_SEEN_MODULES: usize = 32; +const STARTUP_PROFILE_MAX_RANKED_GAPS: usize = 8; #[derive(Debug, Clone, Serialize)] struct StartupProfileModule { @@ -244,6 +246,45 @@ struct StartupProfilePhase { detail: String, } +#[derive(Debug, Clone, Serialize)] +struct StartupProfileEventReference { + index: usize, + kind: String, + observed_elapsed_ms: u64, + resumed_wall_elapsed_ms: u64, + thread_system_id: u32, + module: Option, + description: Option, + exception_code: Option, + exception_first_chance: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct StartupProfileGap { + rank: usize, + elapsed_ms: u64, + start: StartupProfileEventReference, + end: StartupProfileEventReference, + detail: String, +} + +#[derive(Debug, Clone, Serialize)] +struct StartupProfileExcludedGap { + start: StartupProfileEventReference, + end: StartupProfileEventReference, + reason: String, +} + +#[derive(Debug, Clone, Serialize)] +struct StartupProfileCompletion { + requested_module: Option, + settle_ms: Option, + status: String, + module_load: Option, + quiet_resumed_elapsed_ms: Option, + detail: String, +} + #[derive(Debug, Clone, Serialize)] struct StartupProfileRun { run: u32, @@ -254,6 +295,10 @@ struct StartupProfileRun { event_filters: Value, timeline: Vec, phase_durations: Vec, + completion: StartupProfileCompletion, + lifecycle_summary: Value, + largest_observed_gaps: Vec, + gaps_excluded_from_ranking: Vec, counts: Value, coverage: Value, cleanup: Value, @@ -269,6 +314,15 @@ pub(super) fn run_live_startup_profile( .as_deref() .map(validate_module_load_filter) .transpose()?; + let completion_module = args + .completion_module + .as_deref() + .map(validate_module_load_filter) + .transpose()?; + let observed_phase_module = phase_module + .as_deref() + .or(completion_module.as_deref()) + .map(ToOwned::to_owned); ensure!( args.runs == 1 || matches!(end, LiveLaunchEnd::Terminate), "--runs greater than one requires --end terminate so a bounded run cannot leave a detached target behind" @@ -291,10 +345,20 @@ pub(super) fn run_live_startup_profile( let mut runs = Vec::with_capacity(args.runs as usize); let mut completed_runs = Vec::with_capacity(args.runs as usize); + let mut completed_runs_with_process_exit = 0usize; for run_index in 1..=args.runs { - match collect_startup_profile_run(&args, run_index, phase_module.as_deref(), end) { + match collect_startup_profile_run( + &args, + run_index, + observed_phase_module.as_deref(), + completion_module.as_deref(), + end, + ) { Ok(run) => { if run.status == "completed" { + if run.finish_reason == "exit_process" { + completed_runs_with_process_exit += 1; + } completed_runs.push(run.clone()); } let should_stop = run.status != "completed"; @@ -318,7 +382,8 @@ pub(super) fn run_live_startup_profile( "workflow": "live_startup_profile", "command_line": args.command_line, "requested_runs": args.runs, - "runs_completed_with_process_exit": completed_runs.len(), + "runs_completed": completed_runs.len(), + "runs_completed_with_process_exit": completed_runs_with_process_exit, "runs": runs, "aggregate": startup_profile_aggregate(&completed_runs), "target_memory_writes": { @@ -336,12 +401,15 @@ pub(super) fn run_live_startup_profile( "launch_to_create_ms": "Host elapsed time from before DbgEng session creation until windbg-tool observes the DbgEng create-process stop.", "phase_elapsed_ms": "Host wall time accumulated only while the target is resumed between observed DbgEng stops. It excludes windbg-tool's intentional stopped-state filter/context work, but includes debugger scheduling and event-delivery latency.", "event_timestamps": "Observed when DbgEng returns control to windbg-tool, not target-side instruction timestamps.", + "completion_module": "A requested module load is an observable image-load boundary only. It does not establish UI readiness, managed assembly registration, first managed method execution, or successful application initialization.", + "quiet_interval": "A requested settle interval establishes only that no configured DbgEng lifecycle stop was observed while the target was resumed for that duration. It does not establish CPU, I/O, UI, or application quiescence.", "not_cpu_time": true, "regression_interpretation": "Repeated values can identify wall-clock variability or candidates for comparison with a baseline. They do not attribute CPU use or prove a regression." }, "limitations": [ "DbgEng lifecycle events do not establish managed assembly registration, managed method execution, JIT activity, or CPU attribution.", "Debuggee output is not captured into structured JSON because this command does not install an output callback; a target can still inherit the invoking console.", + "Largest observed gaps rank only adjacent retained events while the full lifecycle filter set was active. Tail-filter gaps are retained as excluded coverage diagnostics, not ranked evidence.", "First-chance exceptions are opt-in because managed startup can generate enough events to consume the bounded timeline." ] }); @@ -361,6 +429,7 @@ fn collect_startup_profile_run( args: &LiveStartupProfileArgs, run_index: u32, phase_module: Option<&str>, + completion_module: Option<&str>, end: LiveLaunchEnd, ) -> anyhow::Result { let command_started = Instant::now(); @@ -370,8 +439,14 @@ fn collect_startup_profile_run( initial_stop: LiveInitialStop::CreateProcessEvent, })?; - let result = - collect_startup_profile_stops(&session, args, run_index, phase_module, command_started); + let result = collect_startup_profile_stops( + &session, + args, + run_index, + phase_module, + completion_module, + command_started, + ); let cleanup = match &result { Ok(run) if run.finish_reason == "exit_process" => Ok(json!({ "action": "none", @@ -420,6 +495,7 @@ fn collect_startup_profile_stops( args: &LiveStartupProfileArgs, run_index: u32, phase_module: Option<&str>, + completion_module: Option<&str>, command_started: Instant, ) -> anyhow::Result { let initial_event = session @@ -445,32 +521,86 @@ fn collect_startup_profile_stops( let event_filters = configure_startup_profile_event_filters(session, args.include_first_chance_exceptions)?; - let deadline = Instant::now() + Duration::from_millis(u64::from(args.timeout_ms)); let mut timeline_truncated = false; + let mut event_limit_reached = false; let mut tail_filter_commands = Vec::new(); + let mut tail_filter_started_after_event_index = None; + let mut completion = StartupProfileCompletion { + requested_module: completion_module.map(ToOwned::to_owned), + settle_ms: args.settle_ms, + status: if completion_module.is_some() { + "waiting_for_module".to_string() + } else { + "not_requested".to_string() + }, + module_load: None, + quiet_resumed_elapsed_ms: None, + detail: if completion_module.is_some() { + "Waiting for the requested DbgEng module-load event.".to_string() + } else { + "No early module completion boundary was requested; collecting through exit, timeout, or bounded retention behavior.".to_string() + }, + }; + let mut quiet_started_at = None; let finish_reason = loop { - if !timeline_truncated && recording.timeline.len() >= args.max_events as usize - 1 { + if completion_module.is_some() { + if recording.timeline.len() >= args.max_events as usize { + event_limit_reached = true; + break "event_limit"; + } + } else if !timeline_truncated && recording.timeline.len() >= args.max_events as usize - 1 { tail_filter_commands = configure_startup_profile_exit_tail_filters( session, args.include_first_chance_exceptions, )?; + tail_filter_started_after_event_index = + recording.timeline.last().map(|event| event.index); timeline_truncated = true; } - let remaining = deadline.saturating_duration_since(Instant::now()); + let remaining = + Duration::from_millis(u64::from(args.timeout_ms)).saturating_sub(resumed_elapsed); if remaining.is_zero() { break "timeout"; } + let wait_budget = quiet_started_at + .zip(args.settle_ms) + .map(|(started_at, settle_ms)| { + Duration::from_millis(u64::from(settle_ms)) + .saturating_sub(resumed_elapsed.saturating_sub(started_at)) + }) + .unwrap_or(remaining) + .min(remaining); + ensure!( + !wait_budget.is_zero(), + "startup-profile computed a zero event wait budget" + ); session.continue_execution()?; let resumed_at = Instant::now(); - let wait_timeout_ms = duration_millis(remaining).clamp(1, u64::from(u32::MAX)) as u32; + let wait_timeout_ms = duration_millis(wait_budget).clamp(1, u64::from(u32::MAX)) as u32; let wait = session.wait_for_event(wait_timeout_ms)?; resumed_elapsed += resumed_at.elapsed(); - if wait.name.as_deref() == Some("timeout") { + let event = (wait.name.as_deref() != Some("timeout")) + .then(|| { + session + .last_event() + .context("reading a DbgEng lifecycle event") + }) + .transpose()?; + if event.as_ref().is_none_or(startup_profile_no_event_sentinel) { + if let Some(started_at) = quiet_started_at { + let quiet_elapsed = resumed_elapsed.saturating_sub(started_at); + if let Some(settle_ms) = args.settle_ms { + if quiet_elapsed >= Duration::from_millis(u64::from(settle_ms)) { + completion.status = "quiet_interval_observed".to_string(); + completion.quiet_resumed_elapsed_ms = Some(duration_millis(quiet_elapsed)); + completion.detail = "No configured DbgEng lifecycle stop was observed while the target was resumed for the requested settle interval. This is not a UI-ready or target-quiescence signal.".to_string(); + break "completion_module_quiet_interval"; + } + } + } break "timeout"; } - let event = session - .last_event() - .context("reading a DbgEng lifecycle event")?; + let event = event.expect("a non-timeout DbgEng wait has an event"); let exiting = event.event_name == "exit_process"; if !timeline_truncated || exiting { record_startup_profile_event( @@ -482,11 +612,60 @@ fn collect_startup_profile_stops( ); } if exiting { + if quiet_started_at.is_some() { + completion.status = "process_exit_before_quiet_interval".to_string(); + completion.detail = "DbgEng reported exit_process before the requested lifecycle quiet interval completed.".to_string(); + } else if completion_module.is_some() && completion.module_load.is_none() { + completion.status = "process_exit_before_module".to_string(); + completion.detail = + "DbgEng reported exit_process before the requested module-load event." + .to_string(); + } break "exit_process"; } + if completion.module_load.is_none() + && completion_module.is_some_and(|module| { + startup_profile_event_matches_module(recording.timeline.last(), module) + }) + { + completion.module_load = recording + .timeline + .last() + .map(startup_profile_event_reference); + if let Some(settle_ms) = args.settle_ms { + completion.status = "waiting_for_quiet_interval".to_string(); + completion.detail = format!( + "Observed the requested module-load boundary; waiting for {settle_ms} ms of target-resumed time without another configured DbgEng lifecycle stop." + ); + quiet_started_at = Some(resumed_elapsed); + } else { + completion.status = "module_observed".to_string(); + completion.detail = "Observed the requested DbgEng module-load boundary. This does not establish UI readiness or application initialization completion.".to_string(); + break "completion_module"; + } + } else if quiet_started_at.is_some() { + quiet_started_at = Some(resumed_elapsed); + completion.status = "waiting_for_quiet_interval".to_string(); + completion.detail = "A configured DbgEng lifecycle stop occurred after the completion-module boundary; restarting the observed lifecycle quiet interval.".to_string(); + } }; let phase_durations = derive_startup_profile_phases(&recording.timeline, phase_module); + if finish_reason == "timeout" && completion_module.is_some() { + completion.status = if completion.module_load.is_some() { + "quiet_interval_not_observed_before_timeout".to_string() + } else { + "module_not_observed_before_timeout".to_string() + }; + completion.detail = "The profile target-resumed timeout elapsed before the requested completion condition was observed.".to_string(); + } else if finish_reason == "event_limit" && completion_module.is_some() { + completion.status = if completion.module_load.is_some() { + "quiet_interval_not_observed_before_event_limit".to_string() + } else { + "module_not_observed_before_event_limit".to_string() + }; + completion.detail = "The retained lifecycle event limit was reached before the requested completion condition was observed. The target was not continued with filters disabled, so no quiet interval is inferred.".to_string(); + } let StartupProfileRecording { timeline, counts, @@ -500,7 +679,13 @@ fn collect_startup_profile_stops( let module_identity_truncated = counts.unique_module_identity_count > STARTUP_PROFILE_MAX_MODULE_IDENTITIES; let timeline_len = timeline.len(); - let status = if finish_reason == "exit_process" { + let lifecycle_summary = startup_profile_lifecycle_summary(&timeline, phase_module, &completion); + let (largest_observed_gaps, gaps_excluded_from_ranking) = + rank_startup_profile_observed_gaps(&timeline, tail_filter_started_after_event_index); + let status = if matches!( + finish_reason, + "exit_process" | "completion_module" | "completion_module_quiet_interval" + ) { "completed" } else { "incomplete" @@ -518,11 +703,16 @@ fn collect_startup_profile_stops( "timeout_after_create_ms": args.timeout_ms, "timeout_clock": "host_monotonic_target_resumed_wall_time", "timeline_retention_limit_reached": timeline_truncated, - "tail_filter_commands": tail_filter_commands + "tail_filter_commands": tail_filter_commands, + "tail_filter_started_after_event_index": tail_filter_started_after_event_index }), event_filters, + lifecycle_summary, + largest_observed_gaps, + gaps_excluded_from_ranking, timeline, phase_durations, + completion, counts: json!({ "module_load_events": counts.module_load_events, "module_unload_events": counts.module_unload_events, @@ -540,6 +730,7 @@ fn collect_startup_profile_stops( "timeline_event_limit": args.max_events, "timeline_events_returned": timeline_len, "timeline_truncated": timeline_truncated, + "event_limit_reached": event_limit_reached, "truncation_behavior": if timeline_truncated { "After retaining max_events - 1 lifecycle entries, windbg-tool disabled high-volume filters and waited only for exit_process so the final exit boundary remains observable." } else { @@ -547,6 +738,7 @@ fn collect_startup_profile_stops( }, "finished_at_process_exit": finish_reason == "exit_process", "phase_module": phase_module, + "completion_module": completion_module, "first_chance_exceptions_requested": args.include_first_chance_exceptions, "stop_context_requested": args.capture_stop_context, "stop_contexts_returned": captured_contexts @@ -636,6 +828,21 @@ fn configure_startup_profile_exit_tail_filters( Ok(commands) } +fn startup_profile_no_event_sentinel(event: &windbg_dbgeng::DebuggerEventInfo) -> bool { + // DbgEng 10.0.29547 can report S_OK for a bounded wait with no event, then + // expose this all-default LastEventInformation record instead of S_FALSE. + event.event_type == 0 + && event.event_name == "unknown" + && event.process_system_id == u32::MAX + && event.thread_system_id == u32::MAX + && event.description.is_none() + && event.extra_information_size == 0 + && event.breakpoint_id.is_none() + && event.exception.is_none() + && event.module_base.is_none() + && event.exit_code.is_none() +} + fn record_startup_profile_event( session: &DebuggerSession, recording: &mut StartupProfileRecording, @@ -757,6 +964,255 @@ fn normalize_startup_profile_path(value: &str) -> String { value.replace('\\', "/") } +fn startup_profile_event_reference(event: &StartupProfileEvent) -> StartupProfileEventReference { + StartupProfileEventReference { + index: event.index, + kind: event.kind.clone(), + observed_elapsed_ms: event.observed_elapsed_ms, + resumed_wall_elapsed_ms: event.resumed_wall_elapsed_ms, + thread_system_id: event.event.thread_system_id, + module: event.module.clone(), + description: event.event.description.clone(), + exception_code: event + .event + .exception + .as_ref() + .map(|exception| format!("0x{:08X}", exception.code)), + exception_first_chance: event + .event + .exception + .as_ref() + .map(|exception| exception.first_chance), + } +} + +fn startup_profile_event_matches_module( + event: Option<&StartupProfileEvent>, + requested_module: &str, +) -> bool { + event.is_some_and(|event| { + event.kind == "load_module" + && event.module.as_ref().is_some_and(|module| { + [ + module.basename.as_deref(), + module.module_name.as_deref(), + module.image_path.as_deref(), + ] + .into_iter() + .flatten() + .any(|candidate| startup_profile_module_name_matches(candidate, requested_module)) + }) + }) +} + +fn startup_profile_lifecycle_summary( + timeline: &[StartupProfileEvent], + phase_module: Option<&str>, + completion: &StartupProfileCompletion, +) -> Value { + let first = timeline.first().map(startup_profile_event_reference); + let last = timeline.last().map(startup_profile_event_reference); + let first_module_load = timeline + .iter() + .find(|event| event.kind == "load_module") + .map(startup_profile_event_reference); + let last_module_load = timeline + .iter() + .rfind(|event| event.kind == "load_module") + .map(startup_profile_event_reference); + let first_coreclr_load = + find_startup_profile_module_event(timeline, STARTUP_PROFILE_CORECLR_MODULE) + .map(startup_profile_event_reference); + let first_phase_module_load = phase_module + .and_then(|module| find_startup_profile_module_event(timeline, module)) + .map(startup_profile_event_reference); + let first_thread_start = timeline + .iter() + .find(|event| event.kind == "create_thread") + .map(startup_profile_event_reference); + let last_thread_start = timeline + .iter() + .rfind(|event| event.kind == "create_thread") + .map(startup_profile_event_reference); + let first_thread_exit = timeline + .iter() + .find(|event| event.kind == "exit_thread") + .map(startup_profile_event_reference); + let last_thread_exit = timeline + .iter() + .rfind(|event| event.kind == "exit_thread") + .map(startup_profile_event_reference); + let first_exception = timeline + .iter() + .find(|event| event.kind == "exception") + .map(startup_profile_event_reference); + let last_exception = timeline + .iter() + .rfind(|event| event.kind == "exception") + .map(startup_profile_event_reference); + let process_exit = timeline + .iter() + .find(|event| event.kind == "exit_process") + .map(startup_profile_event_reference); + + let mut seen = BTreeSet::new(); + let mut first_seen_modules = Vec::new(); + let mut runtime_loader_modules = Vec::new(); + let mut total_unique_modules = 0usize; + for event in timeline.iter().filter(|event| event.kind == "load_module") { + let Some(module) = event.module.as_ref() else { + continue; + }; + let Some(identity) = module.basename.as_deref().or(module.module_name.as_deref()) else { + continue; + }; + if !seen.insert(identity.to_ascii_lowercase()) { + continue; + } + total_unique_modules += 1; + let classification = startup_profile_module_classification(module, phase_module); + let value = json!({ + "classification": classification, + "event": startup_profile_event_reference(event) + }); + if classification == "runtime_loader" { + runtime_loader_modules.push(value.clone()); + } + if first_seen_modules.len() < STARTUP_PROFILE_MAX_FIRST_SEEN_MODULES { + first_seen_modules.push(value); + } + } + let first_seen_returned = first_seen_modules.len(); + + json!({ + "first_observed_event": first, + "last_observed_event": last, + "process": { + "create": timeline + .iter() + .find(|event| event.kind == "create_process") + .map(startup_profile_event_reference), + "exit": process_exit, + "exit_observed": timeline.iter().any(|event| event.kind == "exit_process") + }, + "modules": { + "first_load": first_module_load, + "last_load": last_module_load, + "first_coreclr_load": first_coreclr_load, + "first_selected_phase_module_load": first_phase_module_load, + "first_seen": first_seen_modules, + "first_seen_returned": first_seen_returned, + "first_seen_total_unique": total_unique_modules, + "first_seen_truncated": total_unique_modules > STARTUP_PROFILE_MAX_FIRST_SEEN_MODULES, + "runtime_loader_first_seen": runtime_loader_modules + }, + "threads": { + "first_start": first_thread_start, + "last_start": last_thread_start, + "first_exit": first_thread_exit, + "last_exit": last_thread_exit + }, + "exceptions": { + "first": first_exception, + "last": last_exception, + "status": if timeline.iter().any(|event| event.kind == "exception") { + "observed" + } else { + "not_observed" + } + }, + "debuggee_output": { + "status": "not_captured_no_output_callback", + "detail": "DbgEng output callbacks are not installed by this event-only profiler. The target can still inherit the invoking console." + }, + "completion_boundary": completion + }) +} + +fn startup_profile_module_classification( + module: &StartupProfileModule, + phase_module: Option<&str>, +) -> &'static str { + let candidates = [ + module.basename.as_deref(), + module.module_name.as_deref(), + module.image_path.as_deref(), + ]; + if [ + "coreclr.dll", + "hostfxr.dll", + "hostpolicy.dll", + "clrjit.dll", + "mscoree.dll", + ] + .iter() + .any(|runtime_module| { + candidates + .into_iter() + .flatten() + .any(|candidate| startup_profile_module_name_matches(candidate, runtime_module)) + }) { + "runtime_loader" + } else if phase_module.is_some_and(|phase_module| { + candidates + .into_iter() + .flatten() + .any(|candidate| startup_profile_module_name_matches(candidate, phase_module)) + }) { + "selected_phase_module" + } else { + "other_module" + } +} + +fn rank_startup_profile_observed_gaps( + timeline: &[StartupProfileEvent], + tail_filter_started_after_event_index: Option, +) -> (Vec, Vec) { + let mut gaps = Vec::new(); + let mut excluded = Vec::new(); + for pair in timeline.windows(2) { + let [start, end] = pair else { + continue; + }; + let Some(elapsed_ms) = end + .resumed_wall_elapsed_ms + .checked_sub(start.resumed_wall_elapsed_ms) + else { + continue; + }; + let start = startup_profile_event_reference(start); + let end = startup_profile_event_reference(end); + if tail_filter_started_after_event_index.is_some_and(|tail_start| start.index >= tail_start) + { + excluded.push(StartupProfileExcludedGap { + start, + end, + reason: "DbgEng high-volume lifecycle filters were disabled for the exit-only tail, so intervening events may be omitted.".to_string(), + }); + continue; + } + gaps.push(StartupProfileGap { + rank: 0, + elapsed_ms, + start, + end, + detail: "Target-resumed host wall time between adjacent observed DbgEng lifecycle stops while the full lifecycle filter set was active; not CPU time.".to_string(), + }); + } + gaps.sort_by(|left, right| { + right + .elapsed_ms + .cmp(&left.elapsed_ms) + .then_with(|| left.start.index.cmp(&right.start.index)) + }); + gaps.truncate(STARTUP_PROFILE_MAX_RANKED_GAPS); + for (index, gap) in gaps.iter_mut().enumerate() { + gap.rank = index + 1; + } + (gaps, excluded) +} + fn derive_startup_profile_phases( timeline: &[StartupProfileEvent], phase_module: Option<&str>, @@ -889,6 +1345,8 @@ fn unavailable_startup_profile_phase(name: &str, detail: &str) -> StartupProfile fn startup_profile_aggregate(completed_runs: &[StartupProfileRun]) -> Value { let mut values_by_phase = BTreeMap::>::new(); let mut phase_occurrences = BTreeMap::::new(); + let mut largest_gap_values = Vec::new(); + let mut largest_gap_boundaries = BTreeMap::::new(); for run in completed_runs { for phase in &run.phase_durations { *phase_occurrences.entry(phase.name.clone()).or_default() += 1; @@ -901,6 +1359,12 @@ fn startup_profile_aggregate(completed_runs: &[StartupProfileRun]) -> Value { } } } + if let Some(gap) = run.largest_observed_gaps.first() { + largest_gap_values.push(gap.elapsed_ms); + *largest_gap_boundaries + .entry(startup_profile_gap_boundary_name(gap)) + .or_default() += 1; + } } let phases = phase_occurrences .into_iter() @@ -932,7 +1396,62 @@ fn startup_profile_aggregate(completed_runs: &[StartupProfileRun]) -> Value { json!({ "completed_run_count": completed_runs.len(), "phase_wall_time_ms": phases, - "coverage": "Only runs that reached exit_process contribute samples. A missing boundary remains missing rather than inferred." + "largest_observed_inter_event_gap_wall_time_ms": startup_profile_gap_distribution( + largest_gap_values, + largest_gap_boundaries + ), + "coverage": "Only runs that reached the requested completion condition contribute samples. A missing boundary remains missing rather than inferred." + }) +} + +fn startup_profile_gap_boundary_name(gap: &StartupProfileGap) -> String { + format!( + "{} -> {}", + startup_profile_event_boundary_name(&gap.start), + startup_profile_event_boundary_name(&gap.end) + ) +} + +fn startup_profile_event_boundary_name(event: &StartupProfileEventReference) -> String { + let module = event + .module + .as_ref() + .and_then(|module| module.basename.as_deref().or(module.module_name.as_deref())); + match module { + Some(module) => format!("{}:{module}", event.kind), + None => event.kind.clone(), + } +} + +fn startup_profile_gap_distribution( + mut values: Vec, + boundary_counts: BTreeMap, +) -> Value { + values.sort_unstable(); + let sample_count = values.len(); + let median_ms = if sample_count == 0 { + None + } else if sample_count % 2 == 1 { + Some(values[sample_count / 2] as f64) + } else { + Some((values[sample_count / 2 - 1] as f64 + values[sample_count / 2] as f64) / 2.0) + }; + let boundaries = boundary_counts + .into_iter() + .map(|(boundary, occurrence_count)| { + json!({ + "boundary": boundary, + "occurrence_count": occurrence_count + }) + }) + .collect::>(); + json!({ + "sample_count": sample_count, + "min_ms": values.first(), + "median_ms": median_ms, + "max_ms": values.last(), + "largest_gap_boundaries": boundaries, + "detail": "One largest ranked adjacent event gap is sampled from each completed run. Gaps with tail-filtered coverage are excluded." }) } @@ -2674,7 +3193,7 @@ pub(super) fn live_capabilities() -> Value { "dbgeng server", "live launch --command-line --end detach|terminate", "live startup-break --command-line --initial-break|--address |--module --module-offset |--symbol ", - "live startup-profile --command-line [--runs ] [--phase-module ]", + "live startup-profile --command-line [--runs ] [--phase-module ] [--completion-module [--settle-ms ]]", "live start --command-line ", "live attach --process-id ", "dump create --process-id --output ", @@ -2697,7 +3216,7 @@ pub(super) fn live_capabilities() -> Value { { "feature": "startup profile workflow", "status": "non_invasive_bounded_lifecycle_collection", - "notes": "Launches at a create-process event, configures DbgEng lifecycle event filters only, and reports host-monotonic wall-time observations. It sets no software/hardware breakpoint, opens no DAC, and performs no target-memory operation." + "notes": "Launches at a create-process event, configures DbgEng lifecycle event filters only, and reports host-monotonic wall-time observations. A requested completion module can stop at an observed image load or after a bounded observed lifecycle-quiet interval. It sets no software/hardware breakpoint, opens no DAC, and performs no target-memory operation." }, { "feature": "managed method breakpoint workflow", @@ -3372,6 +3891,17 @@ mod tests { detail: "test".to_string(), }) .collect(), + completion: StartupProfileCompletion { + requested_module: None, + settle_ms: None, + status: "not_requested".to_string(), + module_load: None, + quiet_resumed_elapsed_ms: None, + detail: "test".to_string(), + }, + lifecycle_summary: json!({}), + largest_observed_gaps: Vec::new(), + gaps_excluded_from_ranking: Vec::new(), counts: json!({}), coverage: json!({}), cleanup: json!({}), @@ -3380,11 +3910,20 @@ mod tests { #[test] fn startup_profile_aggregate_reports_wall_time_median_without_regression_claim() { - let runs = [ + let mut runs = [ startup_profile_run_for_test(1, &[10]), startup_profile_run_for_test(2, &[30]), startup_profile_run_for_test(3, &[20]), ]; + let gap_timeline = vec![ + startup_profile_event_for_test(0, "create_process", 0, 0, None), + startup_profile_event_for_test(1, "load_module", 10, 10, Some("coreclr.dll")), + startup_profile_event_for_test(2, "create_thread", 40, 40, None), + ]; + let gaps = rank_startup_profile_observed_gaps(&gap_timeline, None).0; + for run in &mut runs { + run.largest_observed_gaps = gaps.clone(); + } let aggregate = startup_profile_aggregate(&runs); let phase = &aggregate["phase_wall_time_ms"][0]; @@ -3395,6 +3934,39 @@ mod tests { assert_eq!(phase["median_ms"], 20.0); assert_eq!(phase["max_ms"], 30); assert_eq!(phase["regression_assessment"]["status"], "no_baseline"); + assert_eq!( + aggregate["largest_observed_inter_event_gap_wall_time_ms"]["sample_count"], + 3 + ); + assert_eq!( + aggregate["largest_observed_inter_event_gap_wall_time_ms"]["median_ms"], + 30.0 + ); + } + + #[test] + fn startup_profile_aggregate_samples_one_largest_gap_per_run() { + let mut run = startup_profile_run_for_test(1, &[10]); + run.phase_durations.push(StartupProfilePhase { + name: "coreclr_load_to_selected_module_load".to_string(), + status: "observed".to_string(), + elapsed_ms: Some(5), + start_event_index: Some(1), + end_event_index: Some(2), + detail: "test".to_string(), + }); + let timeline = vec![ + startup_profile_event_for_test(0, "create_process", 0, 0, None), + startup_profile_event_for_test(1, "load_module", 10, 10, Some("coreclr.dll")), + ]; + run.largest_observed_gaps = rank_startup_profile_observed_gaps(&timeline, None).0; + + let aggregate = startup_profile_aggregate(&[run]); + + assert_eq!( + aggregate["largest_observed_inter_event_gap_wall_time_ms"]["sample_count"], + 1 + ); } #[test] @@ -3421,4 +3993,112 @@ mod tests { "ManagedBreakpointFixture.dll" )); } + + #[test] + fn startup_profile_recognizes_dbgeng_no_event_sentinel() { + let sentinel = windbg_dbgeng::DebuggerEventInfo { + event_type: 0, + event_name: "unknown".to_string(), + process_system_id: u32::MAX, + thread_system_id: u32::MAX, + description: None, + extra_information_size: 0, + breakpoint_id: None, + exception: None, + module_base: None, + exit_code: None, + }; + + assert!(startup_profile_no_event_sentinel(&sentinel)); + + let real_event = windbg_dbgeng::DebuggerEventInfo { + event_name: "create_thread".to_string(), + ..sentinel + }; + assert!(!startup_profile_no_event_sentinel(&real_event)); + } + + #[test] + fn startup_profile_lifecycle_summary_classifies_runtime_and_selected_modules() { + let mut exception = startup_profile_event_for_test(5, "exception", 100, 100, None); + exception.event.exception = Some(windbg_dbgeng::DebuggerExceptionInfo { + code: 0xc000_0005, + flags: 0, + address: 0x1234, + first_chance: true, + parameters: vec![], + }); + let timeline = vec![ + startup_profile_event_for_test(0, "create_process", 5, 0, None), + startup_profile_event_for_test(1, "load_module", 10, 5, Some("hostfxr.dll")), + startup_profile_event_for_test(2, "load_module", 15, 10, Some("coreclr.dll")), + startup_profile_event_for_test( + 3, + "load_module", + 20, + 15, + Some("RemoteDesktopManager.dll"), + ), + startup_profile_event_for_test(4, "create_thread", 25, 20, None), + exception, + ]; + let completion = StartupProfileCompletion { + requested_module: Some("RemoteDesktopManager.dll".to_string()), + settle_ms: Some(500), + status: "waiting_for_quiet_interval".to_string(), + module_load: Some(startup_profile_event_reference(&timeline[3])), + quiet_resumed_elapsed_ms: None, + detail: "test".to_string(), + }; + + let summary = startup_profile_lifecycle_summary( + &timeline, + Some("RemoteDesktopManager.dll"), + &completion, + ); + + assert_eq!( + summary["modules"]["first_coreclr_load"]["module"]["basename"], + "coreclr.dll" + ); + assert_eq!( + summary["modules"]["first_selected_phase_module_load"]["module"]["basename"], + "RemoteDesktopManager.dll" + ); + assert_eq!( + summary["modules"]["runtime_loader_first_seen"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!(summary["threads"]["first_start"]["index"], 4); + assert_eq!( + summary["exceptions"]["first"]["exception_code"], + "0xC0000005" + ); + assert_eq!( + summary["debuggee_output"]["status"], + "not_captured_no_output_callback" + ); + } + + #[test] + fn startup_profile_ranks_only_full_filter_observed_gaps() { + let timeline = vec![ + startup_profile_event_for_test(0, "create_process", 1, 0, None), + startup_profile_event_for_test(1, "load_module", 11, 10, Some("coreclr.dll")), + startup_profile_event_for_test(2, "create_thread", 61, 60, None), + startup_profile_event_for_test(3, "exit_process", 161, 160, None), + ]; + + let (gaps, excluded) = rank_startup_profile_observed_gaps(&timeline, Some(2)); + + assert_eq!(gaps.len(), 2); + assert_eq!(gaps[0].elapsed_ms, 50); + assert_eq!(gaps[0].start.index, 1); + assert_eq!(excluded.len(), 1); + assert_eq!(excluded[0].start.index, 2); + assert_eq!(excluded[0].end.index, 3); + } } diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs index bafd32c..3176eb9 100644 --- a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs @@ -1,16 +1,42 @@ using System.Runtime.CompilerServices; +using System.Globalization; namespace ManagedBreakpointFixture; public static class Program { - public static int Main() + public static int Main(string[] args) { Console.WriteLine(ManagedTargets.PublicEntry()); Console.WriteLine(ManagedTargets.InvokePrivateEntry()); Console.WriteLine(ManagedTargets.Overload("selected")); + SleepForStartupObservation(args); return 0; } + + private static void SleepForStartupObservation(string[] args) + { + const string delayArgument = "--startup-observation-delay-ms"; + var index = Array.IndexOf(args, delayArgument); + if (index < 0 || index + 1 >= args.Length) + { + return; + } + + if (!int.TryParse( + args[index + 1], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var delayMilliseconds) + || delayMilliseconds is < 0 or > 10000) + { + throw new ArgumentOutOfRangeException( + delayArgument, + "Expected an integer delay from 0 through 10000 milliseconds."); + } + + Thread.Sleep(delayMilliseconds); + } } public static class ManagedTargets diff --git a/docs/architecture.md b/docs/architecture.md index 58caca5..cb3e947 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,7 +31,7 @@ The native replay bridge has a CMake project at [native/ttd-replay-bridge/CMakeL Live managed breakpoints use a separate [CoreCLR DAC bridge](../native/coreclr-dac-bridge/CMakeLists.txt), never the TTD bridge. It is a narrow C ABI that receives the active DbgEng client, hosts a version- and architecture-matched `mscordaccore.dll`, and implements `ICLRDataTarget` using DbgEng module, memory, thread, and context services. It uses the stable CLR metadata import contract to compare exact raw ECMA-335 `MethodDef` signature bytes when the caller selects an overload, while reporting bounded token/signature diagnostics for name matches. It resolves metadata through a method definition, then obtains the matching method instance after code-generation notification; only that instance supplies the JIT-native breakpoint address. Its opt-in `ICLRDataTarget2` implementation uses DbgEng's active process handle solely for the CLR-requested JIT-notification-table allocation; the CLR then owns that allocation. A separate read-only resolver never calls notification registration and can report an existing native method instance, but cannot observe a module or method first registered/JIT-compiled after the stopped native load event. It exposes only opaque handles and POD output for module/method resolution, CLR notification registration, and representative generated-code addresses. It does not attach `ICorDebug`, load SOS, invoke debugger extension commands, hollow the process, or inject managed code. The x64 bridge is staged separately under `target/native/coreclr-dac-bridge`; the target DAC remains runtime-provided and is not redistributed. -`live startup-profile` is intentionally outside that DAC path. It owns a short-lived DbgEng session, begins at a create-process event, applies only DbgEng lifecycle event filters, and accumulates host-monotonic wall time while the target is resumed between observed stops. The profiler records bounded process/thread/module/exception event metadata and optional read-only stop context, but does not use DbgEng breakpoint APIs, target-memory APIs, callbacks, CLR notifications, SOS, a DAC, or a second debugging engine. Its phase derivation accepts only explicit observed lifecycle boundaries and labels the result as wall-clock observation rather than CPU attribution or managed execution evidence. +`live startup-profile` is intentionally outside that DAC path. It owns a short-lived DbgEng session, begins at a create-process event, applies only DbgEng lifecycle event filters, and accumulates host-monotonic wall time while the target is resumed between observed stops. It can complete at a requested image-load boundary or after a bounded interval without a further configured lifecycle stop; the latter is explicitly an observed lifecycle-quiet interval, not a UI-ready or target-quiescence signal. The profiler records bounded process/thread/module/exception event metadata, lifecycle first/last summaries, ranked fully observed inter-event gaps, and optional read-only stop context, but does not use DbgEng breakpoint APIs, target-memory APIs, callbacks, CLR notifications, SOS, a DAC, or a second debugging engine. Its phase derivation accepts only explicit observed lifecycle boundaries and labels the result as wall-clock observation rather than CPU attribution or managed execution evidence. ## Symbols diff --git a/docs/cli.md b/docs/cli.md index c2b3276..7e3d73d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -223,7 +223,7 @@ The command reports two host-monotonic measures: - `command_to_create_observed_ms` is the wall time from before DbgEng session creation until windbg-tool observes the create-process event. - Timeline `resumed_wall_elapsed_ms` and derived `phase_durations` accumulate only while the target is resumed between observed DbgEng stops. This excludes the collector's intentionally stopped filter/configuration/context work, but includes DbgEng scheduling and event-delivery latency. -Neither measure is target CPU time. A module-load timestamp does not identify managed registration, JIT work, or method execution. Repeated runs report min/median/max wall time only for phases whose two event boundaries were observed in runs that reached `exit_process`; no baseline means the output deliberately does not call a value a regression. +Neither measure is target CPU time. A module-load timestamp does not identify managed registration, JIT work, or method execution. Repeated runs report min/median/max wall time only for phases whose two event boundaries were observed in runs that reached the requested completion condition; no baseline means the output deliberately does not call a value a regression. Build and profile the source-only fixture without any DAC or breakpoint workflow: @@ -233,17 +233,19 @@ dotnet build "$fixture\ManagedBreakpointFixture.csproj" -c Release $report = Join-Path $env:TEMP "windbg-tool-fixture-startup-profile.json" target\debug\windbg-tool.exe --compact live startup-profile ` - --command-line "`"$fixture\bin\Release\net10.0\win-x64\ManagedBreakpointFixture.exe`"" ` - --runs 3 ` + --command-line "`"$fixture\bin\Release\net10.0\win-x64\ManagedBreakpointFixture.exe`" --startup-observation-delay-ms 2000" ` --phase-module ManagedBreakpointFixture.dll ` + --completion-module ManagedBreakpointFixture.dll ` + --settle-ms 250 ` --initial-break-timeout-ms 30000 ` --timeout-ms 30000 ` --max-events 256 ` - --output $report ` - --end terminate + --output $report ``` -`--max-events` bounds retained timeline payload rather than forcing a healthy target to terminate. After retaining `max_events - 1` entries, windbg-tool disables the high-volume thread/module filters and waits only for `exit_process`, preserving the final exit boundary in the last slot. The result marks this as `coverage.timeline_truncated: true` and lists the DbgEng `timing.tail_filter_commands`; it makes no claim about events omitted during that exit-only tail. `--end` is used only if the time bound is reached before process exit. A successful run has `status: "completed"`, `finish_reason: "exit_process"`, and `cleanup.status: "not_needed"`. An incomplete run records `timeout` and then applies `--end`; it is excluded from aggregate samples, and a repeated collection stops after that incomplete run. The optional `--capture-stop-context` captures bounded read-only register/module/symbol/stack context on early stops; it can increase observer overhead and is disabled by default. +`--completion-module ` ends an observation at the matching DbgEng image-load event. It is an image-load boundary only: it is not UI-ready, managed registration, JIT completion, or application initialization. Add `--settle-ms 1..60000` to require a target-resumed interval without a further configured lifecycle stop after that load. Any observed lifecycle stop restarts the interval. A `quiet_interval_observed` result establishes only that bounded DbgEng condition; it does not prove target quiescence, CPU idleness, I/O completion, or UI readiness. If process exit, timeout, or the event limit occurs first, the result stays incomplete and never infers quietness. + +`--max-events` bounds retained timeline payload rather than forcing a healthy target to terminate. For a full-lifetime profile without `--completion-module`, windbg-tool retains `max_events - 1` entries, disables high-volume thread/module filters, and waits only for `exit_process`, preserving the final exit boundary in the last slot. The result marks this as `coverage.timeline_truncated: true` and lists the DbgEng `timing.tail_filter_commands`; it makes no claim about events omitted during that exit-only tail. A completion-bound profile instead stops as incomplete at its retention limit, because disabling the lifecycle filters would make a quiet interval unknowable. A single completion-bound run defaults to `--end detach`, so the target can finish normally. Repeated runs require the explicit `--end terminate` cleanup mode to prevent overlapping detached targets. A full-lifetime successful run has `finish_reason: "exit_process"`; a module completion has `finish_reason: "completion_module"`; a quiet completion has `finish_reason: "completion_module_quiet_interval"`. The optional `--capture-stop-context` captures bounded read-only register/module/symbol/stack context on early stops; it can increase observer overhead and is disabled by default. The important JSON fields are: @@ -257,7 +259,12 @@ The important JSON fields are: "dac_bridge": false }, "runs": [{ - "finish_reason": "exit_process", + "finish_reason": "completion_module_quiet_interval", + "completion": { + "status": "quiet_interval_observed", + "requested_module": "ManagedBreakpointFixture.dll", + "settle_ms": 250 + }, "timeline": [{ "kind": "load_module", "observed_elapsed_ms": 41, @@ -268,6 +275,13 @@ The important JSON fields are: "name": "create_process_to_coreclr_load", "status": "observed", "elapsed_ms": 10 + }], + "lifecycle_summary": { + "debuggee_output": { "status": "not_captured_no_output_callback" } + }, + "largest_observed_gaps": [{ + "elapsed_ms": 10, + "detail": "Target-resumed host wall time between adjacent observed lifecycle stops; not CPU time." }] }], "aggregate": { @@ -290,27 +304,45 @@ $rdm = 'D:\dev\.copilot\copilot-worktrees\RDM\bookish-winner\Windows\RemoteDeskt target\debug\windbg-tool.exe --compact live startup-profile ` --command-line "`"$rdm`" /AutoCloseAfter:30" ` --phase-module RemoteDesktopManager.dll ` + --completion-module RemoteDesktopManager.dll ` + --settle-ms 500 ` --initial-break-timeout-ms 30000 ` - --timeout-ms 90000 ` + --timeout-ms 30000 ` --max-events 512 ` - --end terminate + --output (Join-Path $env:TEMP "windbg-tool-rdm-startup-quiet.json") +``` + +The default detach is intentional for this one completion-bound RDM observation. For a bounded repeated image-load distribution after that initial result, omit `--settle-ms` and use explicit cleanup: + +```powershell +target\debug\windbg-tool.exe --compact live startup-profile ` + --command-line "`"$rdm`" /AutoCloseAfter:30" ` + --runs 3 ` + --phase-module RemoteDesktopManager.dll ` + --completion-module RemoteDesktopManager.dll ` + --initial-break-timeout-ms 30000 ` + --timeout-ms 30000 ` + --max-events 128 ` + --end terminate ` + --output (Join-Path $env:TEMP "windbg-tool-rdm-startup-module-runs-3.json") ``` -Do not add `live startup-break`, `live managed-break`, DAC options, or an endpoint-security workaround to that RDM command. If the initial safe-mode RDM run is blocked or fails, retain its DbgEng error/event evidence and stop rather than retrying or changing protection policy. +`--end terminate` is explicit cleanup for that repeated command, not a startup boundary or a measurement result. Do not add `live startup-break`, `live managed-break`, DAC options, or an endpoint-security workaround to either RDM command. If the initial safe-mode RDM run is blocked or fails, retain its DbgEng error/event evidence and stop rather than retrying or changing protection policy. ### Observed local RDM lifecycle evidence -The local Release x64 RDM build above completed an event-only profile with `exit_process` code `0` and `target_memory_writes.requested: false`. After that initial run established the safe lifecycle path, three bounded repetitions used `--runs 3 --max-events 128`; each retained 128 events, switched to exit-only tail collection, and reached `exit_process` code `0`. +The local Release x64 RDM build completed an event-only completion-bound profile with `target_memory_writes.requested: false`, zero structured DbgEng exception stops, and no synthetic `unknown` events. The first single run saw `coreclr.dll` at resumed-wall 438 ms, `RemoteDesktopManager.dll` at 517 ms, later lifecycle events through 534 ms, then an observed 508 ms lifecycle-quiet interval. It detached rather than observing process exit; this is not a UI-ready signal. + +The bounded three-run module-boundary collection used explicit `--end terminate` cleanup after each `completion_module` result. It did not observe process exit, so post-module lifetime is intentionally unavailable: | Observable host-wall phase | Min ms | Median ms | Max ms | | --- | ---: | ---: | ---: | -| command start to create-process observation | 24 | 25 | 165 | -| create-process to `coreclr.dll` load | 409 | 444 | 606 | -| `coreclr.dll` load to `RemoteDesktopManager.dll` load | 36 | 41 | 45 | -| `RemoteDesktopManager.dll` load to exit-process | 49,183 | 53,327 | 65,624 | -| create-process to exit-process | 49,633 | 53,807 | 66,275 | +| command start to create-process observation | 5 | 5 | 178 | +| create-process to `coreclr.dll` load | 423 | 426 | 427 | +| `coreclr.dll` load to `RemoteDesktopManager.dll` load | 32 | 33 | 34 | +| `hostpolicy.dll` load to `coreclr.dll` load (largest adjacent observed gap) | 189 | 191 | 192 | -The first observed run found `coreclr.dll` at event index 22 and `RemoteDesktopManager.dll` at index 34. These are observer wall-clock lifecycle intervals, not CPU samples or proof that a specific RDM phase consumed CPU. The longest observable interval is therefore a follow-up wall-time investigation candidate only. One target-console `HttpListenerException (87)` was printed during the repeated collection, while the structured DbgEng records contained zero exception stops and all three runs exited with code `0`; the profile does not diagnose or attribute that application output. +The RDM image-load boundary occurred at event index 34 in every repeated sample. A separate 500 ms quiet-bound run reached its 256-event limit after post-module UI/graphics-related loader activity, correctly reporting incomplete rather than claiming startup completion. These are observer wall-clock lifecycle intervals, not CPU samples or proof that a specific RDM phase consumed CPU. The largest gap is a follow-up wall-time investigation candidate only. ### Verified RDM x64 test-VM run diff --git a/docs/development.md b/docs/development.md index b15da6e..7df642d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -87,7 +87,7 @@ Run the fixture's public/private/overload `live managed-break` commands from [cl `live startup-profile` validates the event-only DbgEng path without a breakpoint or DAC. It starts at a create-process event and configures only DbgEng lifecycle filters. It performs no target-memory allocation/write, no CLR notification registration, no injection, and no profiling. Run its fixture command from [cli.md](cli.md) before any RDM profile attempt. -The command's phase values are host-monotonic resumed wall time between DbgEng stops, not CPU time or target-internal timestamps. Preserve `finish_reason`, `coverage`, and incomplete runs when evaluating output. `--capture-stop-context` is read-only but opt-in because its symbol/stack queries add observer overhead. `--include-first-chance-exceptions` is also opt-in and bounded by `--max-events`. +The command's phase values are host-monotonic resumed wall time between DbgEng stops, not CPU time or target-internal timestamps. The fixture accepts `--startup-observation-delay-ms 0..10000` so the documented module-plus-quiet test can detach before normal process exit. A `quiet_interval_observed` result means only that no configured lifecycle stop arrived while the target was resumed for that duration; it does not establish application quiescence. Preserve `finish_reason`, `completion`, `coverage`, and incomplete runs when evaluating output. `--capture-stop-context` is read-only but opt-in because its symbol/stack queries add observer overhead. `--include-first-chance-exceptions` is also opt-in and bounded by `--max-events`. The RDM policy remains stricter for all breakpoint/DAC workflows. The event-only profile command can make one bounded RDM launch after the fixture proves its no-write result. Only after that run reaches `exit_process` may it make a small bounded repeated collection; if the initial run is blocked, do not retry or seek an exclusion, protection change, or workaround. From ca16e014ed611daa85a70cc3456e260e7d68f2cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Jul 2026 13:45:09 -0400 Subject: [PATCH 09/12] Add safe DbgEng observability Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + crates/windbg-dbgeng/Cargo.toml | 2 + crates/windbg-dbgeng/src/lib.rs | 416 ++++++++++ crates/windbg-tool/Cargo.toml | 1 + crates/windbg-tool/src/cli.rs | 151 +++- crates/windbg-tool/src/cli/dispatch.rs | 4 + crates/windbg-tool/src/cli/platform.rs | 718 +++++++++++++++++- crates/windbg-tool/src/pe_symbols.rs | 158 +++- .../ManagedBreakpointFixture/Program.cs | 25 + crates/windbg-ttd/src/targets.rs | 69 +- crates/windbg-ttd/src/tools.rs | 12 +- docs/architecture.md | 2 +- docs/cli.md | 31 +- docs/development.md | 2 +- 14 files changed, 1539 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f50c44e..4e3a7b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1794,6 +1794,7 @@ dependencies = [ "libloading", "serde", "windows", + "windows-core 0.58.0", ] [[package]] diff --git a/crates/windbg-dbgeng/Cargo.toml b/crates/windbg-dbgeng/Cargo.toml index 6419158..01e7837 100644 --- a/crates/windbg-dbgeng/Cargo.toml +++ b/crates/windbg-dbgeng/Cargo.toml @@ -10,6 +10,7 @@ serde.workspace = true [target.'cfg(windows)'.dependencies] libloading.workspace = true +windows-core = "0.58" windows = { workspace = true, features = [ "Win32_Foundation", "Win32_Storage_FileSystem", @@ -22,4 +23,5 @@ windows = { workspace = true, features = [ "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", + "implement", ] } diff --git a/crates/windbg-dbgeng/src/lib.rs b/crates/windbg-dbgeng/src/lib.rs index 52dcd2d..531b0cd 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -2,10 +2,16 @@ use anyhow::{bail, ensure, Context}; use serde::Serialize; #[cfg(windows)] use std::os::windows::ffi::OsStrExt; +#[cfg(windows)] +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, +}; use std::{ env, path::{Path, PathBuf}, sync::OnceLock, + time::Instant, }; #[cfg(windows)] @@ -20,6 +26,7 @@ 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"; pub const DBGENG_RUNTIME_DIR_ENV: &str = "WINDBG_DBGENG_RUNTIME_DIR"; +pub const MAX_VIRTUAL_MEMORY_MAP_REGIONS: u32 = 4096; const DEFAULT_DBGENG_SYMBOL_CACHE: &str = ".windbg-symbol-cache"; const DBGENG_DLL_NAME: &str = "dbgeng.dll"; #[cfg(windows)] @@ -426,6 +433,61 @@ pub struct MemoryReadResult { pub data: String, } +#[derive(Debug, Clone, Serialize)] +pub struct VirtualMemoryRegion { + pub base_address: u64, + pub allocation_base: u64, + pub allocation_protection: u32, + pub region_size: u64, + pub state: u32, + pub protection: u32, + pub kind: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct VirtualMemoryMap { + pub source: String, + pub status: String, + pub region_limit: u32, + pub regions: Vec, + pub truncated: bool, + pub next_query_address: Option, + pub query_error: Option, + pub detail: String, +} + +#[derive(Debug, Clone)] +pub struct DebuggerOutputCaptureOptions { + pub started_at: Instant, + pub max_records: u32, + pub max_chars_per_record: u32, + pub max_total_chars: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DebuggerOutputRecord { + pub elapsed_ms: u64, + pub preceding_event_index: Option, + pub mask: u32, + pub categories: Vec, + pub text: String, + pub text_truncated: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DebuggerOutputCaptureResult { + pub status: String, + pub source: String, + pub records: Vec, + pub records_returned: usize, + pub dropped_record_count: u32, + pub dropped_text_char_count: u32, + pub max_records: u32, + pub max_chars_per_record: u32, + pub max_total_chars: u32, + pub detail: String, +} + #[derive(Debug, Clone, Serialize)] pub struct ThreadInfo { pub engine_id: u32, @@ -580,6 +642,180 @@ pub struct DebuggerSession { symbol_path: String, } +#[cfg(windows)] +const OUTPUT_CAPTURE_NO_EVENT_INDEX: usize = usize::MAX; +#[cfg(windows)] +const DEBUG_OUTPUT_DEBUGGEE_MASK: u32 = 0x0000_0080; +#[cfg(windows)] +const DEBUG_OUTPUT_DEBUGGEE_PROMPT_MASK: u32 = 0x0000_0100; + +#[cfg(windows)] +struct DebuggerOutputCaptureShared { + started_at: Instant, + max_records: usize, + max_chars_per_record: usize, + max_total_chars: usize, + preceding_event_index: AtomicUsize, + state: Mutex, +} + +#[cfg(windows)] +#[derive(Default)] +struct DebuggerOutputCaptureState { + records: Vec, + total_text_chars: usize, + dropped_record_count: u32, + dropped_text_char_count: u32, +} + +#[cfg(windows)] +#[windows::core::implement( + windows::Win32::System::Diagnostics::Debug::Extensions::IDebugOutputCallbacksWide +)] +struct DebuggerOutputCallback { + shared: Arc, +} + +#[cfg(windows)] +impl windows::Win32::System::Diagnostics::Debug::Extensions::IDebugOutputCallbacksWide_Impl + for DebuggerOutputCallback_Impl +{ + fn Output(&self, mask: u32, text: &windows::core::PCWSTR) -> windows::core::Result<()> { + if mask & (DEBUG_OUTPUT_DEBUGGEE_MASK | DEBUG_OUTPUT_DEBUGGEE_PROMPT_MASK) == 0 { + return Ok(()); + } + let text = unsafe { text.to_string() } + .unwrap_or_else(|_| "".to_string()); + self.shared.record(mask, text); + Ok(()) + } +} + +#[cfg(windows)] +impl DebuggerOutputCaptureShared { + fn record(&self, mask: u32, text: String) { + let original_chars = text.chars().count(); + let mut state = match self.state.lock() { + Ok(state) => state, + Err(_) => return, + }; + if state.records.len() >= self.max_records { + state.dropped_record_count = state.dropped_record_count.saturating_add(1); + state.dropped_text_char_count = state + .dropped_text_char_count + .saturating_add(saturating_u32(original_chars)); + return; + } + + let remaining_total = self.max_total_chars.saturating_sub(state.total_text_chars); + let retained_chars = original_chars + .min(self.max_chars_per_record) + .min(remaining_total); + let text_truncated = retained_chars < original_chars; + let retained_text = text.chars().take(retained_chars).collect::(); + if text_truncated { + state.dropped_text_char_count = state + .dropped_text_char_count + .saturating_add(saturating_u32(original_chars - retained_chars)); + } + state.total_text_chars += retained_chars; + let preceding_event_index = self.preceding_event_index.load(Ordering::Relaxed); + state.records.push(DebuggerOutputRecord { + elapsed_ms: duration_millis(self.started_at.elapsed()), + preceding_event_index: (preceding_event_index != OUTPUT_CAPTURE_NO_EVENT_INDEX) + .then_some(preceding_event_index), + mask, + categories: debug_output_categories(mask), + text: retained_text, + text_truncated, + }); + } + + fn snapshot(&self, options: &DebuggerOutputCaptureOptions) -> DebuggerOutputCaptureResult { + let state = match self.state.lock() { + Ok(state) => state, + Err(_) => { + return DebuggerOutputCaptureResult { + status: "unavailable".to_string(), + source: "dbgeng_output_callback".to_string(), + records: Vec::new(), + records_returned: 0, + dropped_record_count: 0, + dropped_text_char_count: 0, + max_records: options.max_records, + max_chars_per_record: options.max_chars_per_record, + max_total_chars: options.max_total_chars, + detail: + "The host-side output capture lock was poisoned; no output is returned." + .to_string(), + } + } + }; + DebuggerOutputCaptureResult { + status: "captured".to_string(), + source: "dbgeng_output_callback".to_string(), + records: state.records.clone(), + records_returned: state.records.len(), + dropped_record_count: state.dropped_record_count, + dropped_text_char_count: state.dropped_text_char_count, + max_records: options.max_records, + max_chars_per_record: options.max_chars_per_record, + max_total_chars: options.max_total_chars, + detail: "Only DbgEng debuggee output categories are enabled. Records are bounded host-side and preceding_event_index identifies the latest retained lifecycle event when the callback entered; it is not a causal association.".to_string(), + } + } +} + +#[cfg(windows)] +pub struct DebuggerOutputCapture { + client: windows::Win32::System::Diagnostics::Debug::Extensions::IDebugClient5, + previous_callback: + Option, + previous_output_mask: u32, + _callback: windows::Win32::System::Diagnostics::Debug::Extensions::IDebugOutputCallbacksWide, + shared: Arc, + options: DebuggerOutputCaptureOptions, + restored: bool, +} + +#[cfg(windows)] +impl DebuggerOutputCapture { + pub fn set_preceding_event_index(&self, index: Option) { + self.shared.preceding_event_index.store( + index.unwrap_or(OUTPUT_CAPTURE_NO_EVENT_INDEX), + Ordering::Relaxed, + ); + } + + pub fn finish(mut self) -> anyhow::Result { + self.restore()?; + Ok(self.shared.snapshot(&self.options)) + } + + fn restore(&mut self) -> anyhow::Result<()> { + if self.restored { + return Ok(()); + } + unsafe { + self.client + .SetOutputCallbacksWide(self.previous_callback.as_ref()) + .context("restoring the previous DbgEng output callback")?; + self.client + .SetOutputMask(self.previous_output_mask) + .context("restoring the previous DbgEng output mask")?; + } + self.restored = true; + Ok(()) + } +} + +#[cfg(windows)] +impl Drop for DebuggerOutputCapture { + fn drop(&mut self) { + let _ = self.restore(); + } +} + #[cfg(windows)] unsafe impl Send for DebuggerSession {} @@ -613,6 +849,68 @@ impl DebuggerSession { } } + pub fn begin_debuggee_output_capture( + &self, + options: DebuggerOutputCaptureOptions, + ) -> anyhow::Result { + use windows::Win32::System::Diagnostics::Debug::Extensions::IDebugOutputCallbacksWide; + + ensure!( + options.max_records > 0, + "DbgEng output capture requires a positive record limit" + ); + ensure!( + options.max_chars_per_record > 0, + "DbgEng output capture requires a positive per-record character limit" + ); + ensure!( + options.max_total_chars > 0, + "DbgEng output capture requires a positive total character limit" + ); + let previous_callback = unsafe { self.client.GetOutputCallbacksWide().ok() }; + let previous_output_mask = unsafe { + self.client + .GetOutputMask() + .context("reading the current DbgEng output mask")? + }; + let shared = Arc::new(DebuggerOutputCaptureShared { + started_at: options.started_at, + max_records: options.max_records as usize, + max_chars_per_record: options.max_chars_per_record as usize, + max_total_chars: options.max_total_chars as usize, + preceding_event_index: AtomicUsize::new(OUTPUT_CAPTURE_NO_EVENT_INDEX), + state: Mutex::new(DebuggerOutputCaptureState::default()), + }); + let callback: IDebugOutputCallbacksWide = DebuggerOutputCallback { + shared: Arc::clone(&shared), + } + .into(); + unsafe { + self.client + .SetOutputCallbacksWide(&callback) + .context("installing the bounded DbgEng output callback")?; + if let Err(error) = self.client.SetOutputMask( + previous_output_mask + | DEBUG_OUTPUT_DEBUGGEE_MASK + | DEBUG_OUTPUT_DEBUGGEE_PROMPT_MASK, + ) { + let _ = self + .client + .SetOutputCallbacksWide(previous_callback.as_ref()); + return Err(error).context("enabling DbgEng debuggee output categories"); + } + } + Ok(DebuggerOutputCapture { + client: self.client.clone(), + previous_callback, + previous_output_mask, + _callback: callback, + shared, + options, + restored: false, + }) + } + pub fn open_coreclr_dac_bridge( &self, coreclr_path: &Path, @@ -832,6 +1130,89 @@ impl DebuggerSession { }) } + pub fn virtual_memory_map(&self, region_limit: u32) -> anyhow::Result { + use windows::Win32::System::Memory::MEMORY_BASIC_INFORMATION64; + + ensure!( + self.kind == DebuggerSessionKind::Live, + "DbgEng QueryVirtual is only supported for live user-mode targets" + ); + ensure!( + region_limit > 0, + "DbgEng virtual-memory map requires a positive region limit" + ); + ensure!( + region_limit <= MAX_VIRTUAL_MEMORY_MAP_REGIONS, + "DbgEng virtual-memory map region limit must not exceed {MAX_VIRTUAL_MEMORY_MAP_REGIONS}" + ); + + let mut regions = Vec::with_capacity((region_limit as usize).min(256)); + let mut query_address = 0u64; + loop { + if regions.len() >= region_limit as usize { + return Ok(VirtualMemoryMap { + source: "dbgeng_idata_spaces4_query_virtual".to_string(), + status: "bounded".to_string(), + region_limit, + regions, + truncated: true, + next_query_address: Some(query_address), + query_error: None, + detail: "The requested region limit was reached before the DbgEng virtual-address query completed.".to_string(), + }); + } + + let mut info = MEMORY_BASIC_INFORMATION64::default(); + let query = unsafe { self.data_spaces.QueryVirtual(query_address, &mut info) }; + if let Err(error) = query { + return Ok(VirtualMemoryMap { + source: "dbgeng_idata_spaces4_query_virtual".to_string(), + status: if regions.is_empty() { + "unavailable".to_string() + } else { + "partial_query_error".to_string() + }, + region_limit, + regions, + truncated: true, + next_query_address: Some(query_address), + query_error: Some(error.to_string()), + detail: "DbgEng QueryVirtual did not return another region. The returned list is not claimed to cover the full address space.".to_string(), + }); + } + ensure!( + info.RegionSize > 0, + "DbgEng QueryVirtual returned a zero-sized region at 0x{query_address:X}" + ); + regions.push(VirtualMemoryRegion { + base_address: info.BaseAddress, + allocation_base: info.AllocationBase, + allocation_protection: info.AllocationProtect.0, + region_size: info.RegionSize, + state: info.State.0, + protection: info.Protect.0, + kind: info.Type.0, + }); + let next_query_address = info + .BaseAddress + .checked_add(info.RegionSize) + .filter(|next| *next > query_address); + let Some(next_query_address) = next_query_address else { + return Ok(VirtualMemoryMap { + source: "dbgeng_idata_spaces4_query_virtual".to_string(), + status: "address_space_exhausted".to_string(), + region_limit, + regions, + truncated: false, + next_query_address: None, + query_error: None, + detail: "DbgEng QueryVirtual reached the end of the representable virtual address range.".to_string(), + }); + }; + query_address = next_query_address; + } + } + pub fn threads(&self) -> anyhow::Result> { let count = unsafe { self.system_objects.GetNumberThreads()? }; let mut engine_ids = vec![0u32; count as usize]; @@ -1854,6 +2235,41 @@ fn encode_hex(bytes: &[u8]) -> String { result } +fn duration_millis(duration: std::time::Duration) -> u64 { + duration.as_millis().min(u128::from(u64::MAX)) as u64 +} + +#[cfg(windows)] +fn saturating_u32(value: usize) -> u32 { + value.min(u32::MAX as usize) as u32 +} + +#[cfg(windows)] +fn debug_output_categories(mask: u32) -> Vec { + const OUTPUT_CATEGORIES: &[(u32, &str)] = &[ + (0x0000_0001, "normal"), + (0x0000_0002, "error"), + (0x0000_0004, "warning"), + (0x0000_0008, "verbose"), + (0x0000_0010, "prompt"), + (0x0000_0020, "prompt_registers"), + (0x0000_0040, "extension_warning"), + (DEBUG_OUTPUT_DEBUGGEE_MASK, "debuggee"), + (DEBUG_OUTPUT_DEBUGGEE_PROMPT_MASK, "debuggee_prompt"), + (0x0000_0200, "symbols"), + (0x0000_0400, "status"), + ]; + let mut categories = OUTPUT_CATEGORIES + .iter() + .filter(|(flag, _)| mask & *flag != 0) + .map(|(_, name)| (*name).to_string()) + .collect::>(); + if categories.is_empty() { + categories.push("unknown".to_string()); + } + categories +} + #[cfg(windows)] fn load_dbghelp_module() -> anyhow::Result { use windows::core::PCWSTR; diff --git a/crates/windbg-tool/Cargo.toml b/crates/windbg-tool/Cargo.toml index b8dc6a1..1f562cf 100644 --- a/crates/windbg-tool/Cargo.toml +++ b/crates/windbg-tool/Cargo.toml @@ -21,6 +21,7 @@ windbg-ttd = { path = "..\\windbg-ttd" } windows = { workspace = true, features = [ "Win32_Foundation", "Win32_Security", + "Win32_Storage_FileSystem", "Win32_System_Kernel", "Win32_System_Threading", ] } diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 3ff3f02..d9d713e 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -281,6 +281,10 @@ enum LiveCommand { about = "Profile bounded DbgEng lifecycle events with host-monotonic wall-clock timing and no breakpoints" )] StartupProfile(LiveStartupProfileArgs), + #[command( + about = "Compare two bounded live startup-profile JSON artifacts without CPU or causal attribution" + )] + StartupCompare(LiveStartupProfileCompareArgs), #[command( about = "Launch .NET under DbgEng, bind the matching CoreCLR DAC, and emit a managed method hit" )] @@ -482,6 +486,8 @@ enum TargetCommand { Event(TargetIdArgs), #[command(about = "Read memory from a daemon-owned target")] Memory(TargetMemoryReadArgs), + #[command(about = "Return a bounded live user-mode virtual-memory map through DbgEng")] + MemoryMap(TargetMemoryMapArgs), #[command(about = "Walk the current stack for a daemon-owned target")] Stack(TargetStackTraceArgs), #[command( @@ -783,6 +789,15 @@ struct LiveStartupProfileArgs { help = "Attach bounded read-only register/module/symbol/stack context to early observed stops" )] capture_stop_context: bool, + #[arg( + long, + value_delimiter = ',', + value_enum, + requires = "capture_stop_context", + value_name = "EVENT", + help = "Comma-separated lifecycle event kinds eligible for read-only context; defaults to load-module,create-thread,exception,exit-process when context capture is enabled" + )] + context_on: Vec, #[arg( long, default_value_t = 8, @@ -797,6 +812,48 @@ struct LiveStartupProfileArgs { help = "Maximum stack frames in each optional stop context" )] max_frames: u32, + #[arg( + long, + help = "Capture bounded DbgEng debuggee-output callback records; disabled by default because debug strings can contain sensitive content" + )] + capture_debuggee_output: bool, + #[arg( + long, + default_value_t = 32, + value_parser = clap::value_parser!(u32).range(1..=128), + requires = "capture_debuggee_output", + help = "Maximum debuggee-output callback records retained" + )] + max_output_records: u32, + #[arg( + long, + default_value_t = 512, + value_parser = clap::value_parser!(u32).range(1..=4096), + requires = "capture_debuggee_output", + help = "Maximum UTF-16 characters retained per debuggee-output callback record" + )] + max_output_chars: u32, + #[arg( + long, + default_value_t = 8192, + value_parser = clap::value_parser!(u32).range(1..=32768), + requires = "capture_debuggee_output", + help = "Maximum UTF-16 characters retained across all debuggee-output callback records" + )] + max_total_output_chars: u32, + #[arg( + long, + help = "Read bounded host-side PE/file metadata only for DbgEng-observed module image paths" + )] + capture_module_provenance: bool, + #[arg( + long, + default_value_t = 32, + value_parser = clap::value_parser!(u32).range(1..=128), + requires = "capture_module_provenance", + help = "Maximum unique DbgEng-observed module image paths inspected for host file metadata" + )] + max_module_provenance: u32, #[arg( long, value_name = "PATH", @@ -812,6 +869,40 @@ struct LiveStartupProfileArgs { end: String, } +#[derive(Debug, Args)] +struct LiveStartupProfileCompareArgs { + #[arg( + long, + value_name = "PATH", + help = "Earlier startup-profile JSON artifact" + )] + baseline: PathBuf, + #[arg( + long, + value_name = "PATH", + help = "Later startup-profile JSON artifact" + )] + candidate: PathBuf, + #[arg( + long, + default_value_t = 64, + value_parser = clap::value_parser!(u32).range(1..=256), + help = "Maximum retained lifecycle events compared per matched run" + )] + max_sequence_events: u32, +} + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +enum StartupProfileContextEvent { + CreateProcess, + ExitProcess, + CreateThread, + ExitThread, + LoadModule, + UnloadModule, + Exception, +} + #[derive(Debug, Args)] struct LiveManagedBreakArgs { #[arg(long, help = "Full command line to launch under DbgEng")] @@ -968,6 +1059,11 @@ struct LiveSessionStartArgs { command_line: String, #[arg(long, default_value_t = 5000)] initial_break_timeout_ms: u32, + #[arg( + long, + help = "Stop on DbgEng's create-process event instead of the default software initial-break engine option" + )] + create_process_stop: bool, } #[derive(Debug, Args)] @@ -1287,6 +1383,19 @@ struct TargetMemoryReadArgs { size: u32, } +#[derive(Debug, Args)] +struct TargetMemoryMapArgs { + #[arg(short = 't', long = "target")] + target: u64, + #[arg( + long, + default_value_t = 256, + value_parser = clap::value_parser!(u32).range(1..=4096), + help = "Maximum DbgEng virtual-memory regions returned" + )] + region_limit: u32, +} + #[derive(Debug, Args)] struct TargetDumpArgs { #[arg(short = 't', long = "target")] @@ -3787,6 +3896,7 @@ fn backend_capability(kind: &str) -> Value { "can_stack": true, "can_query_symbols": true, "can_query_source": true, + "can_query_virtual_memory_map": "live_user_mode_only", "can_step": true, "can_continue": true, "can_set_breakpoint": true, @@ -3796,7 +3906,7 @@ fn backend_capability(kind: &str) -> Value { "supports_jobs": false, "supports_timeline": false, "required_identifiers": ["target_id"], - "safe_commands": ["debug snapshot", "target status", "target event", "target thread", "target stack", "target disasm", "breakpoint plan"], + "safe_commands": ["debug snapshot", "target status", "target event", "target thread", "target stack", "target disasm", "target memory-map", "breakpoint plan"], "mutating_commands": ["target continue", "target step", "breakpoint set", "target dump"], "destructive_commands": ["target terminate", "target close"], "unsupported_operations": [ @@ -4299,6 +4409,7 @@ async fn live_start_and_print( arguments: json!({ "command_line": args.command_line, "initial_break_timeout_ms": args.initial_break_timeout_ms, + "create_process_stop": args.create_process_stop, }), }, output, @@ -4641,7 +4752,8 @@ fn discover_manifest() -> Value { "live capabilities", "live launch --command-line --end detach|terminate", "live startup-profile --command-line [--runs ] [--phase-module ] [--completion-module [--settle-ms ]]", - "live start --command-line ", + "live startup-compare --baseline --candidate ", + "live start --command-line [--create-process-stop]", "live attach --process-id " ], "dump": ["dump open ", "dump inspect ", "dump create --process-id --output "], @@ -4679,6 +4791,7 @@ fn discover_manifest() -> Value { "target event --target ", "target thread --target --engine-thread-id ", "target memory --target --address --size ", + "target memory-map --target [--region-limit ]", "target dump --target --output ", "target stack --target ", "target disasm --target ", @@ -5065,6 +5178,7 @@ fn tool_command_map() -> Value { { "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_memory_map", "commands": ["target memory-map"] }, { "tool": "target_list_threads", "commands": ["target threads"] }, { "tool": "target_list_modules", "commands": ["target modules"] }, { "tool": "target_symbol_by_offset", "commands": ["target symbol"] }, @@ -5315,7 +5429,16 @@ fn command_metadata() -> Value { "session_required": false, "cost": "launches_process_and_collects_bounded_lifecycle_events", "safety": "live_debugging_changes_target_execution_state_without_target_memory_writes", - "bounds": ["--runs 1..10", "--initial-break-timeout-ms", "--timeout-ms", "--max-events", "--completion-module ", "--settle-ms 1..60000 (requires --completion-module)", "--max-context-events", "--end detach|terminate"] + "bounds": ["--runs 1..10", "--initial-break-timeout-ms", "--timeout-ms", "--max-events", "--completion-module ", "--settle-ms 1..60000 (requires --completion-module)", "--max-context-events", "--context-on", "--max-output-records", "--max-output-chars", "--max-total-output-chars", "--max-module-provenance", "--end detach|terminate"] + }, + { + "command": "live startup-compare", + "requires_daemon": false, + "requires_native_ttd": false, + "session_required": false, + "cost": "bounded_local_json_comparison", + "safety": "read_only_host_artifact", + "bounds": ["artifacts <= 16 MiB", "--max-sequence-events 1..256"] }, { "command": "live start", @@ -5324,7 +5447,7 @@ fn command_metadata() -> Value { "session_required": false, "cost": "launches_process_and_persists_target", "safety": "live_debugging_changes_target_execution_state", - "bounds": ["--initial-break-timeout-ms"] + "bounds": ["--initial-break-timeout-ms", "--create-process-stop"] }, { "command": "live attach", @@ -5393,6 +5516,16 @@ fn command_metadata() -> Value { "safety": "read_only_memory", "bounds": ["--size"] }, + { + "command": "target memory-map", + "requires_daemon": true, + "requires_native_ttd": false, + "session_required": false, + "cost": "bounded_dbgeng_virtual_memory_query", + "safety": "read_only_dbgeng_data_space", + "bounds": ["--region-limit 1..4096"], + "limitations": ["Live user-mode targets only; bounded, partial, or unavailable coverage is explicit."] + }, { "command": "target dump", "requires_daemon": true, @@ -5636,6 +5769,16 @@ fn target_memory_call(args: TargetMemoryReadArgs) -> anyhow::Result { }) } +fn target_memory_map_call(args: TargetMemoryMapArgs) -> ToolCall { + ToolCall { + name: "target_memory_map".to_string(), + arguments: json!({ + "target_id": args.target, + "region_limit": args.region_limit, + }), + } +} + fn target_dump_call(args: TargetDumpArgs) -> ToolCall { ToolCall { name: "target_write_dump".to_string(), diff --git a/crates/windbg-tool/src/cli/dispatch.rs b/crates/windbg-tool/src/cli/dispatch.rs index a2e8858..6250fa5 100644 --- a/crates/windbg-tool/src/cli/dispatch.rs +++ b/crates/windbg-tool/src/cli/dispatch.rs @@ -47,6 +47,7 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { LiveCommand::Launch(args) => platform::run_live_launch(args, &output), LiveCommand::StartupBreak(args) => platform::run_live_startup_break(args, &output), LiveCommand::StartupProfile(args) => platform::run_live_startup_profile(args, &output), + LiveCommand::StartupCompare(args) => platform::run_live_startup_compare(args, &output), LiveCommand::ManagedBreak(args) => platform::run_live_managed_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, @@ -315,6 +316,9 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { TargetCommand::Memory(args) => { call_and_print(pipe, target_memory_call(args)?, &output).await } + TargetCommand::MemoryMap(args) => { + call_and_print(pipe, target_memory_map_call(args), &output).await + } TargetCommand::Stack(args) => { call_and_print(pipe, target_stack_call(args), &output).await } diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index d04a532..0c974da 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -14,9 +14,10 @@ use std::thread; use std::time::{Duration, Instant}; use windbg_dbgeng::{ launch_live_session, live_launch_initial_break, open_dump_session, start_process_server, - write_process_dump, BreakpointInfo, DebuggerSession, DumpKind, DumpOpenOptions, - DumpWriteOptions, LiveInitialStop, LiveLaunchEnd, LiveLaunchOptions, LiveLaunchSessionOptions, - ManagedCodeAvailability, ModuleInfo, ProcessDumpOptions, ProcessServerOptions, + write_process_dump, BreakpointInfo, DebuggerOutputCaptureOptions, DebuggerSession, DumpKind, + DumpOpenOptions, DumpWriteOptions, LiveInitialStop, LiveLaunchEnd, LiveLaunchOptions, + LiveLaunchSessionOptions, ManagedCodeAvailability, ModuleInfo, ProcessDumpOptions, + ProcessServerOptions, }; use windbg_install::WindbgManager; use windows::core::{PCWSTR, PWSTR}; @@ -32,9 +33,11 @@ use windows::Win32::System::Threading::{ use super::output::{print_value, OutputOptions}; use super::{ CliDumpKind, DbgEngServerArgs, DumpCreateArgs, DumpInspectArgs, LiveLaunchArgs, - LiveManagedBreakArgs, LiveStartupBreakArgs, LiveStartupProfileArgs, TraceRecordArgs, - TraceRecordProfile, TraceReplayCpuSupport, WindbgCommand, + LiveManagedBreakArgs, LiveStartupBreakArgs, LiveStartupProfileArgs, + LiveStartupProfileCompareArgs, StartupProfileContextEvent, TraceRecordArgs, TraceRecordProfile, + TraceReplayCpuSupport, WindbgCommand, }; +use crate::pe_symbols::bounded_pe_file_metadata; pub(super) fn run_dbgeng_server( args: DbgEngServerArgs, @@ -214,6 +217,7 @@ const STARTUP_PROFILE_CORECLR_MODULE: &str = "coreclr.dll"; const STARTUP_PROFILE_MAX_MODULE_IDENTITIES: usize = 128; const STARTUP_PROFILE_MAX_FIRST_SEEN_MODULES: usize = 32; const STARTUP_PROFILE_MAX_RANKED_GAPS: usize = 8; +const STARTUP_PROFILE_MAX_PROVENANCE_FILE_BYTES: u64 = 8 * 1024 * 1024; #[derive(Debug, Clone, Serialize)] struct StartupProfileModule { @@ -233,7 +237,7 @@ struct StartupProfileEvent { module: Option, loaded_module_count: Option, live_thread_count: Option, - context: Option, + context: Value, } #[derive(Debug, Clone, Serialize)] @@ -297,6 +301,8 @@ struct StartupProfileRun { phase_durations: Vec, completion: StartupProfileCompletion, lifecycle_summary: Value, + debuggee_output: Value, + module_provenance: Value, largest_observed_gaps: Vec, gaps_excluded_from_ranking: Vec, counts: Value, @@ -378,6 +384,11 @@ pub(super) fn run_live_startup_profile( } } + let debuggee_output_limitation = if args.capture_debuggee_output { + "Opt-in debuggee output capture retains only bounded DbgEng debuggee categories and text. It can be unavailable if DbgEng rejects callback installation, and a preceding lifecycle-event reference establishes observation order rather than causation." + } else { + "Debuggee output is not captured into structured JSON because this command does not install an output callback; a target can still inherit the invoking console." + }; let mut result = json!({ "workflow": "live_startup_profile", "command_line": args.command_line, @@ -408,7 +419,7 @@ pub(super) fn run_live_startup_profile( }, "limitations": [ "DbgEng lifecycle events do not establish managed assembly registration, managed method execution, JIT activity, or CPU attribution.", - "Debuggee output is not captured into structured JSON because this command does not install an output callback; a target can still inherit the invoking console.", + debuggee_output_limitation, "Largest observed gaps rank only adjacent retained events while the full lifecycle filter set was active. Tail-filter gaps are retained as excluded coverage diagnostics, not ranked evidence.", "First-chance exceptions are opt-in because managed startup can generate enough events to consume the bounded timeline." ] @@ -425,6 +436,302 @@ pub(super) fn run_live_startup_profile( print_value(result, output_options) } +const STARTUP_PROFILE_COMPARE_MAX_ARTIFACT_BYTES: u64 = 16 * 1024 * 1024; + +pub(super) fn run_live_startup_compare( + args: LiveStartupProfileCompareArgs, + output_options: &OutputOptions, +) -> anyhow::Result<()> { + let baseline = read_startup_profile_artifact(&args.baseline, "baseline")?; + let candidate = read_startup_profile_artifact(&args.candidate, "candidate")?; + let baseline_runs = startup_profile_completed_artifact_runs(&baseline.value); + let candidate_runs = startup_profile_completed_artifact_runs(&candidate.value); + let phase_comparison = + startup_profile_compare_phase_distributions(&baseline_runs, &candidate_runs); + let gap_comparison = startup_profile_compare_largest_gaps(&baseline_runs, &candidate_runs); + let sequence_comparison = startup_profile_compare_sequences( + &baseline_runs, + &candidate_runs, + args.max_sequence_events as usize, + ); + + print_value( + json!({ + "workflow": "live_startup_profile_compare", + "baseline": baseline.summary, + "candidate": candidate.summary, + "comparison": { + "phase_wall_time_ms": phase_comparison, + "largest_observed_inter_event_gap_wall_time_ms": gap_comparison, + "lifecycle_sequence": sequence_comparison + }, + "coverage": { + "baseline_completed_runs": baseline_runs.len(), + "candidate_completed_runs": candidate_runs.len(), + "matched_run_pairs": baseline_runs.len().min(candidate_runs.len()), + "unmatched_baseline_runs": baseline_runs.len().saturating_sub(candidate_runs.len()), + "unmatched_candidate_runs": candidate_runs.len().saturating_sub(baseline_runs.len()), + "sequence_event_limit_per_run": args.max_sequence_events + }, + "interpretation": { + "wall_time_only": true, + "cpu_attribution": "not_available", + "causal_attribution": "not_available", + "regression_assessment": "The artifact comparison reports observed wall-time and lifecycle-sequence differences only. It does not establish a CPU regression, target-internal cause, managed-method timing, I/O cause, or UI readiness." + } + }), + output_options, + ) +} + +struct StartupProfileArtifact { + value: Value, + summary: Value, +} + +fn read_startup_profile_artifact( + path: &Path, + role: &str, +) -> anyhow::Result { + let metadata = fs::metadata(path) + .with_context(|| format!("reading {role} startup-profile artifact {}", path.display()))?; + ensure!( + metadata.is_file(), + "{role} startup-profile artifact is not a regular file: {}", + path.display() + ); + ensure!( + metadata.len() <= STARTUP_PROFILE_COMPARE_MAX_ARTIFACT_BYTES, + "{role} startup-profile artifact is {} bytes, exceeding the {}-byte comparison limit", + metadata.len(), + STARTUP_PROFILE_COMPARE_MAX_ARTIFACT_BYTES + ); + let contents = fs::read_to_string(path) + .with_context(|| format!("reading {role} startup-profile artifact {}", path.display()))?; + let value: Value = serde_json::from_str(&contents) + .with_context(|| format!("parsing {role} startup-profile artifact {}", path.display()))?; + ensure!( + value["workflow"].as_str() == Some("live_startup_profile"), + "{role} artifact is not a live startup-profile result" + ); + let runs = value["runs"] + .as_array() + .with_context(|| format!("{role} startup-profile artifact has no runs array"))?; + Ok(StartupProfileArtifact { + summary: json!({ + "role": role, + "path": path, + "artifact_bytes": metadata.len(), + "requested_runs": value["requested_runs"], + "runs_returned": runs.len(), + "runs_completed": value["runs_completed"], + "runs_completed_with_process_exit": value["runs_completed_with_process_exit"] + }), + value, + }) +} + +fn startup_profile_completed_artifact_runs(artifact: &Value) -> Vec<&Value> { + artifact["runs"] + .as_array() + .into_iter() + .flatten() + .filter(|run| run["status"].as_str() == Some("completed")) + .collect() +} + +fn startup_profile_compare_phase_distributions( + baseline_runs: &[&Value], + candidate_runs: &[&Value], +) -> Vec { + let baseline = startup_profile_artifact_phase_samples(baseline_runs); + let candidate = startup_profile_artifact_phase_samples(candidate_runs); + let names = baseline + .keys() + .chain(candidate.keys()) + .cloned() + .collect::>(); + names + .into_iter() + .map(|name| { + let baseline_values = baseline.get(&name).cloned().unwrap_or_default(); + let candidate_values = candidate.get(&name).cloned().unwrap_or_default(); + let baseline_distribution = startup_profile_wall_time_distribution(&baseline_values); + let candidate_distribution = startup_profile_wall_time_distribution(&candidate_values); + let median_delta_ms = baseline_distribution["median_ms"] + .as_f64() + .zip(candidate_distribution["median_ms"].as_f64()) + .map(|(baseline, candidate)| candidate - baseline); + json!({ + "name": name, + "baseline": baseline_distribution, + "candidate": candidate_distribution, + "candidate_minus_baseline_median_ms": median_delta_ms, + "detail": "Observed target-resumed host wall-time phase distribution. A delta is not a CPU, causal, or regression attribution." + }) + }) + .collect() +} + +fn startup_profile_artifact_phase_samples(runs: &[&Value]) -> BTreeMap> { + let mut samples = BTreeMap::>::new(); + for run in runs { + for phase in run["phase_durations"].as_array().into_iter().flatten() { + let (Some(name), Some(elapsed_ms)) = ( + phase["name"].as_str(), + (phase["status"].as_str() == Some("observed")) + .then(|| phase["elapsed_ms"].as_u64()) + .flatten(), + ) else { + continue; + }; + samples + .entry(name.to_string()) + .or_default() + .push(elapsed_ms); + } + } + samples +} + +fn startup_profile_compare_largest_gaps( + baseline_runs: &[&Value], + candidate_runs: &[&Value], +) -> Value { + let baseline = startup_profile_artifact_largest_gap_samples(baseline_runs); + let candidate = startup_profile_artifact_largest_gap_samples(candidate_runs); + let baseline_distribution = startup_profile_wall_time_distribution(&baseline); + let candidate_distribution = startup_profile_wall_time_distribution(&candidate); + let median_delta_ms = baseline_distribution["median_ms"] + .as_f64() + .zip(candidate_distribution["median_ms"].as_f64()) + .map(|(baseline, candidate)| candidate - baseline); + json!({ + "baseline": baseline_distribution, + "candidate": candidate_distribution, + "candidate_minus_baseline_median_ms": median_delta_ms, + "detail": "One retained largest fully observed lifecycle gap from each completed run. This does not attribute time to CPU, I/O, JIT, or a target-internal cause." + }) +} + +fn startup_profile_artifact_largest_gap_samples(runs: &[&Value]) -> Vec { + runs.iter() + .filter_map(|run| run["largest_observed_gaps"].as_array()?.first()) + .filter_map(|gap| gap["elapsed_ms"].as_u64()) + .collect() +} + +fn startup_profile_wall_time_distribution(values: &[u64]) -> Value { + let mut values = values.to_vec(); + values.sort_unstable(); + let sample_count = values.len(); + let median_ms = match sample_count { + 0 => None, + count if count % 2 == 1 => Some(values[count / 2] as f64), + count => Some((values[count / 2 - 1] as f64 + values[count / 2] as f64) / 2.0), + }; + json!({ + "sample_count": sample_count, + "min_ms": values.first(), + "median_ms": median_ms, + "max_ms": values.last() + }) +} + +fn startup_profile_compare_sequences( + baseline_runs: &[&Value], + candidate_runs: &[&Value], + max_events: usize, +) -> Value { + let pairs = baseline_runs + .iter() + .zip(candidate_runs) + .enumerate() + .map(|(pair_index, (baseline, candidate))| { + startup_profile_compare_run_sequence(pair_index + 1, baseline, candidate, max_events) + }) + .collect::>(); + let divergent_pairs = pairs + .iter() + .filter(|pair| pair["status"].as_str() == Some("diverged")) + .count(); + json!({ + "pairs": pairs, + "compared_pair_count": pairs.len(), + "divergent_pair_count": divergent_pairs, + "detail": "Event kinds, module basenames, and exception codes are compared in retained DbgEng lifecycle order. Thread system IDs are intentionally omitted because they are not stable across launches." + }) +} + +fn startup_profile_compare_run_sequence( + pair_index: usize, + baseline: &Value, + candidate: &Value, + max_events: usize, +) -> Value { + let baseline_events = baseline["timeline"].as_array().cloned().unwrap_or_default(); + let candidate_events = candidate["timeline"] + .as_array() + .cloned() + .unwrap_or_default(); + let baseline_tokens = baseline_events + .iter() + .take(max_events) + .map(startup_profile_artifact_event_token) + .collect::>(); + let candidate_tokens = candidate_events + .iter() + .take(max_events) + .map(startup_profile_artifact_event_token) + .collect::>(); + let shared_prefix = baseline_tokens + .iter() + .zip(&candidate_tokens) + .take_while(|(baseline, candidate)| baseline == candidate) + .count(); + let first_divergence = + if shared_prefix < baseline_tokens.len() && shared_prefix < candidate_tokens.len() { + Some(json!({ + "index": shared_prefix, + "baseline": baseline_tokens[shared_prefix], + "candidate": candidate_tokens[shared_prefix] + })) + } else { + None + }; + let length_differs = baseline_tokens.len() != candidate_tokens.len(); + let status = if first_divergence.is_some() || length_differs { + "diverged" + } else { + "shared_prefix_within_limit" + }; + json!({ + "pair_index": pair_index, + "baseline_run": baseline["run"], + "candidate_run": candidate["run"], + "status": status, + "shared_prefix_event_count": shared_prefix, + "first_divergence": first_divergence, + "baseline_events_returned": baseline_events.len(), + "candidate_events_returned": candidate_events.len(), + "baseline_events_compared": baseline_tokens.len(), + "candidate_events_compared": candidate_tokens.len(), + "sequence_comparison_truncated": baseline_events.len() > max_events || candidate_events.len() > max_events, + "baseline_profile_timeline_truncated": baseline["coverage"]["timeline_truncated"], + "candidate_profile_timeline_truncated": candidate["coverage"]["timeline_truncated"] + }) +} + +fn startup_profile_artifact_event_token(event: &Value) -> Value { + json!({ + "kind": event["kind"], + "module_basename": event["module"]["basename"], + "exception_code": event["event"]["exception"]["code"] + .as_u64() + .map(|code| format!("0x{code:08X}")) + }) +} + fn collect_startup_profile_run( args: &LiveStartupProfileArgs, run_index: u32, @@ -521,6 +828,47 @@ fn collect_startup_profile_stops( let event_filters = configure_startup_profile_event_filters(session, args.include_first_chance_exceptions)?; + let mut debuggee_output = if args.capture_debuggee_output { + json!({ + "status": "unavailable", + "source": "dbgeng_output_callback", + "records": [], + "detail": "DbgEng output capture was requested but is not available." + }) + } else { + json!({ + "status": "not_requested", + "source": "dbgeng_output_callback", + "records": [], + "detail": "Debuggee output capture is disabled by default because debug strings can contain sensitive content." + }) + }; + let output_capture = if args.capture_debuggee_output { + match session.begin_debuggee_output_capture(DebuggerOutputCaptureOptions { + started_at: command_started, + max_records: args.max_output_records, + max_chars_per_record: args.max_output_chars, + max_total_chars: args.max_total_output_chars, + }) { + Ok(capture) => { + capture + .set_preceding_event_index(recording.timeline.last().map(|event| event.index)); + Some(capture) + } + Err(error) => { + debuggee_output = json!({ + "status": "unavailable", + "source": "dbgeng_output_callback", + "records": [], + "detail": "The startup profile continued without output capture after DbgEng rejected callback installation.", + "error": error.to_string() + }); + None + } + } + } else { + None + }; let mut timeline_truncated = false; let mut event_limit_reached = false; let mut tail_filter_commands = Vec::new(); @@ -610,6 +958,10 @@ fn collect_startup_profile_stops( event, (command_started.elapsed(), resumed_elapsed), ); + if let Some(capture) = output_capture.as_ref() { + capture + .set_preceding_event_index(recording.timeline.last().map(|event| event.index)); + } } if exiting { if quiet_started_at.is_some() { @@ -679,7 +1031,21 @@ fn collect_startup_profile_stops( let module_identity_truncated = counts.unique_module_identity_count > STARTUP_PROFILE_MAX_MODULE_IDENTITIES; let timeline_len = timeline.len(); - let lifecycle_summary = startup_profile_lifecycle_summary(&timeline, phase_module, &completion); + if let Some(capture) = output_capture { + debuggee_output = match capture.finish() { + Ok(capture) => startup_profile_debuggee_output(capture, &timeline), + Err(error) => json!({ + "status": "unavailable", + "source": "dbgeng_output_callback", + "records": [], + "detail": "DbgEng output callback restoration failed; no output is returned because the capture lifecycle could not be confirmed.", + "error": error.to_string() + }), + }; + } + let module_provenance = startup_profile_module_provenance(&timeline, args); + let lifecycle_summary = + startup_profile_lifecycle_summary(&timeline, phase_module, &completion, &debuggee_output); let (largest_observed_gaps, gaps_excluded_from_ranking) = rank_startup_profile_observed_gaps(&timeline, tail_filter_started_after_event_index); let status = if matches!( @@ -708,6 +1074,8 @@ fn collect_startup_profile_stops( }), event_filters, lifecycle_summary, + debuggee_output, + module_provenance, largest_observed_gaps, gaps_excluded_from_ranking, timeline, @@ -741,7 +1109,8 @@ fn collect_startup_profile_stops( "completion_module": completion_module, "first_chance_exceptions_requested": args.include_first_chance_exceptions, "stop_context_requested": args.capture_stop_context, - "stop_contexts_returned": captured_contexts + "stop_contexts_returned": captured_contexts, + "module_provenance_requested": args.capture_module_provenance }), cleanup: Value::Null, }) @@ -902,28 +1271,7 @@ fn record_startup_profile_event( recording.counts.unique_module_identity_count = recording.module_identities.len(); } } - let context = - if args.capture_stop_context && recording.captured_contexts < args.max_context_events { - recording.captured_contexts += 1; - Some(match session.core_registers() { - Ok(registers) => match live_stop_context(session, registers, args.max_frames) { - Ok(context) => json!({ - "status": "ok", - "value": context - }), - Err(error) => json!({ - "status": "error", - "error": error.to_string() - }), - }, - Err(error) => json!({ - "status": "error", - "error": error.to_string() - }), - }) - } else { - None - }; + let context = startup_profile_stop_context(session, recording, args, &kind); recording.timeline.push(StartupProfileEvent { index: recording.timeline.len(), kind, @@ -937,6 +1285,78 @@ fn record_startup_profile_event( }); } +fn startup_profile_stop_context( + session: &DebuggerSession, + recording: &mut StartupProfileRecording, + args: &LiveStartupProfileArgs, + event_kind: &str, +) -> Value { + if !args.capture_stop_context { + return json!({ + "status": "not_requested", + "detail": "Read-only stop-context capture is disabled." + }); + } + if !startup_profile_context_event_selected(args, event_kind) { + return json!({ + "status": "not_selected", + "detail": "This lifecycle event kind is outside --context-on." + }); + } + if recording.captured_contexts >= args.max_context_events { + return json!({ + "status": "limit_reached", + "detail": "The bounded read-only stop-context capture limit was reached." + }); + } + + recording.captured_contexts += 1; + match session.core_registers() { + Ok(registers) => match live_stop_context(session, registers, args.max_frames) { + Ok(context) => json!({ + "status": "captured", + "source": "dbgeng_read_only_stop_snapshot", + "value": context + }), + Err(error) => json!({ + "status": "unavailable", + "source": "dbgeng_read_only_stop_snapshot", + "error": error.to_string() + }), + }, + Err(error) => json!({ + "status": "unavailable", + "source": "dbgeng_read_only_stop_snapshot", + "error": error.to_string() + }), + } +} + +fn startup_profile_context_event_selected(args: &LiveStartupProfileArgs, event_kind: &str) -> bool { + let selected = if args.context_on.is_empty() { + &[ + StartupProfileContextEvent::LoadModule, + StartupProfileContextEvent::CreateThread, + StartupProfileContextEvent::Exception, + StartupProfileContextEvent::ExitProcess, + ][..] + } else { + args.context_on.as_slice() + }; + selected.iter().any(|selected| { + matches!( + (selected, event_kind), + (StartupProfileContextEvent::CreateProcess, "create_process") + | (StartupProfileContextEvent::ExitProcess, "exit_process") + | (StartupProfileContextEvent::CreateThread, "create_thread") + | (StartupProfileContextEvent::ExitThread, "exit_thread") + | (StartupProfileContextEvent::LoadModule, "load_module") + | (StartupProfileContextEvent::UnloadModule, "unload_module") + | (StartupProfileContextEvent::Exception, "exception") + ) + }) +} + fn normalize_startup_profile_module(module: ModuleInfo) -> StartupProfileModule { let image_path = [ module.loaded_image_name.as_deref(), @@ -1009,6 +1429,7 @@ fn startup_profile_lifecycle_summary( timeline: &[StartupProfileEvent], phase_module: Option<&str>, completion: &StartupProfileCompletion, + debuggee_output: &Value, ) -> Value { let first = timeline.first().map(startup_profile_event_reference); let last = timeline.last().map(startup_profile_event_reference); @@ -1122,13 +1543,133 @@ fn startup_profile_lifecycle_summary( } }, "debuggee_output": { - "status": "not_captured_no_output_callback", - "detail": "DbgEng output callbacks are not installed by this event-only profiler. The target can still inherit the invoking console." + "status": debuggee_output["status"], + "records_returned": debuggee_output["records_returned"], + "dropped_record_count": debuggee_output["dropped_record_count"], + "detail": debuggee_output["detail"] }, "completion_boundary": completion }) } +fn startup_profile_debuggee_output( + capture: windbg_dbgeng::DebuggerOutputCaptureResult, + timeline: &[StartupProfileEvent], +) -> Value { + let records = capture + .records + .into_iter() + .map(|record| { + let preceding_event = record + .preceding_event_index + .and_then(|index| timeline.get(index)) + .map(startup_profile_event_reference); + json!({ + "elapsed_ms": record.elapsed_ms, + "preceding_event_index": record.preceding_event_index, + "preceding_event": preceding_event, + "mask": format!("0x{:X}", record.mask), + "categories": record.categories, + "text": record.text, + "text_truncated": record.text_truncated + }) + }) + .collect::>(); + json!({ + "status": capture.status, + "source": capture.source, + "records": records, + "records_returned": capture.records_returned, + "dropped_record_count": capture.dropped_record_count, + "dropped_text_char_count": capture.dropped_text_char_count, + "max_records": capture.max_records, + "max_chars_per_record": capture.max_chars_per_record, + "max_total_chars": capture.max_total_chars, + "detail": capture.detail + }) +} + +fn startup_profile_module_provenance( + timeline: &[StartupProfileEvent], + args: &LiveStartupProfileArgs, +) -> Value { + if !args.capture_module_provenance { + return json!({ + "status": "not_requested", + "source": "host_file_metadata", + "records": [], + "detail": "Host-side module provenance is disabled by default. When enabled, windbg-tool reads only bounded metadata for DbgEng-observed module image paths." + }); + } + + let mut seen_paths = BTreeSet::new(); + let mut candidates = Vec::new(); + for event in timeline { + if event.kind != "load_module" { + continue; + } + let Some(image_path) = event + .module + .as_ref() + .and_then(|module| module.image_path.as_deref()) + else { + continue; + }; + if seen_paths.insert(image_path.to_ascii_lowercase()) { + candidates.push(( + image_path.to_string(), + startup_profile_event_reference(event), + )); + } + } + + let candidate_count = candidates.len(); + let truncated = candidate_count > args.max_module_provenance as usize; + let records = candidates + .into_iter() + .take(args.max_module_provenance as usize) + .map(|(image_path, event)| { + if !Path::new(&image_path).is_absolute() { + return json!({ + "event": event, + "observed_image_path": image_path, + "status": "rejected_non_absolute_path", + "detail": "DbgEng did not provide an absolute image path, so windbg-tool did not perform a host file read." + }); + } + match bounded_pe_file_metadata( + Path::new(&image_path), + STARTUP_PROFILE_MAX_PROVENANCE_FILE_BYTES, + ) { + Ok(metadata) => json!({ + "event": event, + "observed_image_path": image_path, + "status": "captured", + "metadata": metadata + }), + Err(error) => json!({ + "event": event, + "observed_image_path": image_path, + "status": "unavailable", + "error": error.to_string(), + "detail": "The debugger-observed path could not be inspected under the host metadata limits." + }), + } + }) + .collect::>(); + json!({ + "status": "captured", + "source": "host_file_metadata", + "records": records, + "unique_observed_image_paths": candidate_count, + "returned": records.len(), + "limit": args.max_module_provenance, + "truncated": truncated, + "host_file_read_limit_bytes_per_module": STARTUP_PROFILE_MAX_PROVENANCE_FILE_BYTES, + "detail": "Metadata is read from host files only after DbgEng reported their absolute image paths. It is not target-memory evidence and its timestamps are not lifecycle timing." + }) +} + fn startup_profile_module_classification( module: &StartupProfileModule, phase_module: Option<&str>, @@ -3825,7 +4366,10 @@ mod tests { }), loaded_module_count: None, live_thread_count: None, - context: None, + context: json!({ + "status": "not_requested", + "detail": "test" + }), } } @@ -3900,6 +4444,21 @@ mod tests { detail: "test".to_string(), }, lifecycle_summary: json!({}), + debuggee_output: json!({ + "status": "not_requested", + "source": "dbgeng_output_callback", + "records": [], + "records_returned": 0, + "dropped_record_count": 0, + "dropped_text_char_count": 0, + "detail": "test" + }), + module_provenance: json!({ + "status": "not_requested", + "source": "host_file_metadata", + "records": [], + "detail": "test" + }), largest_observed_gaps: Vec::new(), gaps_excluded_from_ranking: Vec::new(), counts: json!({}), @@ -4055,6 +4614,12 @@ mod tests { &timeline, Some("RemoteDesktopManager.dll"), &completion, + &json!({ + "status": "not_requested", + "records_returned": 0, + "dropped_record_count": 0, + "detail": "test" + }), ); assert_eq!( @@ -4077,9 +4642,90 @@ mod tests { summary["exceptions"]["first"]["exception_code"], "0xC0000005" ); + assert_eq!(summary["debuggee_output"]["status"], "not_requested"); + } + + #[test] + fn startup_profile_output_records_reference_the_latest_lifecycle_event() { + let timeline = vec![ + startup_profile_event_for_test(0, "create_process", 5, 0, None), + startup_profile_event_for_test(1, "load_module", 12, 7, Some("coreclr.dll")), + ]; + let output = startup_profile_debuggee_output( + windbg_dbgeng::DebuggerOutputCaptureResult { + status: "captured".to_string(), + source: "dbgeng_output_callback".to_string(), + records: vec![windbg_dbgeng::DebuggerOutputRecord { + elapsed_ms: 13, + preceding_event_index: Some(1), + mask: 0x80, + categories: vec!["debuggee".to_string()], + text: "fixture-output".to_string(), + text_truncated: false, + }], + records_returned: 1, + dropped_record_count: 0, + dropped_text_char_count: 0, + max_records: 4, + max_chars_per_record: 64, + max_total_chars: 128, + detail: "test".to_string(), + }, + &timeline, + ); + + assert_eq!(output["records_returned"], 1); + assert_eq!(output["records"][0]["text"], "fixture-output"); + assert_eq!( + output["records"][0]["preceding_event"]["module"]["basename"], + "coreclr.dll" + ); + } + + #[test] + fn startup_profile_comparator_reports_wall_time_and_sequence_differences() { + let baseline = json!({ + "status": "completed", + "run": 1, + "phase_durations": [{ + "name": "create_process_to_coreclr_load", + "status": "observed", + "elapsed_ms": 10 + }], + "largest_observed_gaps": [{ "elapsed_ms": 7 }], + "timeline": [ + { "kind": "create_process", "module": null, "event": { "exception": null } }, + { "kind": "load_module", "module": { "basename": "coreclr.dll" }, "event": { "exception": null } } + ], + "coverage": { "timeline_truncated": false } + }); + let candidate = json!({ + "status": "completed", + "run": 1, + "phase_durations": [{ + "name": "create_process_to_coreclr_load", + "status": "observed", + "elapsed_ms": 15 + }], + "largest_observed_gaps": [{ "elapsed_ms": 9 }], + "timeline": [ + { "kind": "create_process", "module": null, "event": { "exception": null } }, + { "kind": "load_module", "module": { "basename": "hostfxr.dll" }, "event": { "exception": null } } + ], + "coverage": { "timeline_truncated": false } + }); + let baseline_runs = [&baseline]; + let candidate_runs = [&candidate]; + + let phases = startup_profile_compare_phase_distributions(&baseline_runs, &candidate_runs); + let sequence = startup_profile_compare_sequences(&baseline_runs, &candidate_runs, 8); + + assert_eq!(phases[0]["candidate_minus_baseline_median_ms"], json!(5.0)); + assert_eq!(sequence["divergent_pair_count"], 1); + assert_eq!(sequence["pairs"][0]["first_divergence"]["index"], 1); assert_eq!( - summary["debuggee_output"]["status"], - "not_captured_no_output_callback" + sequence["pairs"][0]["first_divergence"]["candidate"]["module_basename"], + "hostfxr.dll" ); } diff --git a/crates/windbg-tool/src/pe_symbols.rs b/crates/windbg-tool/src/pe_symbols.rs index a71772b..aa7c851 100644 --- a/crates/windbg-tool/src/pe_symbols.rs +++ b/crates/windbg-tool/src/pe_symbols.rs @@ -1,12 +1,162 @@ use anyhow::{bail, ensure, Context}; use serde_json::{json, Value}; use std::path::Path; +use std::time::UNIX_EPOCH; + +#[cfg(windows)] +use std::ffi::OsStr; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; +#[cfg(windows)] +use windows::core::PCWSTR; +#[cfg(windows)] +use windows::Win32::Storage::FileSystem::{ + GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW, VS_FIXEDFILEINFO, +}; const IMAGE_DIRECTORY_ENTRY_EXPORT: usize = 0; const IMAGE_DIRECTORY_ENTRY_DEBUG: usize = 6; const IMAGE_DEBUG_TYPE_CODEVIEW: u32 = 2; const OPTIONAL_HEADER_PE32: u16 = 0x10b; const OPTIONAL_HEADER_PE32_PLUS: u16 = 0x20b; +const MAX_VERSION_INFO_BYTES: u32 = 1024 * 1024; + +pub fn bounded_pe_file_metadata(path: &Path, max_file_bytes: u64) -> anyhow::Result { + let metadata = + std::fs::metadata(path).with_context(|| format!("reading {}", path.display()))?; + ensure!( + metadata.is_file(), + "observed module path is not a regular file: {}", + path.display() + ); + ensure!( + metadata.len() <= max_file_bytes, + "observed module file is {} bytes, exceeding the {}-byte host metadata read limit", + metadata.len(), + max_file_bytes + ); + let canonical_path = std::fs::canonicalize(path) + .with_context(|| format!("canonicalizing {}", path.display()))?; + let pe = diagnose_pe(&canonical_path)?; + let modified_unix_seconds = metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()); + Ok(json!({ + "source": "host_file_metadata", + "canonical_path": canonical_path, + "file_size_bytes": metadata.len(), + "last_write_unix_seconds": modified_unix_seconds, + "pe": { + "machine": pe["machine"], + "machine_value": pe["machine_value"], + "timestamp": pe["timestamp"], + "timestamp_hex": pe["timestamp_hex"], + "size_of_image": pe["size_of_image"], + "size_of_image_hex": pe["size_of_image_hex"], + "image_symbol_store_key": pe["image_symbol_store_key"], + "codeview": pe["codeview"] + }, + "version": file_version_info(&canonical_path), + "host_file_read_limit_bytes": max_file_bytes + })) +} + +#[cfg(windows)] +fn file_version_info(path: &Path) -> Value { + let path_wide = OsStr::new(path) + .encode_wide() + .chain(Some(0)) + .collect::>(); + let size = unsafe { GetFileVersionInfoSizeW(PCWSTR(path_wide.as_ptr()), None) }; + if size == 0 { + return json!({ + "status": "unavailable", + "detail": "No Win32 version resource was available for this host file." + }); + } + if size > MAX_VERSION_INFO_BYTES { + return json!({ + "status": "unavailable", + "detail": format!( + "The Win32 version resource is {} bytes, exceeding the {}-byte host metadata limit.", + size, + MAX_VERSION_INFO_BYTES + ) + }); + } + + let mut version_data = vec![0u8; size as usize]; + if let Err(error) = unsafe { + GetFileVersionInfoW( + PCWSTR(path_wide.as_ptr()), + 0, + size, + version_data.as_mut_ptr().cast(), + ) + } { + return json!({ + "status": "unavailable", + "detail": "Windows could not read the host file version resource.", + "error": error.to_string() + }); + } + + let root = [b'\\' as u16, 0]; + let mut fixed_info = std::ptr::null_mut(); + let mut fixed_info_bytes = 0u32; + let queried = unsafe { + VerQueryValueW( + version_data.as_ptr().cast(), + PCWSTR(root.as_ptr()), + &mut fixed_info, + &mut fixed_info_bytes, + ) + } + .as_bool(); + if !queried + || fixed_info.is_null() + || fixed_info_bytes < std::mem::size_of::() as u32 + { + return json!({ + "status": "unavailable", + "detail": "The host file version resource did not expose VS_FIXEDFILEINFO." + }); + } + let fixed_info = unsafe { std::ptr::read_unaligned(fixed_info.cast::()) }; + if fixed_info.dwSignature != 0xFEEF04BD { + return json!({ + "status": "unavailable", + "detail": "The host file version resource has an invalid VS_FIXEDFILEINFO signature." + }); + } + json!({ + "status": "captured", + "source": "win32_version_resource", + "file_version": version_tuple(fixed_info.dwFileVersionMS, fixed_info.dwFileVersionLS), + "product_version": version_tuple(fixed_info.dwProductVersionMS, fixed_info.dwProductVersionLS), + "resource_size_bytes": size + }) +} + +#[cfg(not(windows))] +fn file_version_info(_path: &Path) -> Value { + json!({ + "status": "unavailable", + "detail": "Win32 version resources are only available on Windows." + }) +} + +fn version_tuple(high: u32, low: u32) -> String { + format!( + "{}.{}.{}.{}", + high >> 16, + high & 0xFFFF, + low >> 16, + low & 0xFFFF + ) +} #[derive(Debug, Clone)] struct Section { @@ -385,11 +535,15 @@ mod tests { #[cfg(windows)] #[test] - fn parses_current_exe_pe_identity() -> anyhow::Result<()> { - let info = diagnose_pe(&std::env::current_exe()?)?; + fn parses_current_exe_pe_identity_with_bounded_file_metadata() -> anyhow::Result<()> { + let executable = std::env::current_exe()?; + let info = diagnose_pe(&executable)?; assert!(info["timestamp"].is_u64(), "{info}"); assert!(info["size_of_image"].is_u64(), "{info}"); assert!(info["image_symbol_store_key"].is_string(), "{info}"); + let metadata = bounded_pe_file_metadata(&executable, 64 * 1024 * 1024)?; + assert_eq!(metadata["source"], "host_file_metadata"); + assert!(metadata["pe"]["machine"].is_string(), "{metadata}"); Ok(()) } } diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs index 3176eb9..33acb69 100644 --- a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Globalization; +using System.Runtime.InteropServices; namespace ManagedBreakpointFixture; @@ -10,10 +11,31 @@ public static int Main(string[] args) Console.WriteLine(ManagedTargets.PublicEntry()); Console.WriteLine(ManagedTargets.InvokePrivateEntry()); Console.WriteLine(ManagedTargets.Overload("selected")); + EmitRequestedDebugOutput(args); SleepForStartupObservation(args); return 0; } + private static void EmitRequestedDebugOutput(string[] args) + { + const string outputArgument = "--debug-output"; + var index = Array.IndexOf(args, outputArgument); + if (index < 0 || index + 1 >= args.Length) + { + return; + } + + var text = args[index + 1]; + if (text.Length > 256) + { + throw new ArgumentOutOfRangeException( + outputArgument, + "Expected at most 256 UTF-16 characters."); + } + + OutputDebugString(text); + } + private static void SleepForStartupObservation(string[] args) { const string delayArgument = "--startup-observation-delay-ms"; @@ -37,6 +59,9 @@ private static void SleepForStartupObservation(string[] args) Thread.Sleep(delayMilliseconds); } + + [DllImport("kernel32.dll", EntryPoint = "OutputDebugStringW", CharSet = CharSet.Unicode)] + private static extern void OutputDebugString(string text); } public static class ManagedTargets diff --git a/crates/windbg-ttd/src/targets.rs b/crates/windbg-ttd/src/targets.rs index 2b9723b..9c33dbd 100644 --- a/crates/windbg-ttd/src/targets.rs +++ b/crates/windbg-ttd/src/targets.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context}; +use anyhow::{bail, ensure, Context}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -9,7 +9,7 @@ use windbg_dbgeng::{ DebuggerSessionSummary, DisassemblyResult, DumpKind, DumpOpenOptions, DumpWriteOptions, DumpWriteResult, EvaluationResult, LiveAttachOptions, LiveInitialStop, LiveLaunchSessionOptions, MemoryReadResult, ModuleInfo, SourceLocation, StackFrameInfo, - SymbolInfo, ThreadContext, ThreadInfo, + SymbolInfo, ThreadContext, ThreadInfo, VirtualMemoryMap, MAX_VIRTUAL_MEMORY_MAP_REGIONS, }; pub type TargetId = u64; @@ -29,6 +29,8 @@ pub struct LiveLaunchRequest { pub command_line: String, #[serde(default = "default_live_wait_timeout_ms")] pub initial_break_timeout_ms: u32, + #[serde(default)] + pub create_process_stop: bool, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -62,6 +64,17 @@ pub struct TargetMemoryReadRequest { pub size: u32, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct TargetMemoryMapRequest { + pub target_id: TargetId, + #[serde(default = "default_target_memory_map_regions")] + #[schemars( + range(min = 1, max = 4096), + description = "Maximum DbgEng virtual-memory regions returned; must be from 1 through 4096." + )] + pub region_limit: u32, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct TargetAddressRequest { pub target_id: TargetId, @@ -198,6 +211,12 @@ pub struct TargetMemoryReadResponse { pub memory: MemoryReadResult, } +#[derive(Debug, Clone, Serialize)] +pub struct TargetMemoryMapResponse { + pub target_id: TargetId, + pub memory_map: VirtualMemoryMap, +} + #[derive(Debug, Clone, Serialize)] pub struct TargetModuleList { pub target_id: TargetId, @@ -292,7 +311,11 @@ impl TargetRegistry { let session = launch_live_session(LiveLaunchSessionOptions { command_line: request.command_line, initial_break_timeout_ms: request.initial_break_timeout_ms, - initial_stop: LiveInitialStop::SoftwareBreakpoint, + initial_stop: if request.create_process_stop { + LiveInitialStop::CreateProcessEvent + } else { + LiveInitialStop::SoftwareBreakpoint + }, })?; Ok(self.insert_target(session)) } @@ -410,6 +433,19 @@ impl TargetRegistry { }) } + pub fn memory_map( + &self, + request: TargetMemoryMapRequest, + ) -> anyhow::Result { + validate_target_memory_map_region_limit(request.region_limit)?; + let target = self.target(request.target_id)?; + ensure_live_target(request.target_id, &target.session)?; + Ok(TargetMemoryMapResponse { + target_id: request.target_id, + memory_map: target.session.virtual_memory_map(request.region_limit)?, + }) + } + pub fn list_threads(&self, request: TargetRequest) -> anyhow::Result { let target = self.target(request.target_id)?; Ok(TargetThreadList { @@ -603,6 +639,18 @@ fn default_target_stack_frames() -> u32 { 32 } +fn default_target_memory_map_regions() -> u32 { + 256 +} + +fn validate_target_memory_map_region_limit(region_limit: u32) -> anyhow::Result<()> { + ensure!( + (1..=MAX_VIRTUAL_MEMORY_MAP_REGIONS).contains(®ion_limit), + "target memory-map region_limit must be from 1 through {MAX_VIRTUAL_MEMORY_MAP_REGIONS}" + ); + Ok(()) +} + fn default_target_disasm_count() -> u32 { 16 } @@ -622,3 +670,18 @@ impl From for DumpKind { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_memory_map_region_limit_before_accessing_a_target() { + assert!(validate_target_memory_map_region_limit(1).is_ok()); + assert!(validate_target_memory_map_region_limit(MAX_VIRTUAL_MEMORY_MAP_REGIONS).is_ok()); + assert!(validate_target_memory_map_region_limit(0).is_err()); + assert!( + validate_target_memory_map_region_limit(MAX_VIRTUAL_MEMORY_MAP_REGIONS + 1).is_err() + ); + } +} diff --git a/crates/windbg-ttd/src/tools.rs b/crates/windbg-ttd/src/tools.rs index afd6e7d..04da446 100644 --- a/crates/windbg-ttd/src/tools.rs +++ b/crates/windbg-ttd/src/tools.rs @@ -3,8 +3,8 @@ use crate::state::ServiceState; use crate::targets::{ DumpOpenRequest, LiveAttachRequest, LiveLaunchRequest, TargetAddressRequest, TargetBreakpointRemoveRequest, TargetBreakpointSetRequest, TargetDisassembleRequest, - TargetExpressionRequest, TargetMemoryReadRequest, TargetRequest, TargetStackTraceRequest, - TargetThreadContextRequest, TargetWaitRequest, TargetWriteDumpRequest, + TargetExpressionRequest, TargetMemoryMapRequest, TargetMemoryReadRequest, TargetRequest, + TargetStackTraceRequest, TargetThreadContextRequest, TargetWaitRequest, TargetWriteDumpRequest, }; use crate::ttd_replay::{ AddressInfoRequest, CursorId, IndexBuildRequest, IndexStatsRequest, IndexStatusRequest, @@ -215,6 +215,10 @@ pub fn definitions() -> Vec { "target_read_memory", "Read memory from a daemon-owned live or dump target session.", ), + tool::( + "target_memory_map", + "Return a bounded user-mode virtual-memory region map through DbgEng IDebugDataSpaces4::QueryVirtual.", + ), tool::( "target_list_threads", "List threads from a daemon-owned live or dump target session.", @@ -504,6 +508,10 @@ pub async fn call(state: &mut ServiceState, call: ToolCall) -> anyhow::Result(call.arguments)?; Ok(serde_json::to_value(state.targets.read_memory(request)?)?) } + "target_memory_map" => { + let request = parse::(call.arguments)?; + Ok(serde_json::to_value(state.targets.memory_map(request)?)?) + } "target_list_threads" => { let request = parse::(call.arguments)?; Ok(serde_json::to_value(state.targets.list_threads(request)?)?) diff --git a/docs/architecture.md b/docs/architecture.md index cb3e947..fc8950f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,7 +31,7 @@ The native replay bridge has a CMake project at [native/ttd-replay-bridge/CMakeL Live managed breakpoints use a separate [CoreCLR DAC bridge](../native/coreclr-dac-bridge/CMakeLists.txt), never the TTD bridge. It is a narrow C ABI that receives the active DbgEng client, hosts a version- and architecture-matched `mscordaccore.dll`, and implements `ICLRDataTarget` using DbgEng module, memory, thread, and context services. It uses the stable CLR metadata import contract to compare exact raw ECMA-335 `MethodDef` signature bytes when the caller selects an overload, while reporting bounded token/signature diagnostics for name matches. It resolves metadata through a method definition, then obtains the matching method instance after code-generation notification; only that instance supplies the JIT-native breakpoint address. Its opt-in `ICLRDataTarget2` implementation uses DbgEng's active process handle solely for the CLR-requested JIT-notification-table allocation; the CLR then owns that allocation. A separate read-only resolver never calls notification registration and can report an existing native method instance, but cannot observe a module or method first registered/JIT-compiled after the stopped native load event. It exposes only opaque handles and POD output for module/method resolution, CLR notification registration, and representative generated-code addresses. It does not attach `ICorDebug`, load SOS, invoke debugger extension commands, hollow the process, or inject managed code. The x64 bridge is staged separately under `target/native/coreclr-dac-bridge`; the target DAC remains runtime-provided and is not redistributed. -`live startup-profile` is intentionally outside that DAC path. It owns a short-lived DbgEng session, begins at a create-process event, applies only DbgEng lifecycle event filters, and accumulates host-monotonic wall time while the target is resumed between observed stops. It can complete at a requested image-load boundary or after a bounded interval without a further configured lifecycle stop; the latter is explicitly an observed lifecycle-quiet interval, not a UI-ready or target-quiescence signal. The profiler records bounded process/thread/module/exception event metadata, lifecycle first/last summaries, ranked fully observed inter-event gaps, and optional read-only stop context, but does not use DbgEng breakpoint APIs, target-memory APIs, callbacks, CLR notifications, SOS, a DAC, or a second debugging engine. Its phase derivation accepts only explicit observed lifecycle boundaries and labels the result as wall-clock observation rather than CPU attribution or managed execution evidence. +`live startup-profile` is intentionally outside that DAC path. It owns a short-lived DbgEng session, begins at a create-process event, applies only DbgEng lifecycle event filters, and accumulates host-monotonic wall time while the target is resumed between observed stops. It can complete at a requested image-load boundary or after a bounded interval without a further configured lifecycle stop; the latter is explicitly an observed lifecycle-quiet interval, not a UI-ready or target-quiescence signal. The profiler records bounded process/thread/module/exception event metadata, lifecycle first/last summaries, ranked fully observed inter-event gaps, and optional read-only stop context. Its opt-in host-side output callback captures only bounded debuggee categories and restores the preceding DbgEng callback/mask; opt-in module provenance reads bounded metadata only from DbgEng-observed absolute image paths. Neither is target-memory evidence or a timing source. The profiler does not use DbgEng breakpoint APIs, target-memory APIs, CLR notifications, SOS, a DAC, or a second debugging engine. Its phase derivation accepts only explicit observed lifecycle boundaries and labels the result as wall-clock observation rather than CPU attribution or managed execution evidence. The daemon's `target memory-map` is a separate bounded, direct `IDebugDataSpaces4::QueryVirtual` wrapper for stopped live user-mode targets; it reports partial/unavailable coverage explicitly and does not substitute an OS process-memory API. ## Symbols diff --git a/docs/cli.md b/docs/cli.md index 7e3d73d..a771e4f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -216,7 +216,9 @@ The .NET 10.0.9 x64 fixture was validated with the direct DAC bridge: `live startup-profile` is a one-shot DbgEng lifecycle collector for agent-oriented startup evidence. It launches at a DbgEng `create_process` event, enables only create/exit-process, create/exit-thread, load/unload-module event filters, then resumes the target and records bounded stopped events. It does **not** configure a software or hardware breakpoint, open the DAC, request CLR notifications, allocate or write target memory, inject code, or use a profiler. -The default exception policy does not request first-chance exceptions because managed startup can produce enough expected exceptions to consume the bounded timeline. Use `--include-first-chance-exceptions` only when that additional event volume is justified. Debuggee output is not captured into the structured result because the command does not install a DbgEng output callback, although a target can still inherit the invoking console. +The default exception policy does not request first-chance exceptions because managed startup can produce enough expected exceptions to consume the bounded timeline. Use `--include-first-chance-exceptions` only when that additional event volume is justified. + +`--capture-debuggee-output` is an opt-in, host-side DbgEng `IDebugOutputCallbacksWide` capture for only the debuggee and debuggee-prompt categories. It is disabled by default because debug strings can contain sensitive content. `--max-output-records`, `--max-output-chars`, and `--max-total-output-chars` independently bound retained records and text; `debuggee_output` reports truncation/drop counts and restores the prior callback and output mask before the run ends. A record's `preceding_event_index` is the latest retained lifecycle event when DbgEng invoked the callback, not a causal association. The command reports two host-monotonic measures: @@ -245,7 +247,9 @@ target\debug\windbg-tool.exe --compact live startup-profile ` `--completion-module ` ends an observation at the matching DbgEng image-load event. It is an image-load boundary only: it is not UI-ready, managed registration, JIT completion, or application initialization. Add `--settle-ms 1..60000` to require a target-resumed interval without a further configured lifecycle stop after that load. Any observed lifecycle stop restarts the interval. A `quiet_interval_observed` result establishes only that bounded DbgEng condition; it does not prove target quiescence, CPU idleness, I/O completion, or UI readiness. If process exit, timeout, or the event limit occurs first, the result stays incomplete and never infers quietness. -`--max-events` bounds retained timeline payload rather than forcing a healthy target to terminate. For a full-lifetime profile without `--completion-module`, windbg-tool retains `max_events - 1` entries, disables high-volume thread/module filters, and waits only for `exit_process`, preserving the final exit boundary in the last slot. The result marks this as `coverage.timeline_truncated: true` and lists the DbgEng `timing.tail_filter_commands`; it makes no claim about events omitted during that exit-only tail. A completion-bound profile instead stops as incomplete at its retention limit, because disabling the lifecycle filters would make a quiet interval unknowable. A single completion-bound run defaults to `--end detach`, so the target can finish normally. Repeated runs require the explicit `--end terminate` cleanup mode to prevent overlapping detached targets. A full-lifetime successful run has `finish_reason: "exit_process"`; a module completion has `finish_reason: "completion_module"`; a quiet completion has `finish_reason: "completion_module_quiet_interval"`. The optional `--capture-stop-context` captures bounded read-only register/module/symbol/stack context on early stops; it can increase observer overhead and is disabled by default. +`--max-events` bounds retained timeline payload rather than forcing a healthy target to terminate. For a full-lifetime profile without `--completion-module`, windbg-tool retains `max_events - 1` entries, disables high-volume thread/module filters, and waits only for `exit_process`, preserving the final exit boundary in the last slot. The result marks this as `coverage.timeline_truncated: true` and lists the DbgEng `timing.tail_filter_commands`; it makes no claim about events omitted during that exit-only tail. A completion-bound profile instead stops as incomplete at its retention limit, because disabling the lifecycle filters would make a quiet interval unknowable. A single completion-bound run defaults to `--end detach`, so the target can finish normally. Repeated runs require the explicit `--end terminate` cleanup mode to prevent overlapping detached targets. A full-lifetime successful run has `finish_reason: "exit_process"`; a module completion has `finish_reason: "completion_module"`; a quiet completion has `finish_reason: "completion_module_quiet_interval"`. + +The opt-in `--capture-stop-context` captures bounded read-only register/module/symbol/stack context on selected early stops; it can increase observer overhead and is disabled by default. `--context-on` selects event kinds, defaulting to `load-module`, `create-thread`, `exception`, and `exit-process`; every timeline event has an explicit context status (`not_requested`, `not_selected`, `limit_reached`, `captured`, or `unavailable`). `--capture-module-provenance` separately reads bounded host-file metadata only for absolute module image paths DbgEng already observed. Its records include canonical host path, file size/last-write time, PE architecture/image timestamp, CodeView identity, and available Win32 file/product versions. This is host-file metadata, not target-memory evidence or lifecycle timing; it never hashes, verifies signatures, or reads an unobserved path. The important JSON fields are: @@ -276,8 +280,14 @@ The important JSON fields are: "status": "observed", "elapsed_ms": 10 }], - "lifecycle_summary": { - "debuggee_output": { "status": "not_captured_no_output_callback" } + "debuggee_output": { + "status": "captured", + "records_returned": 1, + "dropped_record_count": 0 + }, + "module_provenance": { + "status": "captured", + "source": "host_file_metadata" }, "largest_observed_gaps": [{ "elapsed_ms": 10, @@ -297,6 +307,17 @@ The important JSON fields are: } ``` +Compare independent artifacts with no debugger launch or target interaction: + +```powershell +target\debug\windbg-tool.exe --compact live startup-compare ` + --baseline $baselineReport ` + --candidate $candidateReport ` + --max-sequence-events 64 +``` + +The comparator accepts only `live_startup_profile` JSON artifacts up to 16 MiB. It compares observed phase and one-largest-gap wall-time distributions plus bounded lifecycle event/module/exception sequence prefixes. It reports profile and comparison truncation, omits unstable thread IDs, and never labels a difference as a CPU regression or causal explanation. + For a future RDM observation, use the bounded no-breakpoint form only after the fixture command has completed with the expected no-write report: ```powershell @@ -386,6 +407,8 @@ target\debug\windbg-tool.exe --compact target thread --target 1 ` `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. +`target memory-map --target --region-limit <1..4096>` and MCP `target_memory_map` query the stopped live user-mode target through `IDebugDataSpaces4::QueryVirtual`; both enforce the same `1..4096` region bound. They do not parse extension output or call `VirtualQueryEx`. The result uses numeric `MEMORY_BASIC_INFORMATION64` fields with `source: "dbgeng_idata_spaces4_query_virtual"`. `status: "bounded"` and `truncated: true` mean the region cap was reached, while `unavailable` or `partial_query_error` explicitly mean that full address-space coverage is not claimed. It is unavailable for dump targets. + ## 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. diff --git a/docs/development.md b/docs/development.md index 7df642d..a1862d1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -87,7 +87,7 @@ Run the fixture's public/private/overload `live managed-break` commands from [cl `live startup-profile` validates the event-only DbgEng path without a breakpoint or DAC. It starts at a create-process event and configures only DbgEng lifecycle filters. It performs no target-memory allocation/write, no CLR notification registration, no injection, and no profiling. Run its fixture command from [cli.md](cli.md) before any RDM profile attempt. -The command's phase values are host-monotonic resumed wall time between DbgEng stops, not CPU time or target-internal timestamps. The fixture accepts `--startup-observation-delay-ms 0..10000` so the documented module-plus-quiet test can detach before normal process exit. A `quiet_interval_observed` result means only that no configured lifecycle stop arrived while the target was resumed for that duration; it does not establish application quiescence. Preserve `finish_reason`, `completion`, `coverage`, and incomplete runs when evaluating output. `--capture-stop-context` is read-only but opt-in because its symbol/stack queries add observer overhead. `--include-first-chance-exceptions` is also opt-in and bounded by `--max-events`. +The command's phase values are host-monotonic resumed wall time between DbgEng stops, not CPU time or target-internal timestamps. The fixture accepts `--startup-observation-delay-ms 0..10000` so the documented module-plus-quiet test can detach before normal process exit, and `--debug-output ` to emit at most 256 UTF-16 characters through `OutputDebugStringW` for opt-in callback validation. Use the latter only with a non-sensitive fixture string and `--capture-debuggee-output` caps. A `quiet_interval_observed` result means only that no configured lifecycle stop arrived while the target was resumed for that duration; it does not establish application quiescence. Preserve `finish_reason`, `completion`, `coverage`, and incomplete runs when evaluating output. `--capture-stop-context` and `--capture-module-provenance` are read-only but opt-in because their host-side inspection queries add observer overhead. `--include-first-chance-exceptions` is also opt-in and bounded by `--max-events`. The RDM policy remains stricter for all breakpoint/DAC workflows. The event-only profile command can make one bounded RDM launch after the fixture proves its no-write result. Only after that run reaches `exit_process` may it make a small bounded repeated collection; if the initial run is blocked, do not retry or seek an exclusion, protection change, or workaround. From 4dc9ababe4cf56d3b34a7647c117f20f25e88df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Jul 2026 14:06:09 -0400 Subject: [PATCH 10/12] Add DbgEng thread accounting observability Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-dbgeng/src/lib.rs | 379 ++++++++++++++++++ crates/windbg-tool/src/cli.rs | 150 +++++++ crates/windbg-tool/src/cli/dispatch.rs | 14 + crates/windbg-tool/src/cli/platform.rs | 284 ++++++++++++- .../ManagedBreakpointFixture/Program.cs | 33 ++ crates/windbg-ttd/src/targets.rs | 140 ++++++- crates/windbg-ttd/src/tools.rs | 36 +- docs/architecture.md | 4 +- docs/cli.md | 12 +- docs/development.md | 2 +- 10 files changed, 1032 insertions(+), 22 deletions(-) diff --git a/crates/windbg-dbgeng/src/lib.rs b/crates/windbg-dbgeng/src/lib.rs index 531b0cd..7ed25e1 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -27,6 +27,9 @@ pub const NT_ALT_SYMBOL_PATH_ENV: &str = "_NT_ALT_SYMBOL_PATH"; pub const NT_SYMCACHE_PATH_ENV: &str = "_NT_SYMCACHE_PATH"; pub const DBGENG_RUNTIME_DIR_ENV: &str = "WINDBG_DBGENG_RUNTIME_DIR"; pub const MAX_VIRTUAL_MEMORY_MAP_REGIONS: u32 = 4096; +pub const MAX_THREAD_ACCOUNTING_THREADS: u32 = 128; +pub const MAX_MODULE_PARAMETER_QUERIES: usize = 128; +pub const MAX_SYMBOL_ENTRY_OFFSET_REGIONS: usize = 16; const DEFAULT_DBGENG_SYMBOL_CACHE: &str = ".windbg-symbol-cache"; const DBGENG_DLL_NAME: &str = "dbgeng.dll"; #[cfg(windows)] @@ -494,6 +497,42 @@ pub struct ThreadInfo { pub system_id: u32, } +#[derive(Debug, Clone, Serialize)] +pub struct ThreadAccountingEntry { + pub thread: ThreadInfo, + pub basic_information_status: String, + pub basic_information_size_bytes: Option, + pub name_status: String, + pub name: Option, + pub name_size_bytes: Option, + pub valid_mask: Option, + pub exit_status: Option, + pub priority_class: Option, + pub priority: Option, + pub create_time_raw: Option, + pub exit_time_raw: Option, + pub kernel_time_raw: Option, + pub user_time_raw: Option, + pub kernel_time_ms: Option, + pub user_time_ms: Option, + pub start_offset: Option, + pub affinity: Option, + pub errors: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ThreadAccountingSnapshot { + pub source: String, + pub status: String, + pub counter_units: String, + pub total_threads: Option, + pub threads: Vec, + pub returned: usize, + pub limit: u32, + pub truncated: bool, + pub detail: String, +} + #[derive(Debug, Clone, Serialize)] pub struct ModuleInfo { pub base_address: u64, @@ -503,6 +542,46 @@ pub struct ModuleInfo { pub symbol_file: Option, } +#[derive(Debug, Clone, Serialize)] +pub struct ModuleDebugParameters { + pub base_address: u64, + pub image_size: u32, + pub time_date_stamp: u32, + pub checksum: u32, + pub flags: u32, + pub symbol_type: u32, + pub symbol_type_name: String, + pub image_name_size: u32, + pub module_name_size: u32, + pub loaded_image_name_size: u32, + pub symbol_file_name_size: u32, + pub mapped_image_name_size: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SymbolEntryOffsetRegion { + pub base_address: u64, + pub size: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SymbolEntryRange { + pub source: String, + pub status: String, + pub address: u64, + pub symbol_module_base: Option, + pub symbol_offset: Option, + pub symbol_size: Option, + pub displacement: Option, + pub symbol_tag: Option, + pub symbol_flags: Option, + pub symbol_token: Option, + pub regions: Vec, + pub regions_available: Option, + pub regions_truncated: bool, + pub detail: String, +} + #[derive(Debug, Clone, Serialize)] pub struct SymbolInfo { pub address: u64, @@ -1235,6 +1314,167 @@ impl DebuggerSession { .collect()) } + pub fn thread_accounting_snapshot( + &self, + max_threads: u32, + ) -> anyhow::Result { + use windows::core::Interface; + use windows::Win32::System::Diagnostics::Debug::Extensions::{ + IDebugAdvanced2, DEBUG_SYSOBJINFO_THREAD_BASIC_INFORMATION, + DEBUG_SYSOBJINFO_THREAD_NAME_WIDE, DEBUG_TBINFO_AFFINITY, DEBUG_TBINFO_EXIT_STATUS, + DEBUG_TBINFO_PRIORITY, DEBUG_TBINFO_PRIORITY_CLASS, DEBUG_TBINFO_START_OFFSET, + DEBUG_TBINFO_TIMES, DEBUG_THREAD_BASIC_INFORMATION, + }; + + ensure!( + (1..=MAX_THREAD_ACCOUNTING_THREADS).contains(&max_threads), + "DbgEng thread-accounting limit must be from 1 through {MAX_THREAD_ACCOUNTING_THREADS}" + ); + let threads = self.threads()?; + let total_threads = threads.len(); + let truncated = total_threads > max_threads as usize; + let advanced: IDebugAdvanced2 = match self.client.cast() { + Ok(advanced) => advanced, + Err(error) => { + return Ok(ThreadAccountingSnapshot { + source: "dbgeng_iddebugadvanced2_getsystemobjectinformation".to_string(), + status: "unavailable".to_string(), + counter_units: "100ns".to_string(), + total_threads: Some(total_threads), + threads: Vec::new(), + returned: 0, + limit: max_threads, + truncated, + detail: format!( + "DbgEng did not expose IDebugAdvanced2 for thread accounting: {error}" + ), + }); + } + }; + + const MAX_THREAD_NAME_CHARS: usize = 128; + let mut entries = Vec::with_capacity(total_threads.min(max_threads as usize)); + for thread in threads.into_iter().take(max_threads as usize) { + let mut basic = DEBUG_THREAD_BASIC_INFORMATION::default(); + let mut basic_information_size_bytes = 0u32; + let basic_result = unsafe { + advanced.GetSystemObjectInformation( + DEBUG_SYSOBJINFO_THREAD_BASIC_INFORMATION, + 0, + thread.engine_id, + Some((&mut basic as *mut DEBUG_THREAD_BASIC_INFORMATION).cast()), + std::mem::size_of::() as u32, + Some(&mut basic_information_size_bytes), + ) + }; + + let mut name_buffer = vec![0u16; MAX_THREAD_NAME_CHARS]; + let mut name_size_bytes = 0u32; + let name_result = unsafe { + advanced.GetSystemObjectInformation( + DEBUG_SYSOBJINFO_THREAD_NAME_WIDE, + 0, + thread.engine_id, + Some(name_buffer.as_mut_ptr().cast()), + (name_buffer.len() * std::mem::size_of::()) as u32, + Some(&mut name_size_bytes), + ) + }; + let basic_captured = basic_result.is_ok(); + let name_captured = name_result.is_ok(); + + let mut errors = Vec::new(); + let basic_information_status = match &basic_result { + Ok(()) => "captured".to_string(), + Err(error) => { + errors.push(format!("basic_information: {error}")); + "unavailable".to_string() + } + }; + let (name_status, name) = match &name_result { + Ok(()) => { + let capacity_bytes = (name_buffer.len() * std::mem::size_of::()) as u32; + let used_chars = if name_size_bytes == 0 { + name_buffer + .iter() + .position(|character| *character == 0) + .unwrap_or(name_buffer.len()) + } else { + (name_size_bytes as usize / std::mem::size_of::()) + .min(name_buffer.len()) + }; + let name_slice = &name_buffer[..used_chars]; + let name_len = name_slice + .iter() + .position(|character| *character == 0) + .unwrap_or(name_slice.len()); + let name = String::from_utf16_lossy(&name_slice[..name_len]); + ( + if name_size_bytes > capacity_bytes { + "truncated".to_string() + } else if name.is_empty() { + "not_provided".to_string() + } else { + "captured".to_string() + }, + (!name.is_empty()).then_some(name), + ) + } + Err(error) => { + errors.push(format!("thread_name: {error}")); + ("unavailable".to_string(), None) + } + }; + let valid = basic.Valid; + let has = |flag| basic_captured && valid & flag != 0; + entries.push(ThreadAccountingEntry { + thread, + basic_information_status, + basic_information_size_bytes: basic_captured + .then_some(basic_information_size_bytes), + name_status, + name, + name_size_bytes: name_captured.then_some(name_size_bytes), + valid_mask: basic_captured.then_some(valid), + exit_status: has(DEBUG_TBINFO_EXIT_STATUS).then_some(basic.ExitStatus), + priority_class: has(DEBUG_TBINFO_PRIORITY_CLASS).then_some(basic.PriorityClass), + priority: has(DEBUG_TBINFO_PRIORITY).then_some(basic.Priority), + create_time_raw: has(DEBUG_TBINFO_TIMES).then_some(basic.CreateTime), + exit_time_raw: has(DEBUG_TBINFO_TIMES).then_some(basic.ExitTime), + kernel_time_raw: has(DEBUG_TBINFO_TIMES).then_some(basic.KernelTime), + user_time_raw: has(DEBUG_TBINFO_TIMES).then_some(basic.UserTime), + kernel_time_ms: has(DEBUG_TBINFO_TIMES) + .then_some(basic.KernelTime as f64 / 10_000.0), + user_time_ms: has(DEBUG_TBINFO_TIMES).then_some(basic.UserTime as f64 / 10_000.0), + start_offset: has(DEBUG_TBINFO_START_OFFSET).then_some(basic.StartOffset), + affinity: has(DEBUG_TBINFO_AFFINITY).then_some(basic.Affinity), + errors, + }); + } + let unavailable_entries = entries + .iter() + .filter(|entry| entry.basic_information_status != "captured") + .count(); + let status = if entries.is_empty() || unavailable_entries == entries.len() { + "unavailable" + } else if unavailable_entries > 0 || truncated { + "partial" + } else { + "captured" + }; + Ok(ThreadAccountingSnapshot { + source: "dbgeng_iddebugadvanced2_getsystemobjectinformation".to_string(), + status: status.to_string(), + counter_units: "100ns".to_string(), + total_threads: Some(total_threads), + returned: entries.len(), + threads: entries, + limit: max_threads, + truncated, + detail: "Per-thread fields are returned only when their DEBUG_THREAD_BASIC_INFORMATION valid-mask bit is set. KernelTime and UserTime use 100 ns DbgEng counters; their millisecond projections were fixture-validated against a bounded CPU-burn run. They remain per-thread accounting samples, not lifecycle-gap causality.".to_string(), + }) + } + pub fn modules(&self) -> anyhow::Result> { let mut loaded = 0u32; let mut unloaded = 0u32; @@ -1271,6 +1511,125 @@ impl DebuggerSession { Ok(modules) } + pub fn module_parameters( + &self, + base_addresses: &[u64], + ) -> anyhow::Result> { + use windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_MODULE_PARAMETERS; + + ensure!( + !base_addresses.is_empty(), + "DbgEng module-parameter query requires at least one observed module base address" + ); + ensure!( + base_addresses.len() <= MAX_MODULE_PARAMETER_QUERIES, + "DbgEng module-parameter query supports at most {MAX_MODULE_PARAMETER_QUERIES} module base addresses" + ); + let mut parameters = vec![DEBUG_MODULE_PARAMETERS::default(); base_addresses.len()]; + unsafe { + self.symbols.GetModuleParameters( + base_addresses.len() as u32, + Some(base_addresses.as_ptr()), + 0, + parameters.as_mut_ptr(), + )?; + } + Ok(parameters + .into_iter() + .map(|parameters| ModuleDebugParameters { + base_address: parameters.Base, + image_size: parameters.Size, + time_date_stamp: parameters.TimeDateStamp, + checksum: parameters.Checksum, + flags: parameters.Flags, + symbol_type: parameters.SymbolType, + symbol_type_name: dbgeng_symbol_type_name(parameters.SymbolType).to_string(), + image_name_size: parameters.ImageNameSize, + module_name_size: parameters.ModuleNameSize, + loaded_image_name_size: parameters.LoadedImageNameSize, + symbol_file_name_size: parameters.SymbolFileNameSize, + mapped_image_name_size: parameters.MappedImageNameSize, + }) + .collect()) + } + + pub fn symbol_entry_range_by_offset(&self, address: u64) -> anyhow::Result { + use windows::Win32::System::Diagnostics::Debug::Extensions::{ + DEBUG_MODULE_AND_ID, DEBUG_OFFSET_REGION, DEBUG_SYMBOL_ENTRY, + }; + + let mut id = DEBUG_MODULE_AND_ID::default(); + let mut displacement = 0u64; + let mut entries = 0u32; + unsafe { + self.symbols.GetSymbolEntriesByOffset( + address, + 0, + Some(&mut id), + Some(&mut displacement), + 1, + Some(&mut entries), + )?; + } + if entries == 0 { + return Ok(SymbolEntryRange { + source: "dbgeng_idebugsymbols5_symbol_entry".to_string(), + status: "not_found".to_string(), + address, + symbol_module_base: None, + symbol_offset: None, + symbol_size: None, + displacement: None, + symbol_tag: None, + symbol_flags: None, + symbol_token: None, + regions: Vec::new(), + regions_available: Some(0), + regions_truncated: false, + detail: "DbgEng returned no symbol entry for the requested address.".to_string(), + }); + } + + let mut entry = DEBUG_SYMBOL_ENTRY::default(); + unsafe { + self.symbols.GetSymbolEntryInformation(&id, &mut entry)?; + } + let mut regions = vec![DEBUG_OFFSET_REGION::default(); MAX_SYMBOL_ENTRY_OFFSET_REGIONS]; + let mut regions_available = 0u32; + unsafe { + self.symbols.GetSymbolEntryOffsetRegions( + &id, + 0, + Some(regions.as_mut_slice()), + Some(&mut regions_available), + )?; + } + let returned = regions_available.min(MAX_SYMBOL_ENTRY_OFFSET_REGIONS as u32) as usize; + regions.truncate(returned); + Ok(SymbolEntryRange { + source: "dbgeng_idebugsymbols5_symbol_entry".to_string(), + status: "captured".to_string(), + address, + symbol_module_base: Some(entry.ModuleBase), + symbol_offset: Some(entry.Offset), + symbol_size: Some(entry.Size), + displacement: Some(displacement), + symbol_tag: Some(entry.Tag), + symbol_flags: Some(entry.Flags), + symbol_token: Some(entry.Token), + regions: regions + .into_iter() + .map(|region| SymbolEntryOffsetRegion { + base_address: region.Base, + size: region.Size, + }) + .collect(), + regions_available: Some(regions_available), + regions_truncated: regions_available > MAX_SYMBOL_ENTRY_OFFSET_REGIONS as u32, + detail: "This is a bounded DbgEng symbol-entry offset-region query. It identifies debugger symbol coverage at an observed native address, but does not establish managed method execution. Symbol queries can trigger host-side symbol resolution I/O according to the configured symbol path.".to_string(), + }) + } + pub fn module_by_offset(&self, address: u64) -> anyhow::Result> { use windows::Win32::System::Diagnostics::Debug::Extensions::DEBUG_GETMOD_NO_UNLOADED_MODULES; @@ -1946,6 +2305,26 @@ fn status_name(status: u32) -> String { } } +#[cfg(windows)] +fn dbgeng_symbol_type_name(symbol_type: u32) -> &'static str { + use windows::Win32::System::Diagnostics::Debug::Extensions::{ + DEBUG_SYMTYPE_CODEVIEW, DEBUG_SYMTYPE_COFF, DEBUG_SYMTYPE_DEFERRED, DEBUG_SYMTYPE_DIA, + DEBUG_SYMTYPE_EXPORT, DEBUG_SYMTYPE_NONE, DEBUG_SYMTYPE_PDB, DEBUG_SYMTYPE_SYM, + }; + + match symbol_type { + DEBUG_SYMTYPE_NONE => "none", + DEBUG_SYMTYPE_COFF => "coff", + DEBUG_SYMTYPE_CODEVIEW => "codeview", + DEBUG_SYMTYPE_PDB => "pdb", + DEBUG_SYMTYPE_EXPORT => "export", + DEBUG_SYMTYPE_DEFERRED => "deferred", + DEBUG_SYMTYPE_SYM => "sym", + DEBUG_SYMTYPE_DIA => "dia", + _ => "unknown", + } +} + #[cfg(windows)] fn start_process_server_impl(options: ProcessServerOptions) -> anyhow::Result { use windows::core::PCWSTR; diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index d9d713e..814aae5 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -476,6 +476,14 @@ enum TargetCommand { Step(TargetIdArgs), #[command(about = "List threads for a daemon-owned target")] Threads(TargetIdArgs), + #[command( + about = "Read bounded DbgEng per-thread CPU accounting for a stopped daemon-owned target" + )] + ThreadAccounting(TargetThreadAccountingArgs), + #[command( + about = "Read bounded DbgEng symbol-readiness parameters for supplied observed module bases" + )] + ModuleParameters(TargetModuleParametersArgs), #[command(about = "List modules for a daemon-owned target")] Modules(TargetIdArgs), #[command(about = "Read current thread and instruction offsets for a daemon-owned target")] @@ -498,6 +506,10 @@ enum TargetCommand { Disasm(TargetDisasmArgs), #[command(about = "Resolve the nearest symbol for a daemon-owned target address")] Symbol(TargetAddressArgs), + #[command( + about = "Read bounded DbgEng symbol-entry offset regions for an existing native address" + )] + SymbolEntryRange(TargetAddressArgs), #[command( about = "Resolve source file and line information for a daemon-owned target address" )] @@ -812,6 +824,42 @@ struct LiveStartupProfileArgs { help = "Maximum stack frames in each optional stop context" )] max_frames: u32, + #[arg( + long, + requires = "capture_stop_context", + help = "Attach one bounded DbgEng symbol-entry range query to each captured stop context; symbol paths can cause host-side resolution I/O" + )] + capture_native_symbol_entry_range: bool, + #[arg( + long, + help = "Capture bounded read-only DbgEng per-thread accounting snapshots with raw validity-gated counters" + )] + capture_thread_accounting: bool, + #[arg( + long, + value_delimiter = ',', + value_enum, + requires = "capture_thread_accounting", + value_name = "EVENT", + help = "Comma-separated lifecycle event kinds eligible for thread accounting; defaults to create-process,load-module,create-thread,exit-process when enabled" + )] + thread_accounting_on: Vec, + #[arg( + long, + default_value_t = 8, + value_parser = clap::value_parser!(u32).range(1..=32), + requires = "capture_thread_accounting", + help = "Maximum selected lifecycle stops that collect a thread-accounting snapshot" + )] + max_thread_accounting_snapshots: u32, + #[arg( + long, + default_value_t = 32, + value_parser = clap::value_parser!(u32).range(1..=128), + requires = "capture_thread_accounting", + help = "Maximum threads returned in each DbgEng thread-accounting snapshot" + )] + max_thread_accounting_threads: u32, #[arg( long, help = "Capture bounded DbgEng debuggee-output callback records; disabled by default because debug strings can contain sensitive content" @@ -854,6 +902,19 @@ struct LiveStartupProfileArgs { help = "Maximum unique DbgEng-observed module image paths inspected for host file metadata" )] max_module_provenance: u32, + #[arg( + long, + help = "Read bounded DbgEng module parameters only for module bases observed in this lifecycle timeline; symbol paths can cause host-side resolution I/O" + )] + capture_dbgeng_module_parameters: bool, + #[arg( + long, + default_value_t = 32, + value_parser = clap::value_parser!(u32).range(1..=128), + requires = "capture_dbgeng_module_parameters", + help = "Maximum observed module bases queried through DbgEng IDebugSymbols5" + )] + max_dbgeng_module_parameters: u32, #[arg( long, value_name = "PATH", @@ -1396,6 +1457,32 @@ struct TargetMemoryMapArgs { region_limit: u32, } +#[derive(Debug, Args)] +struct TargetThreadAccountingArgs { + #[arg(short = 't', long = "target")] + target: u64, + #[arg( + long, + default_value_t = 32, + value_parser = clap::value_parser!(u32).range(1..=128), + help = "Maximum DbgEng threads returned" + )] + max_threads: u32, +} + +#[derive(Debug, Args)] +struct TargetModuleParametersArgs { + #[arg(short = 't', long = "target")] + target: u64, + #[arg( + long = "module-base", + required = true, + value_name = "ADDRESS", + help = "DbgEng-observed module base address; repeat for up to 128 distinct modules" + )] + module_bases: Vec, +} + #[derive(Debug, Args)] struct TargetDumpArgs { #[arg(short = 't', long = "target")] @@ -5779,6 +5866,42 @@ fn target_memory_map_call(args: TargetMemoryMapArgs) -> ToolCall { } } +fn target_thread_accounting_call(args: TargetThreadAccountingArgs) -> ToolCall { + ToolCall { + name: "target_thread_accounting".to_string(), + arguments: json!({ + "target_id": args.target, + "max_threads": args.max_threads, + }), + } +} + +fn target_module_parameters_call(args: TargetModuleParametersArgs) -> anyhow::Result { + ensure!( + (1..=128).contains(&args.module_bases.len()), + "target module-parameters requires from 1 through 128 --module-base values" + ); + let module_base_addresses = args + .module_bases + .iter() + .map(|address| parse_u64_argument(address)) + .collect::>>()?; + let mut unique = module_base_addresses.clone(); + unique.sort_unstable(); + unique.dedup(); + ensure!( + unique.len() == module_base_addresses.len(), + "target module-parameters requires distinct --module-base values" + ); + Ok(ToolCall { + name: "target_module_parameters".to_string(), + arguments: json!({ + "target_id": args.target, + "module_base_addresses": module_base_addresses, + }), + }) +} + fn target_dump_call(args: TargetDumpArgs) -> ToolCall { ToolCall { name: "target_write_dump".to_string(), @@ -8107,4 +8230,31 @@ mod tests { assert_eq!(call.arguments["kind"], "full"); assert_eq!(call.arguments["overwrite"], true); } + + #[test] + fn builds_bounded_target_observability_tool_calls() -> anyhow::Result<()> { + let accounting = target_thread_accounting_call(TargetThreadAccountingArgs { + target: 7, + max_threads: 32, + }); + assert_eq!(accounting.name, "target_thread_accounting"); + assert_eq!(accounting.arguments["target_id"], 7); + assert_eq!(accounting.arguments["max_threads"], 32); + + let parameters = target_module_parameters_call(TargetModuleParametersArgs { + target: 7, + module_bases: vec!["0x1000".to_string(), "0x2000".to_string()], + })?; + assert_eq!(parameters.name, "target_module_parameters"); + assert_eq!( + parameters.arguments["module_base_addresses"], + json!([0x1000, 0x2000]) + ); + assert!(target_module_parameters_call(TargetModuleParametersArgs { + target: 7, + module_bases: vec!["0x1000".to_string(), "0x1000".to_string()], + }) + .is_err()); + Ok(()) + } } diff --git a/crates/windbg-tool/src/cli/dispatch.rs b/crates/windbg-tool/src/cli/dispatch.rs index 6250fa5..f04be33 100644 --- a/crates/windbg-tool/src/cli/dispatch.rs +++ b/crates/windbg-tool/src/cli/dispatch.rs @@ -319,6 +319,12 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { TargetCommand::MemoryMap(args) => { call_and_print(pipe, target_memory_map_call(args), &output).await } + TargetCommand::ThreadAccounting(args) => { + call_and_print(pipe, target_thread_accounting_call(args), &output).await + } + TargetCommand::ModuleParameters(args) => { + call_and_print(pipe, target_module_parameters_call(args)?, &output).await + } TargetCommand::Stack(args) => { call_and_print(pipe, target_stack_call(args), &output).await } @@ -336,6 +342,14 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { ) .await } + TargetCommand::SymbolEntryRange(args) => { + call_and_print( + pipe, + target_address_call("target_symbol_entry_range", args)?, + &output, + ) + .await + } TargetCommand::Source(args) => { call_and_print( pipe, diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 0c974da..8f7c8e0 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -1,7 +1,7 @@ use anyhow::{bail, ensure, Context}; use serde::Serialize; use serde_json::{json, Value}; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::env; use std::ffi::OsString; use std::fs; @@ -238,6 +238,7 @@ struct StartupProfileEvent { loaded_module_count: Option, live_thread_count: Option, context: Value, + thread_accounting: Value, } #[derive(Debug, Clone, Serialize)] @@ -303,6 +304,7 @@ struct StartupProfileRun { lifecycle_summary: Value, debuggee_output: Value, module_provenance: Value, + dbgeng_module_parameters: Value, largest_observed_gaps: Vec, gaps_excluded_from_ranking: Vec, counts: Value, @@ -414,11 +416,13 @@ pub(super) fn run_live_startup_profile( "event_timestamps": "Observed when DbgEng returns control to windbg-tool, not target-side instruction timestamps.", "completion_module": "A requested module load is an observable image-load boundary only. It does not establish UI readiness, managed assembly registration, first managed method execution, or successful application initialization.", "quiet_interval": "A requested settle interval establishes only that no configured DbgEng lifecycle stop was observed while the target was resumed for that duration. It does not establish CPU, I/O, UI, or application quiescence.", - "not_cpu_time": true, + "lifecycle_phase_cpu_time": "not_available; lifecycle phases and inter-event gaps remain host wall-time measurements.", + "thread_accounting": "When requested, separate read-only DbgEng per-thread CPU counters can be returned at selected lifecycle stops in raw 100 ns units and fixture-validated milliseconds. They do not attribute a lifecycle gap to CPU work.", "regression_interpretation": "Repeated values can identify wall-clock variability or candidates for comparison with a baseline. They do not attribute CPU use or prove a regression." }, "limitations": [ "DbgEng lifecycle events do not establish managed assembly registration, managed method execution, JIT activity, or CPU attribution.", + "Optional per-thread accounting is a bounded, validity-gated DbgEng snapshot. It is independent from the lifecycle wall clock, returns raw 100 ns counters and fixture-validated millisecond projections, and never establishes causal attribution.", debuggee_output_limitation, "Largest observed gaps rank only adjacent retained events while the full lifecycle filter set was active. Tail-filter gaps are retained as excluded coverage diagnostics, not ranked evidence.", "First-chance exceptions are opt-in because managed startup can generate enough events to consume the bounded timeline." @@ -1023,6 +1027,8 @@ fn collect_startup_profile_stops( counts, module_identities, captured_contexts, + captured_thread_accounting_snapshots, + .. } = recording; let module_identities = module_identities .into_iter() @@ -1044,6 +1050,8 @@ fn collect_startup_profile_stops( }; } let module_provenance = startup_profile_module_provenance(&timeline, args); + let dbgeng_module_parameters = + startup_profile_dbgeng_module_parameters(session, &timeline, args); let lifecycle_summary = startup_profile_lifecycle_summary(&timeline, phase_module, &completion, &debuggee_output); let (largest_observed_gaps, gaps_excluded_from_ranking) = @@ -1076,6 +1084,7 @@ fn collect_startup_profile_stops( lifecycle_summary, debuggee_output, module_provenance, + dbgeng_module_parameters, largest_observed_gaps, gaps_excluded_from_ranking, timeline, @@ -1110,7 +1119,14 @@ fn collect_startup_profile_stops( "first_chance_exceptions_requested": args.include_first_chance_exceptions, "stop_context_requested": args.capture_stop_context, "stop_contexts_returned": captured_contexts, - "module_provenance_requested": args.capture_module_provenance + "native_symbol_entry_range_requested": args.capture_native_symbol_entry_range, + "thread_accounting_requested": args.capture_thread_accounting, + "thread_accounting_snapshot_limit": args.max_thread_accounting_snapshots, + "thread_accounting_snapshots_attempted": captured_thread_accounting_snapshots, + "thread_accounting_thread_limit": args.max_thread_accounting_threads, + "module_provenance_requested": args.capture_module_provenance, + "dbgeng_module_parameters_requested": args.capture_dbgeng_module_parameters, + "dbgeng_module_parameter_limit": args.max_dbgeng_module_parameters }), cleanup: Value::Null, }) @@ -1135,6 +1151,8 @@ struct StartupProfileRecording { counts: StartupProfileCounts, module_identities: BTreeSet, captured_contexts: u32, + captured_thread_accounting_snapshots: u32, + prior_thread_accounting: HashMap<(u32, u32), (usize, windbg_dbgeng::ThreadAccountingEntry)>, } fn configure_startup_profile_event_filters( @@ -1272,6 +1290,7 @@ fn record_startup_profile_event( } } let context = startup_profile_stop_context(session, recording, args, &kind); + let thread_accounting = startup_profile_thread_accounting(session, recording, args, &kind); recording.timeline.push(StartupProfileEvent { index: recording.timeline.len(), kind, @@ -1282,9 +1301,101 @@ fn record_startup_profile_event( loaded_module_count, live_thread_count, context, + thread_accounting, }); } +fn startup_profile_thread_accounting( + session: &DebuggerSession, + recording: &mut StartupProfileRecording, + args: &LiveStartupProfileArgs, + event_kind: &str, +) -> Value { + if !args.capture_thread_accounting { + return json!({ + "status": "not_requested", + "detail": "Read-only per-thread DbgEng accounting capture is disabled." + }); + } + if !startup_profile_thread_accounting_event_selected(args, event_kind) { + return json!({ + "status": "not_selected", + "detail": "This lifecycle event kind is outside --thread-accounting-on." + }); + } + if recording.captured_thread_accounting_snapshots >= args.max_thread_accounting_snapshots { + return json!({ + "status": "limit_reached", + "detail": "The bounded read-only thread-accounting snapshot limit was reached." + }); + } + + recording.captured_thread_accounting_snapshots += 1; + let event_index = recording.timeline.len(); + match session.thread_accounting_snapshot(args.max_thread_accounting_threads) { + Ok(snapshot) => { + let mut same_thread_deltas = Vec::new(); + for entry in &snapshot.threads { + let key = (entry.thread.engine_id, entry.thread.system_id); + if let Some((prior_event_index, previous)) = + recording.prior_thread_accounting.get(&key) + { + let current_times = entry.kernel_time_raw.zip(entry.user_time_raw); + let previous_times = previous.kernel_time_raw.zip(previous.user_time_raw); + let delta = match (current_times, previous_times) { + (Some((kernel, user)), Some((previous_kernel, previous_user))) + if kernel >= previous_kernel && user >= previous_user => + { + json!({ + "status": "captured", + "kernel_time_delta_raw": kernel - previous_kernel, + "user_time_delta_raw": user - previous_user, + "total_time_delta_raw": kernel - previous_kernel + user - previous_user, + "kernel_time_delta_ms": (kernel - previous_kernel) as f64 / 10_000.0, + "user_time_delta_ms": (user - previous_user) as f64 / 10_000.0, + "total_time_delta_ms": (kernel - previous_kernel + user - previous_user) as f64 / 10_000.0, + "detail": "The same DbgEng engine/system thread identifier pair had validity-gated, nondecreasing 100 ns counters. Millisecond projections were fixture-validated, but do not attribute a lifecycle gap to CPU work." + }) + } + (Some(_), Some(_)) => json!({ + "status": "unavailable_counter_decreased", + "detail": "At least one raw counter decreased between selected stops, so no delta was emitted." + }), + _ => json!({ + "status": "unavailable_missing_valid_timing_counters", + "detail": "One or both snapshots did not expose validity-gated kernel and user timing counters." + }), + }; + same_thread_deltas.push(json!({ + "engine_thread_id": entry.thread.engine_id, + "system_thread_id": entry.thread.system_id, + "prior_event_index": prior_event_index, + "current_event_index": event_index, + "delta": delta + })); + } + recording + .prior_thread_accounting + .insert(key, (event_index, entry.clone())); + } + json!({ + "status": snapshot.status, + "source": snapshot.source, + "counter_units": snapshot.counter_units, + "snapshot": snapshot, + "same_thread_deltas": same_thread_deltas, + "detail": "The snapshot uses read-only IDebugAdvanced2::GetSystemObjectInformation. Deltas compare only entries with the same DbgEng engine/system thread identifier pair; they do not establish CPU causality for a lifecycle gap or a stable identity if the operating system later reuses a thread ID." + }) + } + Err(error) => json!({ + "status": "unavailable", + "source": "dbgeng_iddebugadvanced2_getsystemobjectinformation", + "detail": "DbgEng rejected the read-only thread-accounting query.", + "error": error.to_string() + }), + } +} + fn startup_profile_stop_context( session: &DebuggerSession, recording: &mut StartupProfileRecording, @@ -1312,18 +1423,51 @@ fn startup_profile_stop_context( recording.captured_contexts += 1; match session.core_registers() { - Ok(registers) => match live_stop_context(session, registers, args.max_frames) { - Ok(context) => json!({ - "status": "captured", - "source": "dbgeng_read_only_stop_snapshot", - "value": context - }), - Err(error) => json!({ - "status": "unavailable", - "source": "dbgeng_read_only_stop_snapshot", - "error": error.to_string() - }), - }, + Ok(registers) => { + let instruction_offset = registers.instruction_offset; + match live_stop_context(session, registers, args.max_frames) { + Ok(context) => { + let native_symbol_entry_range = if args.capture_native_symbol_entry_range { + match instruction_offset { + Some(address) => match session.symbol_entry_range_by_offset(address) { + Ok(range) => json!({ + "status": range.status, + "source": range.source, + "value": range, + "detail": "The bounded DbgEng symbol-entry query can cause host-side symbol-resolution I/O through the configured symbol path. It is not target timing or proof of managed execution." + }), + Err(error) => json!({ + "status": "unavailable", + "source": "dbgeng_idebugsymbols5_symbol_entry", + "error": error.to_string() + }), + }, + None => json!({ + "status": "unavailable", + "source": "dbgeng_idebugsymbols5_symbol_entry", + "detail": "The read-only stop context had no instruction offset." + }), + } + } else { + json!({ + "status": "not_requested", + "detail": "Native symbol-entry range capture is disabled." + }) + }; + json!({ + "status": "captured", + "source": "dbgeng_read_only_stop_snapshot", + "value": context, + "native_symbol_entry_range": native_symbol_entry_range + }) + } + Err(error) => json!({ + "status": "unavailable", + "source": "dbgeng_read_only_stop_snapshot", + "error": error.to_string() + }), + } + } Err(error) => json!({ "status": "unavailable", "source": "dbgeng_read_only_stop_snapshot", @@ -1357,6 +1501,34 @@ fn startup_profile_context_event_selected(args: &LiveStartupProfileArgs, event_k }) } +fn startup_profile_thread_accounting_event_selected( + args: &LiveStartupProfileArgs, + event_kind: &str, +) -> bool { + let selected = if args.thread_accounting_on.is_empty() { + &[ + StartupProfileContextEvent::CreateProcess, + StartupProfileContextEvent::LoadModule, + StartupProfileContextEvent::CreateThread, + StartupProfileContextEvent::ExitProcess, + ][..] + } else { + args.thread_accounting_on.as_slice() + }; + selected.iter().any(|selected| { + matches!( + (selected, event_kind), + (StartupProfileContextEvent::CreateProcess, "create_process") + | (StartupProfileContextEvent::ExitProcess, "exit_process") + | (StartupProfileContextEvent::CreateThread, "create_thread") + | (StartupProfileContextEvent::ExitThread, "exit_thread") + | (StartupProfileContextEvent::LoadModule, "load_module") + | (StartupProfileContextEvent::UnloadModule, "unload_module") + | (StartupProfileContextEvent::Exception, "exception") + ) + }) +} + fn normalize_startup_profile_module(module: ModuleInfo) -> StartupProfileModule { let image_path = [ module.loaded_image_name.as_deref(), @@ -1589,6 +1761,78 @@ fn startup_profile_debuggee_output( }) } +fn startup_profile_dbgeng_module_parameters( + session: &DebuggerSession, + timeline: &[StartupProfileEvent], + args: &LiveStartupProfileArgs, +) -> Value { + if !args.capture_dbgeng_module_parameters { + return json!({ + "status": "not_requested", + "source": "dbgeng_idebugsymbols5_getmoduleparameters", + "records": [], + "detail": "DbgEng module-parameter capture is disabled by default because configured symbol paths can cause host-side resolution I/O." + }); + } + let mut modules = BTreeMap::new(); + for event in timeline { + if let (Some(base_address), Some(module)) = (event.event.module_base, event.module.as_ref()) + { + modules + .entry(base_address) + .or_insert_with(|| module.clone()); + } + } + let observed_module_count = modules.len(); + let selected = modules + .into_iter() + .take(args.max_dbgeng_module_parameters as usize) + .collect::>(); + if selected.is_empty() { + return json!({ + "status": "not_observed", + "source": "dbgeng_idebugsymbols5_getmoduleparameters", + "records": [], + "detail": "No retained lifecycle event had a DbgEng module base address to query." + }); + } + let base_addresses = selected + .iter() + .map(|(base_address, _)| *base_address) + .collect::>(); + match session.module_parameters(&base_addresses) { + Ok(parameters) => { + let records = selected + .into_iter() + .zip(parameters) + .map(|((base_address, module), parameters)| { + json!({ + "module": module, + "base_address": format!("0x{base_address:X}"), + "parameters": parameters + }) + }) + .collect::>(); + json!({ + "status": "captured", + "source": "dbgeng_idebugsymbols5_getmoduleparameters", + "records": records, + "records_returned": base_addresses.len(), + "observed_module_base_count": observed_module_count, + "truncated": observed_module_count > base_addresses.len(), + "detail": "These are bounded DbgEng module/symbol-readiness parameters for lifecycle-observed bases. They are not target timing or target-memory evidence. The configured symbol path can cause host-side symbol-resolution I/O." + }) + } + Err(error) => json!({ + "status": "unavailable", + "source": "dbgeng_idebugsymbols5_getmoduleparameters", + "records": [], + "detail": "DbgEng rejected the bounded module-parameter query; lifecycle collection continued.", + "error": error.to_string() + }), + } +} + fn startup_profile_module_provenance( timeline: &[StartupProfileEvent], args: &LiveStartupProfileArgs, @@ -4370,6 +4614,10 @@ mod tests { "status": "not_requested", "detail": "test" }), + thread_accounting: json!({ + "status": "not_requested", + "detail": "test" + }), } } @@ -4459,6 +4707,12 @@ mod tests { "records": [], "detail": "test" }), + dbgeng_module_parameters: json!({ + "status": "not_requested", + "source": "dbgeng_idebugsymbols5_getmoduleparameters", + "records": [], + "detail": "test" + }), largest_observed_gaps: Vec::new(), gaps_excluded_from_ranking: Vec::new(), counts: json!({}), diff --git a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs index 33acb69..b7efdf6 100644 --- a/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using System.Globalization; using System.Runtime.InteropServices; +using System.Diagnostics; namespace ManagedBreakpointFixture; @@ -12,6 +13,7 @@ public static int Main(string[] args) Console.WriteLine(ManagedTargets.InvokePrivateEntry()); Console.WriteLine(ManagedTargets.Overload("selected")); EmitRequestedDebugOutput(args); + BurnRequestedCpu(args); SleepForStartupObservation(args); return 0; } @@ -60,6 +62,37 @@ private static void SleepForStartupObservation(string[] args) Thread.Sleep(delayMilliseconds); } + private static void BurnRequestedCpu(string[] args) + { + const string durationArgument = "--cpu-burn-ms"; + var index = Array.IndexOf(args, durationArgument); + if (index < 0 || index + 1 >= args.Length) + { + return; + } + + if (!int.TryParse( + args[index + 1], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var durationMilliseconds) + || durationMilliseconds is < 1 or > 10000) + { + throw new ArgumentOutOfRangeException( + durationArgument, + "Expected an integer duration from 1 through 10000 milliseconds."); + } + + var stopwatch = Stopwatch.StartNew(); + ulong accumulator = 0; + while (stopwatch.ElapsedMilliseconds < durationMilliseconds) + { + accumulator = unchecked((accumulator * 6364136223846793005UL) + (ulong)Stopwatch.GetTimestamp()); + } + + GC.KeepAlive(accumulator); + } + [DllImport("kernel32.dll", EntryPoint = "OutputDebugStringW", CharSet = CharSet.Unicode)] private static extern void OutputDebugString(string text); } diff --git a/crates/windbg-ttd/src/targets.rs b/crates/windbg-ttd/src/targets.rs index 9c33dbd..22f45c3 100644 --- a/crates/windbg-ttd/src/targets.rs +++ b/crates/windbg-ttd/src/targets.rs @@ -8,8 +8,10 @@ use windbg_dbgeng::{ DebuggerEventInfo, DebuggerExecutionStatus, DebuggerSession, DebuggerSessionKind, DebuggerSessionSummary, DisassemblyResult, DumpKind, DumpOpenOptions, DumpWriteOptions, DumpWriteResult, EvaluationResult, LiveAttachOptions, LiveInitialStop, - LiveLaunchSessionOptions, MemoryReadResult, ModuleInfo, SourceLocation, StackFrameInfo, - SymbolInfo, ThreadContext, ThreadInfo, VirtualMemoryMap, MAX_VIRTUAL_MEMORY_MAP_REGIONS, + LiveLaunchSessionOptions, MemoryReadResult, ModuleDebugParameters, ModuleInfo, SourceLocation, + StackFrameInfo, SymbolEntryRange, SymbolInfo, ThreadAccountingSnapshot, ThreadContext, + ThreadInfo, VirtualMemoryMap, MAX_MODULE_PARAMETER_QUERIES, MAX_THREAD_ACCOUNTING_THREADS, + MAX_VIRTUAL_MEMORY_MAP_REGIONS, }; pub type TargetId = u64; @@ -75,6 +77,27 @@ pub struct TargetMemoryMapRequest { pub region_limit: u32, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct TargetThreadAccountingRequest { + pub target_id: TargetId, + #[serde(default = "default_target_thread_accounting_threads")] + #[schemars( + range(min = 1, max = 128), + description = "Maximum DbgEng threads returned; must be from 1 through 128." + )] + pub max_threads: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct TargetModuleParametersRequest { + pub target_id: TargetId, + #[schemars( + length(min = 1, max = 128), + description = "Distinct DbgEng-observed module base addresses; at most 128." + )] + pub module_base_addresses: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct TargetAddressRequest { pub target_id: TargetId, @@ -217,6 +240,26 @@ pub struct TargetMemoryMapResponse { pub memory_map: VirtualMemoryMap, } +#[derive(Debug, Clone, Serialize)] +pub struct TargetThreadAccountingResponse { + pub target_id: TargetId, + pub thread_accounting: ThreadAccountingSnapshot, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TargetModuleParametersResponse { + pub target_id: TargetId, + pub source: String, + pub parameters: Vec, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TargetSymbolEntryRangeResponse { + pub target_id: TargetId, + pub symbol_entry_range: SymbolEntryRange, +} + #[derive(Debug, Clone, Serialize)] pub struct TargetModuleList { pub target_id: TargetId, @@ -454,6 +497,47 @@ impl TargetRegistry { }) } + pub fn thread_accounting( + &self, + request: TargetThreadAccountingRequest, + ) -> anyhow::Result { + validate_target_thread_accounting_limit(request.max_threads)?; + let target = self.target(request.target_id)?; + Ok(TargetThreadAccountingResponse { + target_id: request.target_id, + thread_accounting: target + .session + .thread_accounting_snapshot(request.max_threads)?, + }) + } + + pub fn module_parameters( + &self, + request: TargetModuleParametersRequest, + ) -> anyhow::Result { + validate_target_module_parameter_bases(&request.module_base_addresses)?; + let target = self.target(request.target_id)?; + Ok(TargetModuleParametersResponse { + target_id: request.target_id, + source: "dbgeng_idebugsymbols5_getmoduleparameters".to_string(), + parameters: target.session.module_parameters(&request.module_base_addresses)?, + detail: "This bounded DbgEng symbol-readiness query applies only to supplied module base addresses. Its result describes debugger module metadata, not target timing; configured symbol paths can cause host-side symbol-resolution I/O.".to_string(), + }) + } + + pub fn symbol_entry_range( + &self, + request: TargetAddressRequest, + ) -> anyhow::Result { + let target = self.target(request.target_id)?; + Ok(TargetSymbolEntryRangeResponse { + target_id: request.target_id, + symbol_entry_range: target + .session + .symbol_entry_range_by_offset(request.address)?, + }) + } + pub fn list_modules(&self, request: TargetRequest) -> anyhow::Result { let target = self.target(request.target_id)?; Ok(TargetModuleList { @@ -643,6 +727,10 @@ fn default_target_memory_map_regions() -> u32 { 256 } +fn default_target_thread_accounting_threads() -> u32 { + 32 +} + fn validate_target_memory_map_region_limit(region_limit: u32) -> anyhow::Result<()> { ensure!( (1..=MAX_VIRTUAL_MEMORY_MAP_REGIONS).contains(®ion_limit), @@ -651,6 +739,33 @@ fn validate_target_memory_map_region_limit(region_limit: u32) -> anyhow::Result< Ok(()) } +fn validate_target_thread_accounting_limit(max_threads: u32) -> anyhow::Result<()> { + ensure!( + (1..=MAX_THREAD_ACCOUNTING_THREADS).contains(&max_threads), + "target thread-accounting max_threads must be from 1 through {MAX_THREAD_ACCOUNTING_THREADS}" + ); + Ok(()) +} + +fn validate_target_module_parameter_bases(base_addresses: &[u64]) -> anyhow::Result<()> { + ensure!( + !base_addresses.is_empty(), + "target module-parameters requires at least one module base address" + ); + ensure!( + base_addresses.len() <= MAX_MODULE_PARAMETER_QUERIES, + "target module-parameters supports at most {MAX_MODULE_PARAMETER_QUERIES} module base addresses" + ); + let mut unique_bases = base_addresses.to_vec(); + unique_bases.sort_unstable(); + unique_bases.dedup(); + ensure!( + unique_bases.len() == base_addresses.len(), + "target module-parameters requires distinct module base addresses" + ); + Ok(()) +} + fn default_target_disasm_count() -> u32 { 16 } @@ -684,4 +799,25 @@ mod tests { validate_target_memory_map_region_limit(MAX_VIRTUAL_MEMORY_MAP_REGIONS + 1).is_err() ); } + + #[test] + fn validates_thread_accounting_limit_before_accessing_a_target() { + assert!(validate_target_thread_accounting_limit(1).is_ok()); + assert!(validate_target_thread_accounting_limit(MAX_THREAD_ACCOUNTING_THREADS).is_ok()); + assert!(validate_target_thread_accounting_limit(0).is_err()); + assert!( + validate_target_thread_accounting_limit(MAX_THREAD_ACCOUNTING_THREADS + 1).is_err() + ); + } + + #[test] + fn validates_module_parameter_bases_before_accessing_a_target() { + assert!(validate_target_module_parameter_bases(&[0x1000]).is_ok()); + assert!(validate_target_module_parameter_bases(&[]).is_err()); + assert!(validate_target_module_parameter_bases(&[0x1000, 0x1000]).is_err()); + assert!(validate_target_module_parameter_bases( + &(0..=MAX_MODULE_PARAMETER_QUERIES as u64).collect::>() + ) + .is_err()); + } } diff --git a/crates/windbg-ttd/src/tools.rs b/crates/windbg-ttd/src/tools.rs index 04da446..aaf5507 100644 --- a/crates/windbg-ttd/src/tools.rs +++ b/crates/windbg-ttd/src/tools.rs @@ -3,8 +3,10 @@ use crate::state::ServiceState; use crate::targets::{ DumpOpenRequest, LiveAttachRequest, LiveLaunchRequest, TargetAddressRequest, TargetBreakpointRemoveRequest, TargetBreakpointSetRequest, TargetDisassembleRequest, - TargetExpressionRequest, TargetMemoryMapRequest, TargetMemoryReadRequest, TargetRequest, - TargetStackTraceRequest, TargetThreadContextRequest, TargetWaitRequest, TargetWriteDumpRequest, + TargetExpressionRequest, TargetMemoryMapRequest, TargetMemoryReadRequest, + TargetModuleParametersRequest, TargetRequest, TargetStackTraceRequest, + TargetThreadAccountingRequest, TargetThreadContextRequest, TargetWaitRequest, + TargetWriteDumpRequest, }; use crate::ttd_replay::{ AddressInfoRequest, CursorId, IndexBuildRequest, IndexStatsRequest, IndexStatusRequest, @@ -219,6 +221,18 @@ pub fn definitions() -> Vec { "target_memory_map", "Return a bounded user-mode virtual-memory region map through DbgEng IDebugDataSpaces4::QueryVirtual.", ), + tool::( + "target_thread_accounting", + "Return bounded read-only DbgEng per-thread accounting from IDebugAdvanced2.", + ), + tool::( + "target_module_parameters", + "Return bounded DbgEng symbol-readiness parameters for supplied observed module bases.", + ), + tool::( + "target_symbol_entry_range", + "Return bounded DbgEng symbol-entry offset regions for one existing native address.", + ), tool::( "target_list_threads", "List threads from a daemon-owned live or dump target session.", @@ -512,6 +526,24 @@ pub async fn call(state: &mut ServiceState, call: ToolCall) -> anyhow::Result(call.arguments)?; Ok(serde_json::to_value(state.targets.memory_map(request)?)?) } + "target_thread_accounting" => { + let request = parse::(call.arguments)?; + Ok(serde_json::to_value( + state.targets.thread_accounting(request)?, + )?) + } + "target_module_parameters" => { + let request = parse::(call.arguments)?; + Ok(serde_json::to_value( + state.targets.module_parameters(request)?, + )?) + } + "target_symbol_entry_range" => { + let request = parse::(call.arguments)?; + Ok(serde_json::to_value( + state.targets.symbol_entry_range(request)?, + )?) + } "target_list_threads" => { let request = parse::(call.arguments)?; Ok(serde_json::to_value(state.targets.list_threads(request)?)?) diff --git a/docs/architecture.md b/docs/architecture.md index fc8950f..f3a8926 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,7 +31,9 @@ The native replay bridge has a CMake project at [native/ttd-replay-bridge/CMakeL Live managed breakpoints use a separate [CoreCLR DAC bridge](../native/coreclr-dac-bridge/CMakeLists.txt), never the TTD bridge. It is a narrow C ABI that receives the active DbgEng client, hosts a version- and architecture-matched `mscordaccore.dll`, and implements `ICLRDataTarget` using DbgEng module, memory, thread, and context services. It uses the stable CLR metadata import contract to compare exact raw ECMA-335 `MethodDef` signature bytes when the caller selects an overload, while reporting bounded token/signature diagnostics for name matches. It resolves metadata through a method definition, then obtains the matching method instance after code-generation notification; only that instance supplies the JIT-native breakpoint address. Its opt-in `ICLRDataTarget2` implementation uses DbgEng's active process handle solely for the CLR-requested JIT-notification-table allocation; the CLR then owns that allocation. A separate read-only resolver never calls notification registration and can report an existing native method instance, but cannot observe a module or method first registered/JIT-compiled after the stopped native load event. It exposes only opaque handles and POD output for module/method resolution, CLR notification registration, and representative generated-code addresses. It does not attach `ICorDebug`, load SOS, invoke debugger extension commands, hollow the process, or inject managed code. The x64 bridge is staged separately under `target/native/coreclr-dac-bridge`; the target DAC remains runtime-provided and is not redistributed. -`live startup-profile` is intentionally outside that DAC path. It owns a short-lived DbgEng session, begins at a create-process event, applies only DbgEng lifecycle event filters, and accumulates host-monotonic wall time while the target is resumed between observed stops. It can complete at a requested image-load boundary or after a bounded interval without a further configured lifecycle stop; the latter is explicitly an observed lifecycle-quiet interval, not a UI-ready or target-quiescence signal. The profiler records bounded process/thread/module/exception event metadata, lifecycle first/last summaries, ranked fully observed inter-event gaps, and optional read-only stop context. Its opt-in host-side output callback captures only bounded debuggee categories and restores the preceding DbgEng callback/mask; opt-in module provenance reads bounded metadata only from DbgEng-observed absolute image paths. Neither is target-memory evidence or a timing source. The profiler does not use DbgEng breakpoint APIs, target-memory APIs, CLR notifications, SOS, a DAC, or a second debugging engine. Its phase derivation accepts only explicit observed lifecycle boundaries and labels the result as wall-clock observation rather than CPU attribution or managed execution evidence. The daemon's `target memory-map` is a separate bounded, direct `IDebugDataSpaces4::QueryVirtual` wrapper for stopped live user-mode targets; it reports partial/unavailable coverage explicitly and does not substitute an OS process-memory API. +`live startup-profile` is intentionally outside that DAC path. It owns a short-lived DbgEng session, begins at a create-process event, applies only DbgEng lifecycle event filters, and accumulates host-monotonic wall time while the target is resumed between observed stops. It can complete at a requested image-load boundary or after a bounded interval without a further configured lifecycle stop; the latter is explicitly an observed lifecycle-quiet interval, not a UI-ready or target-quiescence signal. The profiler records bounded process/thread/module/exception event metadata, lifecycle first/last summaries, ranked fully observed inter-event gaps, optional read-only stop context, and opt-in validity-gated per-thread `IDebugAdvanced2` accounting. The fixture verified that DbgEng `KernelTime` and `UserTime` are 100 ns counters; their per-thread millisecond projections remain separate from lifecycle wall-time phases and do not create causal attribution. Its opt-in host-side output callback captures only bounded debuggee categories and restores the preceding callback/mask; opt-in module provenance reads bounded metadata only from DbgEng-observed absolute image paths. Bounded `IDebugSymbols5` module parameters and native symbol-entry regions are also available for observed bases/instruction addresses; symbol-path resolution is host-side I/O, not target behavior. None is target-memory evidence or a timing source. The profiler does not use DbgEng breakpoint APIs, target-memory APIs, CLR notifications, SOS, a DAC, or a second debugging engine. Its phase derivation accepts only explicit observed lifecycle boundaries and labels the result as wall-clock observation rather than CPU attribution or managed execution evidence. The daemon's `target memory-map` is a separate bounded, direct `IDebugDataSpaces4::QueryVirtual` wrapper for stopped live user-mode targets; it reports partial/unavailable coverage explicitly and does not substitute an OS process-memory API. + +Direct `IDebugEventCallbacksWide` payload capture is intentionally not installed yet. The currently staged `windows` projection exposes callback methods as `Result<()>`, which normalizes positive DbgEng execution-status return values. A wrapper that forwarded an existing callback through that surface could therefore silently replace its requested continuation status with `DEBUG_STATUS_NO_CHANGE`. Because DbgEng has one event-callback slot per client, windbg-tool rejects that unsafe multiplexing design rather than replacing or partially forwarding a pre-existing callback. Existing lifecycle payloads remain the bounded `LastEventInformation` records; a future implementation must preserve callback return statuses at the COM ABI boundary and fixture-validate restoration alongside output capture before it can be enabled. ## Symbols diff --git a/docs/cli.md b/docs/cli.md index a771e4f..0e76343 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -249,7 +249,11 @@ target\debug\windbg-tool.exe --compact live startup-profile ` `--max-events` bounds retained timeline payload rather than forcing a healthy target to terminate. For a full-lifetime profile without `--completion-module`, windbg-tool retains `max_events - 1` entries, disables high-volume thread/module filters, and waits only for `exit_process`, preserving the final exit boundary in the last slot. The result marks this as `coverage.timeline_truncated: true` and lists the DbgEng `timing.tail_filter_commands`; it makes no claim about events omitted during that exit-only tail. A completion-bound profile instead stops as incomplete at its retention limit, because disabling the lifecycle filters would make a quiet interval unknowable. A single completion-bound run defaults to `--end detach`, so the target can finish normally. Repeated runs require the explicit `--end terminate` cleanup mode to prevent overlapping detached targets. A full-lifetime successful run has `finish_reason: "exit_process"`; a module completion has `finish_reason: "completion_module"`; a quiet completion has `finish_reason: "completion_module_quiet_interval"`. -The opt-in `--capture-stop-context` captures bounded read-only register/module/symbol/stack context on selected early stops; it can increase observer overhead and is disabled by default. `--context-on` selects event kinds, defaulting to `load-module`, `create-thread`, `exception`, and `exit-process`; every timeline event has an explicit context status (`not_requested`, `not_selected`, `limit_reached`, `captured`, or `unavailable`). `--capture-module-provenance` separately reads bounded host-file metadata only for absolute module image paths DbgEng already observed. Its records include canonical host path, file size/last-write time, PE architecture/image timestamp, CodeView identity, and available Win32 file/product versions. This is host-file metadata, not target-memory evidence or lifecycle timing; it never hashes, verifies signatures, or reads an unobserved path. +The opt-in `--capture-stop-context` captures bounded read-only register/module/symbol/stack context on selected early stops; it can increase observer overhead and is disabled by default. `--context-on` selects event kinds, defaulting to `load-module`, `create-thread`, `exception`, and `exit-process`; every timeline event has an explicit context status (`not_requested`, `not_selected`, `limit_reached`, `captured`, or `unavailable`). `--capture-native-symbol-entry-range` optionally adds one bounded `IDebugSymbols5` symbol-entry offset-region lookup to each captured context's existing instruction address. The result is native symbol coverage at that address, not a managed method boundary or execution proof. Both that lookup and `--capture-dbgeng-module-parameters` can cause host-side symbol-resolution I/O through the configured symbol path; neither writes target memory. + +`--capture-thread-accounting` captures bounded `IDebugAdvanced2::GetSystemObjectInformation` snapshots at selected lifecycle stops. `--thread-accounting-on` defaults to `create-process`, `load-module`, `create-thread`, and `exit-process`; `--max-thread-accounting-snapshots` and `--max-thread-accounting-threads` cap work and JSON. Every returned thread preserves the source validity mask and raw `KernelTime`/`UserTime` values. The fixture's bounded `--cpu-burn-ms` run established that these two counters use 100 ns units, so the output also has millisecond projections and same-engine/system-thread deltas when both snapshots are valid and nondecreasing. Those are per-thread accounting samples only: they do not turn a lifecycle wall-time gap into CPU attribution, identify a managed method, or prove that an OS thread identity was never reused. + +`--capture-module-provenance` separately reads bounded host-file metadata only for absolute module image paths DbgEng already observed. Its records include canonical host path, file size/last-write time, PE architecture/image timestamp, CodeView identity, and available Win32 file/product versions. This is host-file metadata, not target-memory evidence or lifecycle timing; it never hashes, verifies signatures, or reads an unobserved path. The important JSON fields are: @@ -289,6 +293,10 @@ The important JSON fields are: "status": "captured", "source": "host_file_metadata" }, + "dbgeng_module_parameters": { + "status": "captured", + "source": "dbgeng_idebugsymbols5_getmoduleparameters" + }, "largest_observed_gaps": [{ "elapsed_ms": 10, "detail": "Target-resumed host wall time between adjacent observed lifecycle stops; not CPU time." @@ -318,6 +326,8 @@ target\debug\windbg-tool.exe --compact live startup-compare ` The comparator accepts only `live_startup_profile` JSON artifacts up to 16 MiB. It compares observed phase and one-largest-gap wall-time distributions plus bounded lifecycle event/module/exception sequence prefixes. It reports profile and comparison truncation, omits unstable thread IDs, and never labels a difference as a CPU regression or causal explanation. +For a daemon-owned stopped target, `target thread-accounting --target --max-threads 32` and MCP `target_thread_accounting` return the same bounded read-only per-thread accounting. `target module-parameters --target --module-base
` / MCP `target_module_parameters` accept only 1 through 128 distinct supplied module bases. `target symbol-entry-range --target --address
` / MCP `target_symbol_entry_range` return a bounded DbgEng symbol-entry region record for one existing native address. The module and symbol queries are not target writes, but their configured symbol path can produce host-side symbol-resolution I/O. + For a future RDM observation, use the bounded no-breakpoint form only after the fixture command has completed with the expected no-write report: ```powershell diff --git a/docs/development.md b/docs/development.md index a1862d1..22a05a9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -87,7 +87,7 @@ Run the fixture's public/private/overload `live managed-break` commands from [cl `live startup-profile` validates the event-only DbgEng path without a breakpoint or DAC. It starts at a create-process event and configures only DbgEng lifecycle filters. It performs no target-memory allocation/write, no CLR notification registration, no injection, and no profiling. Run its fixture command from [cli.md](cli.md) before any RDM profile attempt. -The command's phase values are host-monotonic resumed wall time between DbgEng stops, not CPU time or target-internal timestamps. The fixture accepts `--startup-observation-delay-ms 0..10000` so the documented module-plus-quiet test can detach before normal process exit, and `--debug-output ` to emit at most 256 UTF-16 characters through `OutputDebugStringW` for opt-in callback validation. Use the latter only with a non-sensitive fixture string and `--capture-debuggee-output` caps. A `quiet_interval_observed` result means only that no configured lifecycle stop arrived while the target was resumed for that duration; it does not establish application quiescence. Preserve `finish_reason`, `completion`, `coverage`, and incomplete runs when evaluating output. `--capture-stop-context` and `--capture-module-provenance` are read-only but opt-in because their host-side inspection queries add observer overhead. `--include-first-chance-exceptions` is also opt-in and bounded by `--max-events`. +The command's phase values are host-monotonic resumed wall time between DbgEng stops, not CPU time or target-internal timestamps. The fixture accepts `--startup-observation-delay-ms 0..10000` so the documented module-plus-quiet test can detach before normal process exit, `--debug-output ` to emit at most 256 UTF-16 characters through `OutputDebugStringW` for opt-in callback validation, and `--cpu-burn-ms 1..10000` for thread-accounting validation. A 1200 ms CPU burn produced a validity-gated same-thread user-counter increase of 15,000,000, confirming DbgEng's `KernelTime`/`UserTime` unit is 100 ns before the profiler exposed millisecond projections. Use the debug-output switch only with a non-sensitive fixture string and `--capture-debuggee-output` caps. A `quiet_interval_observed` result means only that no configured lifecycle stop arrived while the target was resumed for that duration; it does not establish application quiescence. Preserve `finish_reason`, `completion`, `coverage`, and incomplete runs when evaluating output. `--capture-stop-context`, `--capture-thread-accounting`, and `--capture-module-provenance` are read-only but opt-in because their queries add observer overhead. `--capture-native-symbol-entry-range` and `--capture-dbgeng-module-parameters` can also cause host-side symbol-resolution I/O through the configured symbol path. `--include-first-chance-exceptions` is also opt-in and bounded by `--max-events`. The RDM policy remains stricter for all breakpoint/DAC workflows. The event-only profile command can make one bounded RDM launch after the fixture proves its no-write result. Only after that run reaches `exit_process` may it make a small bounded repeated collection; if the initial run is blocked, do not retry or seek an exclusion, protection change, or workaround. From 0c49b662719572e80c7a52debbf72848ed6392f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Jul 2026 14:17:08 -0400 Subject: [PATCH 11/12] Add offline startup module reports Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-tool/src/cli.rs | 93 +++ crates/windbg-tool/src/cli/dispatch.rs | 1 + crates/windbg-tool/src/cli/platform.rs | 829 ++++++++++++++++++++++++- docs/cli.md | 33 +- 4 files changed, 934 insertions(+), 22 deletions(-) diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index 814aae5..6a59f88 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -285,6 +285,10 @@ enum LiveCommand { about = "Compare two bounded live startup-profile JSON artifacts without CPU or causal attribution" )] StartupCompare(LiveStartupProfileCompareArgs), + #[command( + about = "Render a bounded offline module timeline from a live startup-profile JSON artifact" + )] + StartupReport(LiveStartupProfileReportArgs), #[command( about = "Launch .NET under DbgEng, bind the matching CoreCLR DAC, and emit a managed method hit" )] @@ -953,6 +957,84 @@ struct LiveStartupProfileCompareArgs { max_sequence_events: u32, } +#[derive(Debug, Args)] +struct LiveStartupProfileReportArgs { + #[arg( + value_name = "ARTIFACT", + help = "Existing live startup-profile JSON artifact to read offline" + )] + artifact: PathBuf, + #[arg( + long, + default_value_t = 1, + value_parser = clap::value_parser!(u32).range(1..=10), + help = "Recorded run number to render; launch-relative timestamps are never combined across runs" + )] + run: u32, + #[arg( + long, + value_name = "SUBSTRING", + help = "Case-insensitive substring filter for module basename, normalized path, or DbgEng module name" + )] + module: Option, + #[arg( + long, + conflicts_with = "rdm_only", + help = "Keep only modules classified from their observed identity as runtime/loader modules" + )] + runtime_only: bool, + #[arg( + long, + conflicts_with = "runtime_only", + help = "Keep only modules whose normalized observed image path contains /RemoteDesktopManager/" + )] + rdm_only: bool, + #[arg( + long, + value_name = "MILLISECONDS", + help = "Keep only first-observed modules at or after this target-resumed host wall-time timestamp" + )] + min_resumed_ms: Option, + #[arg( + long, + default_value_t = 64, + value_parser = clap::value_parser!(u32).range(1..=128), + help = "Maximum filtered module rows returned; the JSON report states whether this bound truncated rows" + )] + max_rows: u32, + #[arg( + long, + value_enum, + default_value_t = StartupProfileReportFormat::Table, + help = "Terminal output format; JSON preserves unclipped normalized paths and structured enrichment" + )] + format: StartupProfileReportFormat, + #[arg( + long, + default_value_t = 56, + value_parser = clap::value_parser!(u32).range(24..=256), + help = "Maximum normalized-path width in table output; JSON always includes the full normalized path" + )] + path_width: u32, + #[arg( + long, + help = "Omit the concise milestones and largest-observed-gaps tables from terminal table output" + )] + no_summary: bool, + #[arg( + long, + value_name = "PATH", + help = "Write the complete structured report JSON to a new file without modifying the source artifact" + )] + output: Option, +} + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +enum StartupProfileReportFormat { + Table, + Json, +} + #[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] enum StartupProfileContextEvent { CreateProcess, @@ -4767,6 +4849,7 @@ fn inferred_command_metadata(command: &Command, path: &[String]) -> Value { | "live launch" | "live startup-break" | "live startup-profile" + | "live startup-report" | "breakpoint capabilities" | "datamodel capabilities" ); @@ -4840,6 +4923,7 @@ fn discover_manifest() -> Value { "live launch --command-line --end detach|terminate", "live startup-profile --command-line [--runs ] [--phase-module ] [--completion-module [--settle-ms ]]", "live startup-compare --baseline --candidate ", + "live startup-report [--run ] [--format table|json]", "live start --command-line [--create-process-stop]", "live attach --process-id " ], @@ -5527,6 +5611,15 @@ fn command_metadata() -> Value { "safety": "read_only_host_artifact", "bounds": ["artifacts <= 16 MiB", "--max-sequence-events 1..256"] }, + { + "command": "live startup-report", + "requires_daemon": false, + "requires_native_ttd": false, + "session_required": false, + "cost": "bounded_local_json_report", + "safety": "read_only_host_artifact", + "bounds": ["artifact <= 16 MiB", "--run 1..10", "--max-rows 1..128", "--path-width 24..256"] + }, { "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 f04be33..f6c7aac 100644 --- a/crates/windbg-tool/src/cli/dispatch.rs +++ b/crates/windbg-tool/src/cli/dispatch.rs @@ -48,6 +48,7 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { LiveCommand::StartupBreak(args) => platform::run_live_startup_break(args, &output), LiveCommand::StartupProfile(args) => platform::run_live_startup_profile(args, &output), LiveCommand::StartupCompare(args) => platform::run_live_startup_compare(args, &output), + LiveCommand::StartupReport(args) => platform::run_live_startup_report(args, &output), LiveCommand::ManagedBreak(args) => platform::run_live_managed_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, diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 8f7c8e0..6d2170c 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -4,6 +4,7 @@ use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::env; use std::ffi::OsString; +use std::fmt::Write as _; use std::fs; use std::os::windows::ffi::OsStrExt; use std::os::windows::process::CommandExt; @@ -34,8 +35,9 @@ use super::output::{print_value, OutputOptions}; use super::{ CliDumpKind, DbgEngServerArgs, DumpCreateArgs, DumpInspectArgs, LiveLaunchArgs, LiveManagedBreakArgs, LiveStartupBreakArgs, LiveStartupProfileArgs, - LiveStartupProfileCompareArgs, StartupProfileContextEvent, TraceRecordArgs, TraceRecordProfile, - TraceReplayCpuSupport, WindbgCommand, + LiveStartupProfileCompareArgs, LiveStartupProfileReportArgs, StartupProfileContextEvent, + StartupProfileReportFormat, TraceRecordArgs, TraceRecordProfile, TraceReplayCpuSupport, + WindbgCommand, }; use crate::pe_symbols::bounded_pe_file_metadata; @@ -440,7 +442,7 @@ pub(super) fn run_live_startup_profile( print_value(result, output_options) } -const STARTUP_PROFILE_COMPARE_MAX_ARTIFACT_BYTES: u64 = 16 * 1024 * 1024; +const STARTUP_PROFILE_ARTIFACT_MAX_BYTES: u64 = 16 * 1024 * 1024; pub(super) fn run_live_startup_compare( args: LiveStartupProfileCompareArgs, @@ -488,6 +490,78 @@ pub(super) fn run_live_startup_compare( ) } +#[derive(Debug, Clone)] +struct StartupProfileReportFilters { + run: u32, + module_substring: Option, + runtime_only: bool, + rdm_only: bool, + min_resumed_ms: Option, + max_rows: usize, +} + +pub(super) fn run_live_startup_report( + args: LiveStartupProfileReportArgs, + output_options: &OutputOptions, +) -> anyhow::Result<()> { + if matches!(args.format, StartupProfileReportFormat::Table) { + ensure!( + output_options.field.is_none() && !output_options.raw && !output_options.envelope, + "--field, --raw, and --envelope require --format json for live startup-report" + ); + } + if let Some(path) = args.output.as_deref() { + ensure!( + !path.exists(), + "refusing to overwrite startup-report artifact {}", + path.display() + ); + let parent = path + .parent() + .context("startup-report artifact path must have a parent directory")?; + ensure!( + parent.is_dir(), + "startup-report artifact directory does not exist: {}", + parent.display() + ); + } + + let artifact = read_startup_profile_artifact(&args.artifact, "report source")?; + let filters = StartupProfileReportFilters { + run: args.run, + module_substring: args.module, + runtime_only: args.runtime_only, + rdm_only: args.rdm_only, + min_resumed_ms: args.min_resumed_ms, + max_rows: args.max_rows as usize, + }; + let mut report = startup_profile_module_report(&artifact, &filters)?; + if let Some(path) = args.output { + fs::write(&path, serde_json::to_vec_pretty(&report)?) + .with_context(|| format!("writing startup-report artifact {}", path.display()))?; + report["report_artifact"] = json!({ + "path": path, + "format": "pretty_json", + "written": true + }); + } + + match args.format { + StartupProfileReportFormat::Json => print_value(report, output_options), + StartupProfileReportFormat::Table => { + print!( + "{}", + startup_profile_module_report_table( + &report, + args.path_width as usize, + !args.no_summary + ) + ); + Ok(()) + } + } +} + struct StartupProfileArtifact { value: Value, summary: Value, @@ -505,10 +579,10 @@ fn read_startup_profile_artifact( path.display() ); ensure!( - metadata.len() <= STARTUP_PROFILE_COMPARE_MAX_ARTIFACT_BYTES, - "{role} startup-profile artifact is {} bytes, exceeding the {}-byte comparison limit", + metadata.len() <= STARTUP_PROFILE_ARTIFACT_MAX_BYTES, + "{role} startup-profile artifact is {} bytes, exceeding the {}-byte artifact limit", metadata.len(), - STARTUP_PROFILE_COMPARE_MAX_ARTIFACT_BYTES + STARTUP_PROFILE_ARTIFACT_MAX_BYTES ); let contents = fs::read_to_string(path) .with_context(|| format!("reading {role} startup-profile artifact {}", path.display()))?; @@ -535,6 +609,508 @@ fn read_startup_profile_artifact( }) } +fn startup_profile_module_report( + artifact: &StartupProfileArtifact, + filters: &StartupProfileReportFilters, +) -> anyhow::Result { + let runs = artifact.value["runs"] + .as_array() + .context("startup-profile artifact has no runs array")?; + let run = runs + .iter() + .find(|candidate| candidate["run"].as_u64() == Some(u64::from(filters.run))) + .with_context(|| { + format!( + "startup-profile artifact does not contain recorded run {}", + filters.run + ) + })?; + let timeline = run["timeline"] + .as_array() + .with_context(|| format!("startup-profile run {} has no timeline array", filters.run))?; + let module_parameters = startup_profile_report_module_parameters(run); + let provenance = startup_profile_report_module_provenance(run); + let phase_module = run["coverage"]["phase_module"].as_str(); + let mut first_seen = BTreeSet::new(); + let mut prior_first_module_resumed_ms = None; + let mut all_rows = Vec::new(); + let mut module_load_events_without_module_identity = 0usize; + + for event in timeline + .iter() + .filter(|event| event["kind"].as_str() == Some("load_module")) + { + let Some(module) = event["module"].as_object() else { + module_load_events_without_module_identity += 1; + continue; + }; + let Some(identity) = startup_profile_report_module_identity(module) else { + module_load_events_without_module_identity += 1; + continue; + }; + if !first_seen.insert(identity.to_ascii_lowercase()) { + continue; + } + + let resumed_wall_elapsed_ms = event["resumed_wall_elapsed_ms"].as_u64(); + let delta_from_prior_first_module_load_resumed_wall_ms = + match (prior_first_module_resumed_ms, resumed_wall_elapsed_ms) { + (Some(prior), Some(current)) => current.checked_sub(prior), + _ => None, + }; + prior_first_module_resumed_ms = resumed_wall_elapsed_ms; + + let base_address = module + .get("base_address") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let module_parameters_record = base_address + .as_deref() + .and_then(|address| module_parameters.get(&address.to_ascii_lowercase())) + .cloned(); + let image_path = module + .get("image_path") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let provenance_record = image_path + .as_deref() + .and_then(|path| { + provenance + .records_by_normalized_path + .get(&path.to_ascii_lowercase()) + }) + .cloned(); + let classifications = startup_profile_report_module_classifications(module, phase_module); + let parameters = module_parameters_record + .as_ref() + .and_then(|record| record.get("parameters")) + .cloned(); + let symbol_type_name = parameters + .as_ref() + .and_then(|parameters| parameters.get("symbol_type_name")) + .cloned(); + + all_rows.push(json!({ + "ordinal": all_rows.len() + 1, + "event_index": event["index"], + "observed_elapsed_ms": event["observed_elapsed_ms"], + "resumed_wall_elapsed_ms": resumed_wall_elapsed_ms, + "delta_from_prior_first_module_load_resumed_wall_ms": + delta_from_prior_first_module_load_resumed_wall_ms, + "classification": classifications, + "module": Value::Object(module.clone()), + "base_address": base_address, + "image_size_bytes": parameters + .as_ref() + .and_then(|parameters| parameters.get("image_size")) + .cloned(), + "symbol_readiness": { + "source": run["dbgeng_module_parameters"]["source"], + "status": if parameters.is_some() { + Value::String("captured".to_string()) + } else { + run["dbgeng_module_parameters"]["status"].clone() + }, + "symbol_type_name": symbol_type_name + }, + "module_parameters": parameters, + "provenance": provenance_record.unwrap_or_else(|| json!({ + "source": run["module_provenance"]["source"], + "status": run["module_provenance"]["status"], + "detail": run["module_provenance"]["detail"] + })) + })); + } + + let matching_rows = all_rows + .iter() + .filter(|row| startup_profile_report_row_matches(row, filters)) + .cloned() + .collect::>(); + let matching_row_count = matching_rows.len(); + let rows_truncated = matching_row_count > filters.max_rows; + let rows = matching_rows + .into_iter() + .take(filters.max_rows) + .collect::>(); + let process_exit = timeline + .iter() + .find(|event| event["kind"].as_str() == Some("exit_process")) + .map(|event| { + json!({ + "index": event["index"], + "observed_elapsed_ms": event["observed_elapsed_ms"], + "resumed_wall_elapsed_ms": event["resumed_wall_elapsed_ms"], + "exit_code": event["event"]["exit_code"] + }) + }); + + Ok(json!({ + "workflow": "live_startup_profile_report", + "offline": { + "status": "offline_artifact_processing", + "target_or_debugger_interaction": false, + "detail": "This report reads only the explicitly supplied bounded startup-profile JSON artifact. It does not launch, attach, query, or modify a target or debugger, and it does not read observed module paths from the host." + }, + "source_of_truth": { + "artifact": artifact.summary, + "detail": "The input live_startup_profile JSON remains the source of truth. This report is a bounded presentation and filter layer over its retained lifecycle events and enrichment records." + }, + "run": { + "number": filters.run, + "status": run["status"], + "finish_reason": run["finish_reason"], + "completion": run["completion"], + "process_exit": process_exit, + "coverage": { + "timeline_events_returned": run["coverage"]["timeline_events_returned"], + "timeline_event_limit": run["coverage"]["timeline_event_limit"], + "timeline_truncated": run["coverage"]["timeline_truncated"], + "event_limit_reached": run["coverage"]["event_limit_reached"], + "truncation_behavior": run["coverage"]["truncation_behavior"], + "module_load_events": run["counts"]["module_load_events"], + "module_load_events_without_module_identity": module_load_events_without_module_identity + } + }, + "filters": { + "module_substring": filters.module_substring, + "runtime_only": filters.runtime_only, + "rdm_only": filters.rdm_only, + "min_resumed_ms": filters.min_resumed_ms, + "max_rows": filters.max_rows + }, + "module_classification": { + "runtime_loader": "The observed basename, DbgEng module name, or normalized image path matches coreclr.dll, hostfxr.dll, hostpolicy.dll, clrjit.dll, or mscoree.dll.", + "rdm_application_path": "The normalized DbgEng-observed image path contains /RemoteDesktopManager/. This is a path label, not a managed assembly, CPU, or ownership claim.", + "selected_phase_module": "The observed module identity matches the profile's requested phase module.", + "other_module": "No runtime, RDM-path, or selected-phase classification matched." + }, + "module_timeline": { + "first_observed_module_load_count": all_rows.len(), + "matching_row_count": matching_row_count, + "rows_returned": rows.len(), + "truncated_by_max_rows": rows_truncated, + "delta_semantics": "delta_from_prior_first_module_load_resumed_wall_ms is target-resumed host wall time between retained first-observed module-load events before report filtering. It is not CPU, file-I/O, loader-internal, JIT, or managed-method duration.", + "rows": rows + }, + "summary": { + "first_coreclr_load": run["lifecycle_summary"]["modules"]["first_coreclr_load"], + "first_selected_phase_module_load": run["lifecycle_summary"]["modules"]["first_selected_phase_module_load"], + "largest_observed_gaps": run["largest_observed_gaps"], + "detail": "Milestones and ranked gaps are copied from the artifact's existing lifecycle summary and gap ranking. They remain DbgEng host-observed wall-time evidence only." + }, + "measurement_semantics": artifact.value["measurement_semantics"], + "limitations": [ + "Module timestamps are host-observed when DbgEng returned lifecycle control to windbg-tool; they are not target instruction timestamps.", + "Observed or resumed wall-time values and module-to-module deltas are not CPU time, file-I/O duration, JIT duration, managed method timing, UI readiness, or causal attribution.", + "Image size and symbol readiness appear only when the source profile requested bounded DbgEng module-parameter enrichment. Provenance appears only when it requested bounded host metadata for DbgEng-observed absolute paths.", + "A truncated source timeline can omit lifecycle events. The report preserves source coverage rather than filling or inferring missing module loads." + ] + })) +} + +struct StartupProfileReportProvenance { + records_by_normalized_path: BTreeMap, +} + +fn startup_profile_report_module_parameters(run: &Value) -> BTreeMap { + run["dbgeng_module_parameters"]["records"] + .as_array() + .into_iter() + .flatten() + .filter_map(|record| { + let base = record["base_address"].as_str()?; + Some((base.to_ascii_lowercase(), record.clone())) + }) + .collect() +} + +fn startup_profile_report_module_provenance(run: &Value) -> StartupProfileReportProvenance { + let records_by_normalized_path = run["module_provenance"]["records"] + .as_array() + .into_iter() + .flatten() + .filter_map(|record| { + let path = record["observed_image_path"].as_str()?; + Some(( + normalize_startup_profile_path(path).to_ascii_lowercase(), + record.clone(), + )) + }) + .collect(); + StartupProfileReportProvenance { + records_by_normalized_path, + } +} + +fn startup_profile_report_module_identity( + module: &serde_json::Map, +) -> Option { + ["basename", "module_name", "image_path"] + .into_iter() + .find_map(|field| { + module + .get(field) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) +} + +fn startup_profile_report_module_classifications( + module: &serde_json::Map, + phase_module: Option<&str>, +) -> Vec<&'static str> { + let candidates = ["basename", "module_name", "image_path"] + .into_iter() + .filter_map(|field| module.get(field).and_then(Value::as_str)) + .collect::>(); + let mut classifications = Vec::with_capacity(3); + if startup_profile_is_runtime_loader_module(&candidates) { + classifications.push("runtime_loader"); + } + if module + .get("image_path") + .and_then(Value::as_str) + .is_some_and(|path| { + normalize_startup_profile_path(path) + .to_ascii_lowercase() + .contains("/remotedesktopmanager/") + }) + { + classifications.push("rdm_application_path"); + } + if phase_module.is_some_and(|phase_module| { + candidates + .iter() + .any(|candidate| startup_profile_module_name_matches(candidate, phase_module)) + }) { + classifications.push("selected_phase_module"); + } + if classifications.is_empty() { + classifications.push("other_module"); + } + classifications +} + +fn startup_profile_report_row_matches(row: &Value, filters: &StartupProfileReportFilters) -> bool { + let classifications = row["classification"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + if filters.runtime_only && !classifications.contains(&"runtime_loader") { + return false; + } + if filters.rdm_only && !classifications.contains(&"rdm_application_path") { + return false; + } + if let Some(minimum) = filters.min_resumed_ms { + match row["resumed_wall_elapsed_ms"].as_u64() { + Some(elapsed_ms) if elapsed_ms >= minimum => {} + _ => return false, + } + } + let Some(filter) = filters.module_substring.as_deref() else { + return true; + }; + let filter = filter.to_ascii_lowercase(); + ["basename", "module_name", "image_path"] + .into_iter() + .filter_map(|field| row["module"][field].as_str()) + .any(|candidate| candidate.to_ascii_lowercase().contains(&filter)) +} + +fn startup_profile_module_report_table( + report: &Value, + path_width: usize, + include_summary: bool, +) -> String { + let mut table = String::new(); + let source = &report["source_of_truth"]["artifact"]; + let run = &report["run"]; + let coverage = &run["coverage"]; + let completion = &run["completion"]; + let _ = writeln!(table, "Startup module timeline (offline artifact report)"); + let _ = writeln!( + table, + "Source: {} ({} bytes)", + report_table_cell(&source["path"], 120), + report_table_cell(&source["artifact_bytes"], 20) + ); + let _ = writeln!( + table, + "Run: {} | status: {} | finish: {} | completion: {}", + report_table_cell(&run["number"], 8), + report_table_cell(&run["status"], 20), + report_table_cell(&run["finish_reason"], 32), + report_table_cell(&completion["status"], 32) + ); + let _ = writeln!( + table, + "Coverage: events {}/{} | source timeline truncated: {} | event limit reached: {}", + report_table_cell(&coverage["timeline_events_returned"], 12), + report_table_cell(&coverage["timeline_event_limit"], 12), + report_table_cell(&coverage["timeline_truncated"], 8), + report_table_cell(&coverage["event_limit_reached"], 8) + ); + let process_exit = &run["process_exit"]; + if process_exit.is_object() { + let _ = writeln!( + table, + "Process exit: code {} at observed/resumed {} / {} ms", + report_table_cell(&process_exit["exit_code"], 12), + report_table_cell(&process_exit["observed_elapsed_ms"], 12), + report_table_cell(&process_exit["resumed_wall_elapsed_ms"], 12) + ); + } else { + let _ = writeln!(table, "Process exit: not retained or not observed"); + } + let _ = writeln!( + table, + "Time semantics: DbgEng host-observed lifecycle times; not CPU, file-I/O, JIT, managed-method, or UI-ready duration." + ); + let _ = writeln!( + table, + "\n # | OBS/RES ms | +FIRST ms | CATEGORY | MODULE | BASE | IMAGE BYTES | SYMBOLS | NORMALIZED PATH" + ); + let _ = writeln!( + table, + "----+----------------+-----------+--------------------------+----------------------------------+--------------------+-------------+--------------+{}", + "-".repeat(path_width.saturating_add(1)) + ); + for row in report["module_timeline"]["rows"] + .as_array() + .into_iter() + .flatten() + { + let classifications = row["classification"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>() + .join(","); + let observed_resumed = format!( + "{}/{}", + report_table_cell(&row["observed_elapsed_ms"], 10), + report_table_cell(&row["resumed_wall_elapsed_ms"], 10) + ); + let symbol = row["symbol_readiness"]["symbol_type_name"] + .as_str() + .or_else(|| row["symbol_readiness"]["status"].as_str()) + .unwrap_or("-"); + let _ = writeln!( + table, + "{:>3} | {:<14} | {:>9} | {:<24} | {:<32} | {:<18} | {:>11} | {:<12} | {}", + report_table_cell(&row["ordinal"], 3), + report_table_cell_string(&observed_resumed, 14), + report_table_cell( + &row["delta_from_prior_first_module_load_resumed_wall_ms"], + 9 + ), + report_table_cell_string(&classifications, 24), + report_table_cell(&row["module"]["basename"], 32), + report_table_cell(&row["base_address"], 18), + report_table_cell(&row["image_size_bytes"], 11), + report_table_cell_string(symbol, 12), + report_table_cell(&row["module"]["image_path"], path_width) + ); + } + let _ = writeln!( + table, + "Rows: {} returned, {} matching before row bound, truncated by --max-rows: {}", + report_table_cell(&report["module_timeline"]["rows_returned"], 12), + report_table_cell(&report["module_timeline"]["matching_row_count"], 12), + report_table_cell(&report["module_timeline"]["truncated_by_max_rows"], 8) + ); + + if include_summary { + let summary = &report["summary"]; + let _ = writeln!(table, "\nMilestones copied from source artifact"); + let _ = writeln!( + table, + " first coreclr: {}", + report_table_event(&summary["first_coreclr_load"]) + ); + let _ = writeln!( + table, + " first selected phase module: {}", + report_table_event(&summary["first_selected_phase_module_load"]) + ); + let _ = writeln!( + table, + "\nLargest retained observed lifecycle gaps (source ranking)" + ); + let _ = writeln!(table, " RANK | WALL ms | FROM -> TO"); + let _ = writeln!( + table, + "------+---------+----------------------------------------------------------" + ); + for gap in summary["largest_observed_gaps"] + .as_array() + .into_iter() + .flatten() + { + let _ = writeln!( + table, + " {:>4} | {:>7} | {} -> {}", + report_table_cell(&gap["rank"], 4), + report_table_cell(&gap["elapsed_ms"], 7), + report_table_event(&gap["start"]), + report_table_event(&gap["end"]) + ); + } + } + table +} + +fn report_table_event(event: &Value) -> String { + if event.is_null() { + return "-".to_string(); + } + let label = event["module"]["basename"] + .as_str() + .or_else(|| event["kind"].as_str()) + .unwrap_or("-"); + format!( + "{} @ {} ms", + report_table_cell_string(label, 36), + report_table_cell(&event["resumed_wall_elapsed_ms"], 12) + ) +} + +fn report_table_cell(value: &Value, width: usize) -> String { + match value { + Value::Null => "-".to_string(), + Value::String(text) => report_table_cell_string(text, width), + Value::Number(number) => report_table_cell_string(&number.to_string(), width), + Value::Bool(value) => report_table_cell_string(&value.to_string(), width), + _ => "-".to_string(), + } +} + +fn report_table_cell_string(value: &str, width: usize) -> String { + let sanitized = value + .chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect::(); + if sanitized.chars().count() <= width { + return sanitized; + } + let prefix_length = width.saturating_sub(3); + format!( + "{}...", + sanitized.chars().take(prefix_length).collect::() + ) +} + fn startup_profile_completed_artifact_runs(artifact: &Value) -> Vec<&Value> { artifact["runs"] .as_array() @@ -1922,26 +2498,15 @@ fn startup_profile_module_classification( module.basename.as_deref(), module.module_name.as_deref(), module.image_path.as_deref(), - ]; - if [ - "coreclr.dll", - "hostfxr.dll", - "hostpolicy.dll", - "clrjit.dll", - "mscoree.dll", ] - .iter() - .any(|runtime_module| { - candidates - .into_iter() - .flatten() - .any(|candidate| startup_profile_module_name_matches(candidate, runtime_module)) - }) { + .into_iter() + .flatten() + .collect::>(); + if startup_profile_is_runtime_loader_module(&candidates) { "runtime_loader" } else if phase_module.is_some_and(|phase_module| { candidates .into_iter() - .flatten() .any(|candidate| startup_profile_module_name_matches(candidate, phase_module)) }) { "selected_phase_module" @@ -1950,6 +2515,22 @@ fn startup_profile_module_classification( } } +fn startup_profile_is_runtime_loader_module(candidates: &[&str]) -> bool { + [ + "coreclr.dll", + "hostfxr.dll", + "hostpolicy.dll", + "clrjit.dll", + "mscoree.dll", + ] + .iter() + .any(|runtime_module| { + candidates + .iter() + .any(|candidate| startup_profile_module_name_matches(candidate, runtime_module)) + }) +} + fn rank_startup_profile_observed_gaps( timeline: &[StartupProfileEvent], tail_filter_started_after_event_index: Option, @@ -3979,6 +4560,7 @@ pub(super) fn live_capabilities() -> Value { "live launch --command-line --end detach|terminate", "live startup-break --command-line --initial-break|--address |--module --module-offset |--symbol ", "live startup-profile --command-line [--runs ] [--phase-module ] [--completion-module [--settle-ms ]]", + "live startup-report [--run ] [--format table|json]", "live start --command-line ", "live attach --process-id ", "dump create --process-id --output ", @@ -4003,6 +4585,11 @@ pub(super) fn live_capabilities() -> Value { "status": "non_invasive_bounded_lifecycle_collection", "notes": "Launches at a create-process event, configures DbgEng lifecycle event filters only, and reports host-monotonic wall-time observations. A requested completion module can stop at an observed image load or after a bounded observed lifecycle-quiet interval. It sets no software/hardware breakpoint, opens no DAC, and performs no target-memory operation." }, + { + "feature": "startup profile artifact report", + "status": "offline_bounded_module_timeline", + "notes": "Reads one explicit live startup-profile JSON artifact up to 16 MiB and renders a bounded table or structured report. It does not launch, attach, query, or modify DbgEng or a target." + }, { "feature": "managed method breakpoint workflow", "status": "x64_coreclr_dac_vertical_slice", @@ -4983,6 +5570,206 @@ mod tests { ); } + #[test] + fn startup_profile_report_preserves_first_seen_module_timing_and_enrichment() { + let artifact = StartupProfileArtifact { + summary: json!({ + "role": "test", + "path": "C:/reports/fixture.json", + "artifact_bytes": 1024 + }), + value: json!({ + "workflow": "live_startup_profile", + "measurement_semantics": { + "clock": "host_monotonic_instant" + }, + "runs": [{ + "run": 1, + "status": "completed", + "finish_reason": "exit_process", + "completion": { + "status": "not_requested" + }, + "coverage": { + "phase_module": "RemoteDesktopManager.dll", + "timeline_events_returned": 6, + "timeline_event_limit": 16, + "timeline_truncated": false, + "event_limit_reached": false, + "truncation_behavior": "test" + }, + "counts": { + "module_load_events": 4 + }, + "timeline": [ + { + "index": 0, + "kind": "create_process", + "observed_elapsed_ms": 10, + "resumed_wall_elapsed_ms": 0, + "event": { "exit_code": null } + }, + { + "index": 1, + "kind": "load_module", + "observed_elapsed_ms": 15, + "resumed_wall_elapsed_ms": 5, + "module": { + "basename": "ntdll.dll", + "module_name": "ntdll", + "image_path": "ntdll.dll", + "base_address": "0x100" + } + }, + { + "index": 2, + "kind": "load_module", + "observed_elapsed_ms": 48, + "resumed_wall_elapsed_ms": 38, + "module": { + "basename": "coreclr.dll", + "module_name": "coreclr", + "image_path": "C:/dotnet/coreclr.dll", + "base_address": "0x200" + } + }, + { + "index": 3, + "kind": "load_module", + "observed_elapsed_ms": 51, + "resumed_wall_elapsed_ms": 41, + "module": { + "basename": "coreclr.dll", + "module_name": "coreclr", + "image_path": "C:/dotnet/coreclr.dll", + "base_address": "0x200" + } + }, + { + "index": 4, + "kind": "load_module", + "observed_elapsed_ms": 90, + "resumed_wall_elapsed_ms": 80, + "module": { + "basename": "RemoteDesktopManager.dll", + "module_name": "RemoteDesktopManager", + "image_path": "D:/RDM/RemoteDesktopManager/Program/RemoteDesktopManager.dll", + "base_address": "0x300" + } + }, + { + "index": 5, + "kind": "exit_process", + "observed_elapsed_ms": 100, + "resumed_wall_elapsed_ms": 90, + "event": { "exit_code": 0 } + } + ], + "dbgeng_module_parameters": { + "source": "dbgeng_idebugsymbols5_getmoduleparameters", + "status": "captured", + "records": [{ + "base_address": "0x200", + "parameters": { + "image_size": 4096, + "symbol_type_name": "pdb" + } + }] + }, + "module_provenance": { + "source": "host_file_metadata", + "status": "captured", + "records": [{ + "observed_image_path": "D:/RDM/RemoteDesktopManager/Program/RemoteDesktopManager.dll", + "status": "captured", + "metadata": { "file_size": 1024 } + }] + }, + "lifecycle_summary": { + "modules": { + "first_coreclr_load": { + "kind": "load_module", + "resumed_wall_elapsed_ms": 38, + "module": { "basename": "coreclr.dll" } + }, + "first_selected_phase_module_load": { + "kind": "load_module", + "resumed_wall_elapsed_ms": 80, + "module": { "basename": "RemoteDesktopManager.dll" } + } + } + }, + "largest_observed_gaps": [{ + "rank": 1, + "elapsed_ms": 42, + "start": { + "kind": "load_module", + "resumed_wall_elapsed_ms": 38, + "module": { "basename": "coreclr.dll" } + }, + "end": { + "kind": "load_module", + "resumed_wall_elapsed_ms": 80, + "module": { "basename": "RemoteDesktopManager.dll" } + } + }] + }] + }), + }; + let all_modules = startup_profile_module_report( + &artifact, + &StartupProfileReportFilters { + run: 1, + module_substring: None, + runtime_only: false, + rdm_only: false, + min_resumed_ms: None, + max_rows: 8, + }, + ) + .unwrap(); + + let rows = all_modules["module_timeline"]["rows"].as_array().unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[1]["module"]["basename"], "coreclr.dll"); + assert_eq!( + rows[1]["delta_from_prior_first_module_load_resumed_wall_ms"], + 33 + ); + assert_eq!(rows[1]["image_size_bytes"], 4096); + assert_eq!(rows[1]["symbol_readiness"]["symbol_type_name"], "pdb"); + assert_eq!( + rows[2]["delta_from_prior_first_module_load_resumed_wall_ms"], + 42 + ); + assert_eq!( + rows[2]["classification"], + json!(["rdm_application_path", "selected_phase_module"]) + ); + assert_eq!(rows[2]["provenance"]["metadata"]["file_size"], 1024); + assert_eq!(all_modules["run"]["process_exit"]["exit_code"], 0); + + let rdm_modules = startup_profile_module_report( + &artifact, + &StartupProfileReportFilters { + run: 1, + module_substring: None, + runtime_only: false, + rdm_only: true, + min_resumed_ms: Some(50), + max_rows: 1, + }, + ) + .unwrap(); + assert_eq!(rdm_modules["module_timeline"]["matching_row_count"], 1); + assert_eq!(rdm_modules["module_timeline"]["rows"][0]["ordinal"], 3); + + let table = startup_profile_module_report_table(&all_modules, 32, true); + assert!(table.contains("Startup module timeline (offline artifact report)")); + assert!(table.contains("coreclr.dll")); + assert!(table.contains("Largest retained observed lifecycle gaps")); + } + #[test] fn startup_profile_ranks_only_full_filter_observed_gaps() { let timeline = vec![ diff --git a/docs/cli.md b/docs/cli.md index 0e76343..e08e2fb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -326,6 +326,37 @@ target\debug\windbg-tool.exe --compact live startup-compare ` The comparator accepts only `live_startup_profile` JSON artifacts up to 16 MiB. It compares observed phase and one-largest-gap wall-time distributions plus bounded lifecycle event/module/exception sequence prefixes. It reports profile and comparison truncation, omits unstable thread IDs, and never labels a difference as a CPU regression or causal explanation. +### Offline startup module timeline + +`live startup-report ` reads one existing `live_startup_profile` JSON artifact and renders its first-observed module loads as a deterministic terminal table. It is strictly offline: it reads only the explicitly supplied regular JSON artifact (up to 16 MiB), does not read the observed module paths, and never launches, attaches, queries, or modifies a target or DbgEng session. The profile artifact remains the source of truth. + +```powershell +$artifact = Join-Path $env:TEMP 'windbg-tool-rdm-thread-accounting.json' +target\debug\windbg-tool.exe live startup-report $artifact --run 1 +``` + +Each row represents the first retained DbgEng `load_module` event for one module identity in the selected launch. `OBS/RES ms` contains the launch-relative host-observed and target-resumed host-wall timestamps. `+FIRST ms` is the target-resumed host wall-time delta from the prior first-observed module load before report filters are applied. Neither is CPU, file-I/O, JIT, managed-method, or UI-ready duration. + +Use bounded local filters without reopening the artifact in a debugger: + +```powershell +# A first-party RDM application-directory view after the runtime has started. +target\debug\windbg-tool.exe live startup-report $artifact ` + --rdm-only --min-resumed-ms 500 --max-rows 32 + +# Runtime-loader images, retaining the normal terminal summary. +target\debug\windbg-tool.exe live startup-report $artifact --runtime-only + +# Full structured rows, including unclipped normalized paths and any captured enrichment. +$report = Join-Path $env:TEMP 'windbg-tool-rdm-module-report.json' +target\debug\windbg-tool.exe --compact live startup-report $artifact ` + --format json --output $report --max-rows 128 +``` + +`--module ` matches a module basename, normalized observed image path, or DbgEng module name case-insensitively. `--runtime-only` selects the transparent runtime/loader name set (`coreclr.dll`, `hostfxr.dll`, `hostpolicy.dll`, `clrjit.dll`, `mscoree.dll`); `--rdm-only` selects normalized observed image paths containing `/RemoteDesktopManager/`. They are labels over observed module identities, not ownership, managed-assembly, CPU, or execution claims. `--run` defaults to run 1 so timestamps from repeated launches are never combined; `--max-rows` defaults to 64 and reports truncation explicitly. + +The JSON form includes the table rows, full paths, source artifact/run coverage, completion and process-exit status, copied first-CoreCLR/selected-module milestones, and the artifact's existing ranked gaps. A row includes image size and symbol readiness only when the source profile captured bounded DbgEng module parameters, and host-file provenance only when that source profile captured it. There is no separate MCP tool because this is a local, explicit-file CLI report; agents can use `--format json` or `--output` for the same stable structured data without a target session. + For a daemon-owned stopped target, `target thread-accounting --target --max-threads 32` and MCP `target_thread_accounting` return the same bounded read-only per-thread accounting. `target module-parameters --target --module-base
` / MCP `target_module_parameters` accept only 1 through 128 distinct supplied module bases. `target symbol-entry-range --target --address
` / MCP `target_symbol_entry_range` return a bounded DbgEng symbol-entry region record for one existing native address. The module and symbol queries are not target writes, but their configured symbol path can produce host-side symbol-resolution I/O. For a future RDM observation, use the bounded no-breakpoint form only after the fixture command has completed with the expected no-write report: @@ -544,7 +575,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`, `live startup-profile`, `dump create`, `dump open`, `dump inspect`, `target event`, `target thread`, `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`, `live startup-profile`, `live startup-report`, `dump create`, `dump open`, `dump inspect`, `target event`, `target thread`, `target dump`, `windbg status` | ## Useful non-replay commands From b5d982e0b72397973ade586c903ec0b7197a8a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Jul 2026 15:02:00 -0400 Subject: [PATCH 12/12] Fix daemon HTTP response reading Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/windbg-ttd/src/daemon.rs | 112 ++++++++++++++++++++++++++------ 1 file changed, 91 insertions(+), 21 deletions(-) diff --git a/crates/windbg-ttd/src/daemon.rs b/crates/windbg-ttd/src/daemon.rs index 2c3a037..33093a9 100644 --- a/crates/windbg-ttd/src/daemon.rs +++ b/crates/windbg-ttd/src/daemon.rs @@ -159,6 +159,7 @@ mod windows { use tokio::sync::{oneshot, Mutex}; const MAX_REQUEST_BYTES: usize = 1024 * 1024; + const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024; const CONNECT_ATTEMPTS: usize = 40; const CONNECT_RETRY_DELAY: Duration = Duration::from_millis(50); @@ -355,13 +356,42 @@ mod windows { } pipe.flush().await.context("flushing daemon request")?; - let mut response = Vec::new(); - pipe.read_to_end(&mut response) - .await - .context("reading daemon response")?; + let response = read_response(&mut pipe).await?; parse_http_response(&response) } + async fn read_response( + pipe: &mut tokio::net::windows::named_pipe::NamedPipeClient, + ) -> anyhow::Result> { + let mut response = Vec::new(); + let mut buffer = vec![0_u8; 4096]; + + loop { + let bytes_read = pipe + .read(&mut buffer) + .await + .context("reading daemon response")?; + if bytes_read == 0 { + return Ok(response); + } + + response.extend_from_slice(&buffer[..bytes_read]); + if response.len() > MAX_RESPONSE_BYTES { + bail!("daemon response exceeds {MAX_RESPONSE_BYTES} bytes"); + } + + if let Some(response_end) = response_body_end(&response)? { + if response_end > MAX_RESPONSE_BYTES { + bail!("daemon response exceeds {MAX_RESPONSE_BYTES} bytes"); + } + if response.len() >= response_end { + response.truncate(response_end); + return Ok(response); + } + } + } + } + async fn open_client( pipe_name: &str, ) -> anyhow::Result { @@ -401,23 +431,7 @@ mod windows { .parse::() .context("daemon response status code was invalid")?; - let mut content_length = None; - for line in lines { - if let Some((name, value)) = line.split_once(':') { - if name.eq_ignore_ascii_case("content-length") { - content_length = Some( - value - .trim() - .parse::() - .context("daemon response content-length was invalid")?, - ); - } - } - } - - let body_end = content_length - .map(|length| header_end + length) - .unwrap_or(bytes.len()); + let body_end = response_body_end(bytes)?.unwrap_or(bytes.len()); if body_end > bytes.len() { bail!("daemon response ended before the declared content-length"); } @@ -431,4 +445,60 @@ mod windows { } Ok(value) } + + fn response_body_end(bytes: &[u8]) -> anyhow::Result> { + let Some(header_end) = bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) + else { + return Ok(None); + }; + + let headers = std::str::from_utf8(&bytes[..header_end]) + .context("daemon response headers were not UTF-8")?; + let content_length = headers + .split("\r\n") + .filter_map(|line| line.split_once(':')) + .find_map(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then_some(value.trim()) + }) + .map(|value| { + value + .parse::() + .context("daemon response content-length was invalid") + }) + .transpose()?; + + content_length + .map(|length| { + header_end + .checked_add(length) + .context("daemon response content-length overflowed") + }) + .transpose() + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn response_body_end_waits_for_complete_headers() { + assert_eq!(response_body_end(b"HTTP/1.1 200 OK\r\n").unwrap(), None); + } + + #[test] + fn response_body_end_uses_content_length() { + let response = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}ignored"; + assert_eq!(response_body_end(response).unwrap(), Some(40)); + } + + #[test] + fn parse_http_response_ignores_bytes_after_content_length() { + let response = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}ignored"; + assert_eq!(parse_http_response(response).unwrap(), json!({})); + } + } }