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/Cargo.lock b/Cargo.lock index 26b205d..4e3a7b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1791,8 +1791,10 @@ name = "windbg-dbgeng" version = "0.1.0" dependencies = [ "anyhow", + "libloading", "serde", "windows", + "windows-core 0.58.0", ] [[package]] diff --git a/crates/windbg-dbgeng/Cargo.toml b/crates/windbg-dbgeng/Cargo.toml index db52ddb..01e7837 100644 --- a/crates/windbg-dbgeng/Cargo.toml +++ b/crates/windbg-dbgeng/Cargo.toml @@ -9,15 +9,19 @@ anyhow.workspace = true serde.workspace = true [target.'cfg(windows)'.dependencies] +libloading.workspace = true +windows-core = "0.58" windows = { workspace = true, features = [ "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_Debug_Extensions", "Win32_System_Kernel", + "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_ProcessStatus", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", + "implement", ] } diff --git a/crates/windbg-dbgeng/src/coreclr_dac.rs b/crates/windbg-dbgeng/src/coreclr_dac.rs new file mode 100644 index 0000000..2a29422 --- /dev/null +++ b/crates/windbg-dbgeng/src/coreclr_dac.rs @@ -0,0 +1,533 @@ +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; +const MAX_SIGNATURE_HEX_CHARS: usize = 1024; +const MAX_METHOD_CANDIDATES: usize = 8; + +#[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 NativeMethodCandidate { + method_token: u32, + signature_truncated: u8, + reserved: [u8; 3], + signature_hex: [u16; MAX_SIGNATURE_HEX_CHARS], +} + +#[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], + 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 { + 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 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, + loaded: *mut u8, +) -> u32; +type ResolveMethod = 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 = + 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 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, +} + +#[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, + disable_module_load_notifications: DisableModuleLoadNotifications, + is_module_loaded: IsModuleLoaded, + resolve_and_notify: ResolveMethod, + resolve_read_only: ResolveMethod, + 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 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 { 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")? + }; + 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, + 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)?, + }) + } + + 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 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; + 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, + 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))?; + 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 { + resolve_method( + self.bridge.as_ptr(), + managed_module_path.as_ptr(), + method.as_ptr(), + signature_blob, + signature_blob_length, + &mut native_method, + ) + }; + self.handle_method_status(status, &native_method, operation)?; + 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: {}. 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)) + } + _ => 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, + matching_method_candidates: native_method_candidates(native)?, + matching_method_candidates_truncated: native.candidates_truncated != 0, + resolved_method: utf16_field(&native.resolved_method, "managed method name")?, + signature_hex: utf16_field( + &native.resolved_signature_hex, + "managed method metadata signature", + )?, + 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 native_method_candidates( + native: &NativeMethodInfo, +) -> 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() + .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 339f7f3..7ed25e1 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -1,15 +1,45 @@ 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)] +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"; 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)] +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)] pub struct StandardSymbolEnvironment { @@ -88,6 +118,189 @@ 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 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, + }; + + 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 { + 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 { + 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 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); + } + } + unreachable!("the DbgEng runtime component list must include dbgeng.dll") + })() + .map_err(|error| format!("{error:#}")) + }); + + match result { + Ok(module) => Ok(HMODULE(*module as *mut _)), + Err(error) => bail!("{error}"), + } +} + +#[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 { + 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)] +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, @@ -110,6 +323,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)] @@ -216,12 +436,103 @@ 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, 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, @@ -231,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, @@ -370,6 +721,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 {} @@ -403,17 +928,92 @@ 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, + 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, }; 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"), } } @@ -426,6 +1026,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; @@ -600,6 +1209,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]; @@ -622,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; @@ -658,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; @@ -902,6 +1874,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, @@ -921,6 +1912,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 { @@ -1234,6 +2247,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, @@ -1288,18 +2305,38 @@ 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; 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, @@ -1320,6 +2357,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(); @@ -1343,20 +2381,25 @@ 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)?; + 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 @@ -1366,11 +2409,12 @@ 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, - )?; } + 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, @@ -1391,21 +2435,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 +2472,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 { @@ -1565,6 +2614,77 @@ 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; + 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, @@ -1572,14 +2692,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, }; @@ -1616,23 +2739,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()))?; @@ -1766,4 +2907,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/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 df5f47a..6a59f88 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -277,6 +277,22 @@ 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 = "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" + )] + 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")] @@ -464,6 +480,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")] @@ -474,6 +498,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( @@ -484,6 +510,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" )] @@ -676,6 +706,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( @@ -694,6 +729,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)] @@ -704,6 +745,349 @@ 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, + 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" + )] + 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, + 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, + 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, + 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" + )] + 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, + 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", + help = "Write the complete JSON result to a new file as well as stdout" + )] + output: Option, + #[arg( + long, + default_value = "detach", + value_parser = ["detach", "terminate"], + help = "Action if the target has not exited at its completion or time bound; terminate is explicit" + )] + 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, 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, + ExitProcess, + CreateThread, + ExitThread, + LoadModule, + UnloadModule, + Exception, +} + +#[derive(Debug, Args)] +struct LiveManagedBreakArgs { + #[arg(long, help = "Full command line to launch under DbgEng")] + command_line: String, + #[arg( + long, + value_name = "MODULE", + help = "Managed assembly module basename, for example RemoteDesktopManager.dll" + )] + managed_module: String, + #[arg( + long, + 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" + )] + 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)] + 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( @@ -818,6 +1202,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)] @@ -1137,6 +1526,45 @@ 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 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")] @@ -3637,6 +4065,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, @@ -3646,7 +4075,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": [ @@ -4149,6 +4578,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, @@ -4418,6 +4848,8 @@ fn inferred_command_metadata(command: &Command, path: &[String]) -> Value { | "live capabilities" | "live launch" | "live startup-break" + | "live startup-profile" + | "live startup-report" | "breakpoint capabilities" | "datamodel capabilities" ); @@ -4489,7 +4921,10 @@ fn discover_manifest() -> Value { "live": [ "live capabilities", "live launch --command-line --end detach|terminate", - "live start --command-line ", + "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 " ], "dump": ["dump open ", "dump inspect ", "dump create --process-id --output "], @@ -4527,6 +4962,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 ", @@ -4913,6 +5349,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"] }, @@ -5156,6 +5593,33 @@ 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", "--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 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, @@ -5163,7 +5627,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", @@ -5232,6 +5696,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, @@ -5475,6 +5949,52 @@ 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_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(), @@ -7803,4 +8323,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 2913c2e..f6c7aac 100644 --- a/crates/windbg-tool/src/cli/dispatch.rs +++ b/crates/windbg-tool/src/cli/dispatch.rs @@ -46,6 +46,10 @@ 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::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, LiveCommand::Capabilities => print_value(platform::live_capabilities(), &output), @@ -313,6 +317,15 @@ 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::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 } @@ -330,6 +343,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 3203d40..6d2170c 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -1,7 +1,11 @@ use anyhow::{bail, ensure, Context}; +use serde::Serialize; 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; use std::path::{Path, PathBuf}; @@ -11,9 +15,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, LiveLaunchEnd, LiveLaunchOptions, LiveLaunchSessionOptions, 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}; @@ -29,9 +34,12 @@ use windows::Win32::System::Threading::{ use super::output::{print_value, OutputOptions}; use super::{ CliDumpKind, DbgEngServerArgs, DumpCreateArgs, DumpInspectArgs, LiveLaunchArgs, - LiveStartupBreakArgs, TraceRecordArgs, TraceRecordProfile, TraceReplayCpuSupport, + LiveManagedBreakArgs, LiveStartupBreakArgs, LiveStartupProfileArgs, + LiveStartupProfileCompareArgs, LiveStartupProfileReportArgs, StartupProfileContextEvent, + StartupProfileReportFormat, TraceRecordArgs, TraceRecordProfile, TraceReplayCpuSupport, WindbgCommand, }; +use crate::pe_symbols::bounded_pe_file_metadata; pub(super) fn run_dbgeng_server( args: DbgEngServerArgs, @@ -73,18 +81,56 @@ 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()?; + 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: if args.hardware_execute { + LiveInitialStop::CreateProcessEvent + } else { + 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, + _ if args.hardware_execute => Some(set_startup_hardware_execute_breakpoint( + &session, + &breakpoint_spec, + )?), _ => Some(set_startup_breakpoint(&session, &breakpoint_spec)?), }; - let continued = requested_breakpoint + let continued_to_breakpoint = requested_breakpoint .is_some() .then(|| session.continue_execution()) .transpose()?; @@ -93,86 +139,3494 @@ 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 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()? + .into_iter() + .find(|breakpoint| breakpoint.id == requested.id), + None => None, + }; + let breakpoint_hit = instruction_offset + .zip( + configured_breakpoint + .as_ref() + .map(|breakpoint| breakpoint.offset), + ) + .is_some_and(|(instruction_offset, breakpoint_offset)| { + event.name.as_deref() == Some("break") + && 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, + "event": event, + "breakpoint": { + "requested": requested_breakpoint, + "configured": configured_breakpoint, + "event": breakpoint_event, + "hit": breakpoint_hit, + "hit_evidence": if breakpoint_hit { + "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 did not report the configured breakpoint ID at its configured instruction pointer" + } + }, + "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"), + } +} + +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 { + 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: Value, + thread_accounting: Value, +} + +#[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 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, + status: String, + finish_reason: String, + target: windbg_dbgeng::DebuggerSessionSummary, + timing: Value, + event_filters: Value, + timeline: Vec, + phase_durations: Vec, + completion: StartupProfileCompletion, + lifecycle_summary: Value, + debuggee_output: Value, + module_provenance: Value, + dbgeng_module_parameters: Value, + largest_observed_gaps: Vec, + gaps_excluded_from_ranking: 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()?; + 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" + ); + 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); + let mut completed_runs_with_process_exit = 0usize; + for run_index in 1..=args.runs { + 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"; + 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 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, + "requested_runs": args.runs, + "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": { + "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.", + "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.", + "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." + ] + }); + 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) +} + +const STARTUP_PROFILE_ARTIFACT_MAX_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, + ) +} + +#[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, +} + +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_ARTIFACT_MAX_BYTES, + "{role} startup-profile artifact is {} bytes, exceeding the {}-byte artifact limit", + metadata.len(), + STARTUP_PROFILE_ARTIFACT_MAX_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_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() + .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, + phase_module: Option<&str>, + completion_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, + completion_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>, + completion_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 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(); + 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 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 = + 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(wait_budget).clamp(1, u64::from(u32::MAX)) as u32; + let wait = session.wait_for_event(wait_timeout_ms)?; + resumed_elapsed += resumed_at.elapsed(); + 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 = 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( + session, + &mut recording, + args, + 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() { + 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, + module_identities, + captured_contexts, + captured_thread_accounting_snapshots, + .. + } = 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(); + 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 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) = + 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" + }; + 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, + "tail_filter_started_after_event_index": tail_filter_started_after_event_index + }), + event_filters, + lifecycle_summary, + debuggee_output, + module_provenance, + dbgeng_module_parameters, + 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, + "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, + "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 { + "All observed lifecycle events were retained." + }, + "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, + "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, + }) +} + +#[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, + captured_thread_accounting_snapshots: u32, + prior_thread_accounting: HashMap<(u32, u32), (usize, windbg_dbgeng::ThreadAccountingEntry)>, +} + +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 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, + 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 = 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, + observed_elapsed_ms: duration_millis(observed_elapsed), + resumed_wall_elapsed_ms: duration_millis(resumed_elapsed), + event, + module, + 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, + 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) => { + 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", + "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 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(), + 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 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, + debuggee_output: &Value, +) -> 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": 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_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, +) -> 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>, +) -> &'static str { + let candidates = [ + module.basename.as_deref(), + module.module_name.as_deref(), + module.image_path.as_deref(), + ] + .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() + .any(|candidate| startup_profile_module_name_matches(candidate, phase_module)) + }) { + "selected_phase_module" + } else { + "other_module" + } +} + +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, +) -> (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>, +) -> 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(); + 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; + if phase.status == "observed" { + if let Some(value) = phase.elapsed_ms { + values_by_phase + .entry(phase.name.clone()) + .or_default() + .push(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() + .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, + "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." + }) +} + +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, +) -> 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"; + + 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")?; + 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 CoreCLR to load")?; + let coreclr_event = session + .last_event() + .context("reading the CoreCLR module-load event")?; + ensure!( + 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: wait={coreclr_wait:?}, event={coreclr_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, args.allow_runtime_write) + .context("initializing the exact-version CoreCLR DAC bridge")?; + dac.enable_module_load_notifications() + .context("requesting CLR managed-module load notifications")?; + + session + .execute_command(&format!("sxe ld:{managed_module}")) + .with_context(|| { + format!("configuring the managed-module load stop for {managed_module}") + })?; + let managed_module_load = wait_for_dbgeng_managed_module_load( + &session, + 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, signature.as_deref()) + .with_context(|| { + format!( + "resolving {method} in the selected managed module {managed_module} through the DAC" + ) + })?; + + let (code_notification, generated_method, clr_notification_pending) = match availability { + ManagedCodeAvailability::Available => ( + None, + resolved_method.clone(), + managed_module_notification_pending, + ), + ManagedCodeAvailability::PendingJit => { + 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")?; + 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, + true, + ) + } + }; + 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 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, + args.wait_timeout_ms, + CLR_NOTIFICATION_EXCEPTION, + clr_notification_pending, + )?; + let registers = session.core_registers()?; + let context = live_stop_context(&session, registers, args.max_frames)?; + Ok(json!({ + "workflow": "live_managed_break_dac", + "command_line": args.command_line, + "initial_event": initial_event, + "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_module_load": { + "managed_module": managed_module, + "dbgeng_load": managed_module_load, + "observation": managed_module_observation, + "notifications_disabled_after_observation": true, + "loaded_module": loaded_managed_module + }, + "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": signature.is_some(), + "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": "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 code breakpoint hit" + } + }, + "context": context, + "limitations": [ + "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 + })) + })(); + + 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 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, +) -> 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"); + 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, + 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 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!( + 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 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_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 configured_breakpoint = match requested_breakpoint.as_ref() { - Some(requested) => session - .list_breakpoints()? - .into_iter() - .find(|breakpoint| breakpoint.id == requested.id), - None => None, - }; - let breakpoint_hit = instruction_offset - .zip( - configured_breakpoint - .as_ref() - .map(|breakpoint| breakpoint.offset), - ) - .is_some_and(|(instruction_offset, breakpoint_offset)| { - event.name.as_deref() == Some("break") && instruction_offset == breakpoint_offset - }); + 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 + })); + } - Ok(json!({ - "workflow": "live_startup_break", - "command_line": args.command_line, - "breakpoint_spec": breakpoint_spec, - "initial_event": initial_event, + 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, - "event": event, - "breakpoint": { - "requested": requested_breakpoint, - "configured": configured_breakpoint, - "hit": breakpoint_hit, - "hit_evidence": if breakpoint_hit { - "current instruction pointer equals the configured breakpoint offset" - } else 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" - } - }, - "context": { - "target": session.summary(), - "registers": registers, - "instruction_pointer": instruction_offset, - "current_module": current_module, - "current_symbol": current_symbol, - "stack": stack - }, - "end": end - })) - })(); + "wait": wait, + "event": event + })); + continue_as_handled = true; + } - 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"), + 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, + managed_module_path: &Path, + wait_timeout_ms: u32, + clr_notification_exception: u32, +) -> anyhow::Result { + 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 + })); + } + continue_as_handled = true; } + + bail!( + "the CLR emitted {MAX_CLR_NOTIFICATIONS} notifications without registering the selected managed module" + ) } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] @@ -221,6 +3675,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, @@ -245,6 +3710,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, @@ -1049,6 +4559,8 @@ 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 ] [--completion-module [--settle-ms ]]", + "live startup-report [--run ] [--format table|json]", "live start --command-line ", "live attach --process-id ", "dump create --process-id --output ", @@ -1068,6 +4580,21 @@ 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. 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", + "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", "status": "dbghelp_minidump_writer", @@ -1082,7 +4609,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": [ @@ -1544,10 +5071,12 @@ 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()), symbol: None, + wait_for_module: None, initial_break_timeout_ms: 5000, wait_timeout_ms: 10000, max_frames: 16, @@ -1579,5 +5108,684 @@ 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] + 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 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!( + 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()); + } + + 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: json!({ + "status": "not_requested", + "detail": "test" + }), + thread_accounting: json!({ + "status": "not_requested", + "detail": "test" + }), + } + } + + #[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(), + 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!({}), + 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" + }), + 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!({}), + coverage: json!({}), + cleanup: json!({}), + } + } + + #[test] + fn startup_profile_aggregate_reports_wall_time_median_without_regression_claim() { + 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]; + + 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"); + 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] + 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" + )); + } + + #[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, + &json!({ + "status": "not_requested", + "records_returned": 0, + "dropped_record_count": 0, + "detail": "test" + }), + ); + + 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_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!( + sequence["pairs"][0]["first_divergence"]["candidate"]["module_basename"], + "hostfxr.dll" + ); + } + + #[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![ + 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/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/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..b7efdf6 --- /dev/null +++ b/crates/windbg-tool/tests/fixtures/ManagedBreakpointFixture/Program.cs @@ -0,0 +1,116 @@ +using System.Runtime.CompilerServices; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Diagnostics; + +namespace ManagedBreakpointFixture; + +public static class Program +{ + public static int Main(string[] args) + { + Console.WriteLine(ManagedTargets.PublicEntry()); + Console.WriteLine(ManagedTargets.InvokePrivateEntry()); + Console.WriteLine(ManagedTargets.Overload("selected")); + EmitRequestedDebugOutput(args); + BurnRequestedCpu(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"; + 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); + } + + 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); +} + +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-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!({})); + } + } } diff --git a/crates/windbg-ttd/src/targets.rs b/crates/windbg-ttd/src/targets.rs index 6e05f59..22f45c3 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; @@ -7,9 +7,11 @@ 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, 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; @@ -29,6 +31,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 +66,38 @@ 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 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, @@ -198,6 +234,32 @@ 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 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, @@ -292,6 +354,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: if request.create_process_stop { + LiveInitialStop::CreateProcessEvent + } else { + LiveInitialStop::SoftwareBreakpoint + }, })?; Ok(self.insert_target(session)) } @@ -409,6 +476,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 { @@ -417,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 { @@ -602,6 +723,49 @@ fn default_target_stack_frames() -> u32 { 32 } +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), + "target memory-map region_limit must be from 1 through {MAX_VIRTUAL_MEMORY_MAP_REGIONS}" + ); + 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 } @@ -621,3 +785,39 @@ 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() + ); + } + + #[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 afd6e7d..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, 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, @@ -215,6 +217,22 @@ 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_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.", @@ -504,6 +522,28 @@ 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_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 7a99ba2..f3a8926 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,13 @@ 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 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, 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 8c8a391..e08e2fb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -90,7 +90,9 @@ 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. Use a module basename and RVA when the executable is present at the initial break: @@ -109,6 +111,325 @@ 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. + +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. 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. + +### 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' ` + --managed-module RemoteDesktopManager.dll ` + --method Devolutions.RemoteDesktopManager.Program.Main ` + --allow-runtime-write ` + --end terminate +``` + +The vertical slice resolves regular private methods as normal metadata. An unambiguous fully-qualified metadata name needs no additional selector. For overloads, pass `--signature` with the exact ECMA-335 `MethodDef` signature blob as hexadecimal byte pairs; whitespace, `-`, `_`, and `:` are accepted only as separators. This is not C# syntax. For example, a static `string Overload(string)` method has the blob `00010E0E` (`DEFAULT`, one parameter, `STRING` return, `STRING` parameter), while `string Overload()` is `00000E`. The response reports the selected `signature_hex`, every bounded name-matching candidate's token/signature, and whether candidate output was truncated. C# signatures, generic instantiations, ReadyToRun indirection, tiered recompilation, re-JIT, and unload transitions remain explicit limits. + +### Managed breakpoint fixture + +`crates\windbg-tool\tests\fixtures\ManagedBreakpointFixture` is a source-only .NET 10 x64 fixture with deterministic no-inline public, private, and overload targets. Its generated `bin` and `obj` directories are ignored. Build it, then run one command per target in an approved test VM: + +```powershell +$fixture = Resolve-Path crates\windbg-tool\tests\fixtures\ManagedBreakpointFixture +dotnet build "$fixture\ManagedBreakpointFixture.csproj" -c Release + +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.PublicEntry ` + --allow-runtime-write --end terminate + +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 ` + --allow-runtime-write --end terminate + +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.Overload ` + --signature 00010E0E ` + --allow-runtime-write --end terminate +``` + +The last command selects `Overload(string)` rather than `Overload()`. A valid result has `managed_breakpoint.hit: true`, a DbgEng breakpoint ID/current instruction pointer equal to `code_generation.representative_native_entry_address`, and `managed_resolution.method_after_code_generation.token`/`signature_hex` identifying the selected `MethodDef`. + +The .NET 10.0.9 x64 fixture was validated with the direct DAC bridge: + +| Target | Token | Signature | Hit evidence | +| --- | --- | --- | --- | +| `ManagedTargets.PublicEntry()` | `100663298` | `00000E` | `hit: true`; breakpoint ID `0` and IP matched its native entry | +| 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. + +`--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: + +- `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 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: + +```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`" --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 +``` + +`--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 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: + +```json +{ + "workflow": "live_startup_profile", + "target_memory_writes": { + "requested": false, + "software_breakpoints": false, + "hardware_breakpoints": false, + "dac_bridge": false + }, + "runs": [{ + "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, + "resumed_wall_elapsed_ms": 10, + "module": { "basename": "coreclr.dll" } + }], + "phase_durations": [{ + "name": "create_process_to_coreclr_load", + "status": "observed", + "elapsed_ms": 10 + }], + "debuggee_output": { + "status": "captured", + "records_returned": 1, + "dropped_record_count": 0 + }, + "module_provenance": { + "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." + }] + }], + "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" } + }] + } +} +``` + +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. + +### 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: + +```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 ` + --completion-module RemoteDesktopManager.dll ` + --settle-ms 500 ` + --initial-break-timeout-ms 30000 ` + --timeout-ms 30000 ` + --max-events 512 ` + --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") +``` + +`--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 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 | 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 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 + +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()`. + +### RDM approval requirement + +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 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. @@ -127,6 +448,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. @@ -252,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`, `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 diff --git a/docs/development.md b/docs/development.md index 36a2967..22a05a9 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,10 +58,39 @@ 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. +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 loads the version-matched `dbgcore.dll`, `dbghelp.dll`, `dbgmodel.dll`, and `dbgeng.dll` set from this directory (or from beside `windbg-tool.exe` in a package) before resolving DbgEng exports. This keeps the executable free of static DbgEng/DbgHelp imports and 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`: 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. + +## Managed breakpoint fixture + +`crates\windbg-tool\tests\fixtures\ManagedBreakpointFixture` is a source-only .NET 10 x64 test target for direct CoreCLR DAC breakpoints. It exercises public, private, and overloaded static methods while `MethodImplOptions.NoInlining` keeps each target observable to the JIT. + +```powershell +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 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. 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. + 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..9bfa065 --- /dev/null +++ b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.cpp @@ -0,0 +1,1628 @@ +// 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 HCORENUM = void*; +using mdToken = ULONG32; +using mdTypeDef = ULONG32; +using mdTypeRef = ULONG32; +using mdInterfaceImpl = ULONG32; +using mdFieldDef = ULONG32; +using mdMethodDef = ULONG32; +using mdParamDef = ULONG32; +using mdMemberRef = ULONG32; +using mdPermission = ULONG32; +using PCCOR_SIGNATURE = const BYTE*; + +static const CLSID CLSID_CorMetaDataDispenser = + {0xe5cb7a31, 0x7512, 0x11d2, {0x89, 0xce, 0x00, 0x80, 0xc7, 0x92, 0xe5, 0xd8}}; +static const IID IID_IMetaDataDispenser = + {0x809c652e, 0x7396, 0x11d2, {0x97, 0x71, 0x00, 0xa0, 0xc9, 0xb4, 0xd5, 0x0c}}; +static const IID IID_IMetaDataImport = + {0x7dac8207, 0xd3ae, 0x4c75, {0x9b, 0x67, 0x92, 0x80, 0x1a, 0x49, 0x7d, 0x44}}; + +// This prefix follows the stable, MIT-licensed CoreCLR metadata contract in cor.h. +// Only methods through GetMethodProps are declared because the bridge never mutates metadata. +struct IMetaDataDispenser : IUnknown { + virtual HRESULT STDMETHODCALLTYPE DefineScope( + REFCLSID rclsid, + DWORD create_flags, + REFIID iid, + IUnknown** scope) = 0; + virtual HRESULT STDMETHODCALLTYPE OpenScope( + LPCWSTR path, + DWORD open_flags, + REFIID iid, + IUnknown** scope) = 0; + virtual HRESULT STDMETHODCALLTYPE OpenScopeOnMemory( + LPCVOID data, + ULONG size, + DWORD open_flags, + REFIID iid, + IUnknown** scope) = 0; +}; + +struct IMetaDataImport : IUnknown { + virtual void STDMETHODCALLTYPE CloseEnum(HCORENUM enumeration) = 0; + virtual HRESULT STDMETHODCALLTYPE CountEnum(HCORENUM enumeration, ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE ResetEnum(HCORENUM enumeration, ULONG position) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumTypeDefs( + HCORENUM* enumeration, + mdTypeDef types[], + ULONG maximum, + ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumInterfaceImpls( + HCORENUM* enumeration, + mdTypeDef type, + mdInterfaceImpl implementations[], + ULONG maximum, + ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumTypeRefs( + HCORENUM* enumeration, + mdTypeRef references[], + ULONG maximum, + ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE FindTypeDefByName( + LPCWSTR type_name, + mdToken enclosing_type, + mdTypeDef* type) = 0; + virtual HRESULT STDMETHODCALLTYPE GetScopeProps( + LPWSTR name, + ULONG name_capacity, + ULONG* name_length, + GUID* mvid) = 0; + virtual HRESULT STDMETHODCALLTYPE GetModuleFromScope(mdToken* module) = 0; + virtual HRESULT STDMETHODCALLTYPE GetTypeDefProps( + mdTypeDef type, + LPWSTR name, + ULONG name_capacity, + ULONG* name_length, + DWORD* attributes, + mdToken* extends) = 0; + virtual HRESULT STDMETHODCALLTYPE GetInterfaceImplProps( + mdInterfaceImpl implementation, + mdTypeDef* type, + mdToken* interface_type) = 0; + virtual HRESULT STDMETHODCALLTYPE GetTypeRefProps( + mdTypeRef reference, + mdToken* resolution_scope, + LPWSTR name, + ULONG name_capacity, + ULONG* name_length) = 0; + virtual HRESULT STDMETHODCALLTYPE ResolveTypeRef( + mdTypeRef reference, + REFIID iid, + IUnknown** scope, + mdTypeDef* type) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumMembers( + HCORENUM* enumeration, + mdTypeDef type, + mdToken members[], + ULONG maximum, + ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumMembersWithName( + HCORENUM* enumeration, + mdTypeDef type, + LPCWSTR name, + mdToken members[], + ULONG maximum, + ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumMethods( + HCORENUM* enumeration, + mdTypeDef type, + mdMethodDef methods[], + ULONG maximum, + ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumMethodsWithName( + HCORENUM* enumeration, + mdTypeDef type, + LPCWSTR name, + mdMethodDef methods[], + ULONG maximum, + ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumFields( + HCORENUM* enumeration, + mdTypeDef type, + mdFieldDef fields[], + ULONG maximum, + ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumFieldsWithName( + HCORENUM* enumeration, + mdTypeDef type, + LPCWSTR name, + mdFieldDef fields[], + ULONG maximum, + ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumParams( + HCORENUM* enumeration, + mdMethodDef method, + mdParamDef parameters[], + ULONG maximum, + ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumMemberRefs( + HCORENUM* enumeration, + mdToken parent, + mdMemberRef references[], + ULONG maximum, + ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumMethodImpls( + HCORENUM* enumeration, + mdTypeDef type, + mdToken bodies[], + mdToken declarations[], + ULONG maximum, + ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE EnumPermissionSets( + HCORENUM* enumeration, + mdToken token, + DWORD actions, + mdPermission permissions[], + ULONG maximum, + ULONG* count) = 0; + virtual HRESULT STDMETHODCALLTYPE FindMember( + mdTypeDef type, + LPCWSTR name, + PCCOR_SIGNATURE signature, + ULONG signature_size, + mdToken* member) = 0; + virtual HRESULT STDMETHODCALLTYPE FindMethod( + mdTypeDef type, + LPCWSTR name, + PCCOR_SIGNATURE signature, + ULONG signature_size, + mdMethodDef* method) = 0; + virtual HRESULT STDMETHODCALLTYPE FindField( + mdTypeDef type, + LPCWSTR name, + PCCOR_SIGNATURE signature, + ULONG signature_size, + mdFieldDef* field) = 0; + virtual HRESULT STDMETHODCALLTYPE FindMemberRef( + mdTypeRef type, + LPCWSTR name, + PCCOR_SIGNATURE signature, + ULONG signature_size, + mdMemberRef* reference) = 0; + virtual HRESULT STDMETHODCALLTYPE GetMethodProps( + mdMethodDef method, + mdTypeDef* type, + LPWSTR name, + ULONG name_capacity, + ULONG* name_length, + DWORD* attributes, + PCCOR_SIGNATURE* signature, + ULONG* signature_size, + ULONG* code_rva, + DWORD* implementation_flags) = 0; +}; + +struct CLRDATA_ADDRESS_RANGE { + CLRDATA_ADDRESS start_address; + CLRDATA_ADDRESS end_address; +}; + +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_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}}; + +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; +}; + +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; + 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; +}; + +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); + +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; +}; + +using MetaDataGetDispenserFn = + HRESULT(STDAPICALLTYPE*)(REFCLSID dispenser_class, REFIID iid, LPVOID* interface_pointer); + +class MetadataImport final { +public: + ~MetadataImport() { + importer_.reset(); + dispenser_.reset(); + if (mscoree_ != nullptr) { + FreeLibrary(mscoree_); + } + } + + static HRESULT Open(const wchar_t* managed_module_path, std::unique_ptr* 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) { + 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); +} + +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"\\/"); + if (separator == std::wstring::npos) { + return {}; + } + path.resize(separator + 1); + path += L"mscordaccore.dll"; + return path; +} + +class DbgEngDataTarget final : public ICLRDataTarget2 { +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; + } + if (iid == IID_ICLRDataTarget2) { + *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; + } + + 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; + 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_instance.reset(); + method.reset(); + process.reset(); + target->Release(); + if (dac_module != nullptr) { + FreeLibrary(dac_module); + } + } + + 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; + 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( + 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) { + 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; + + // 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; + 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) { + 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_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, + 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; +} + +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, + bool request_code_notification) { + 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)); + 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 = + find_module_by_path(bridge->process.get(), managed_module_path, &module); + if (module_status != WINDBG_DAC_OK) { + 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); + 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; + uint32_t selected_count = 0; + std::vector selected_signature; + 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; + 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); + + 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 (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"More than one managed method definition matched the supplied exact metadata signature."); + } + + bridge->method.reset(selected.detach()); + 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; + 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)); + if (!request_code_notification) { + return WINDBG_DAC_OK; + } + 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) + + L" DAC callback diagnostics: " + bridge->target->diagnostic()); + } + + 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) { + 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."); + } + + 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."); + } + 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); + 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)); + } + 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..4b84499 --- /dev/null +++ b/native/coreclr-dac-bridge/windbg_coreclr_dac_bridge.h @@ -0,0 +1,110 @@ +#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; + +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; + uint64_t representative_entry_address; + uint32_t code_notification_flags; + 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. +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_disable_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, + const uint8_t* signature_blob, + 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); + +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,