diff --git a/src/main.rs b/src/main.rs index cea70ce0b..b9850c31d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -170,25 +170,59 @@ async fn handle_update_task( async fn background_stage_update(version: &str) -> Result<()> { use util::check_update::UpdateCheck; - let result = async { - if telemetry::is_auto_update_disabled() { - return Ok(()); - } + let start = std::time::Instant::now(); + let stage_outcome: Option<(bool, Option)> = if telemetry::is_auto_update_disabled() { + None + } else { + let outcome = match util::self_update::download_and_stage(version).await { + // Staged successfully; cache stays until try_apply_staged() succeeds. + Ok(true) => (true, None), + // Another stage process holds the lock; this one is a no-op and + // the in-flight stage will report its own outcome. + Ok(false) => (false, Some("lock_held".to_string())), + Err(e) => { + UpdateCheck::record_download_failure(); + let msg = format!("{e}"); + let truncated = if msg.len() > 256 { + msg[..256].to_string() + } else { + msg + }; + (false, Some(truncated)) + } + }; + Some(outcome) + }; - match util::self_update::download_and_stage(version).await { - Ok(true) => {} // Staged successfully; cache stays until try_apply_staged() succeeds. - Ok(false) => {} // Lock held by another process, will retry - Err(_) => UpdateCheck::record_download_failure(), - } - Ok(()) + // Fire telemetry from the detached child so we have visibility into + // download/stage success rate. The existing `cli_autoupdate_apply` + // event covers the *apply* step (TTY-only, fires from the swapped-in + // binary on the next invocation); this `cli_autoupdate_stage` event + // covers the *download* step which previously had no telemetry. Lock + // contention shows up as success=false / error_message="lock_held" + // and is a no-op rather than a real failure — distinguishable from + // network/checksum errors via the error_message value. + if let Some((success, error_message)) = stage_outcome { + let duration_ms = start.elapsed().as_millis() as u64; + telemetry::send(telemetry::CliTrackEvent { + command: "autoupdate_stage".to_string(), + sub_command: Some(version.to_string()), + duration_ms, + success, + error_message, + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + cli_version: env!("CARGO_PKG_VERSION"), + is_ci: Configs::env_is_ci(), + }) + .await; } - .await; if let Ok(pid_path) = util::self_update::download_update_pid_path() { let _ = std::fs::remove_file(pid_path); } - result + Ok(()) } #[tokio::main] diff --git a/src/telemetry.rs b/src/telemetry.rs index c37b8c139..c0cd219e0 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -293,7 +293,11 @@ fn process_snapshot() -> &'static HashMap { { build_unix_snapshot().unwrap_or_default() } - #[cfg(not(unix))] + #[cfg(target_os = "windows")] + { + build_windows_snapshot().unwrap_or_default() + } + #[cfg(not(any(unix, target_os = "windows")))] { HashMap::new() } @@ -336,6 +340,67 @@ fn build_unix_snapshot() -> Option> { Some(map) } +/// Windows equivalent of `build_unix_snapshot`. Uses the toolhelp32 API to +/// walk the live process table. The `command` field on each node is the +/// executable basename (e.g. `claude.exe`) rather than the full argv — +/// `PROCESSENTRY32W::szExeFile` is the only command-style field the +/// snapshot API exposes, and fetching the full command line per process +/// requires `NtQueryInformationProcess` which is undocumented kernel API. +/// Basename matches are sufficient for the substring-based caller detection +/// that consumes this snapshot. +#[cfg(target_os = "windows")] +fn build_windows_snapshot() -> Option> { + use std::mem::size_of; + use winapi::shared::minwindef::FALSE; + use winapi::um::handleapi::{CloseHandle, INVALID_HANDLE_VALUE}; + use winapi::um::tlhelp32::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, + TH32CS_SNAPPROCESS, + }; + + let mut map = HashMap::new(); + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snapshot == INVALID_HANDLE_VALUE { + return None; + } + + let mut entry: PROCESSENTRY32W = std::mem::zeroed(); + entry.dwSize = size_of::() as u32; + + if Process32FirstW(snapshot, &mut entry) == FALSE { + CloseHandle(snapshot); + return None; + } + + loop { + // szExeFile is a UTF-16 array, null-terminated. Find the null + // terminator and decode just the populated prefix. + let len = entry + .szExeFile + .iter() + .position(|&c| c == 0) + .unwrap_or(entry.szExeFile.len()); + let command = String::from_utf16_lossy(&entry.szExeFile[..len]).to_ascii_lowercase(); + map.insert( + entry.th32ProcessID, + ProcessNode { + ppid: entry.th32ParentProcessID, + command, + }, + ); + + if Process32NextW(snapshot, &mut entry) == FALSE { + break; + } + } + + CloseHandle(snapshot); + } + + Some(map) +} + /// Map a process command line to a canonical agent slug. Operates on the /// lowercased full command line so that node-bundled agents (e.g. `node /// /path/to/cursor-agent`) match even though their `comm` is just `node`. @@ -838,7 +903,55 @@ fn parent_boot_time(pid: u32) -> Option { Some(u64::from_be_bytes(out8)) } -#[cfg(not(any(target_os = "linux", target_os = "macos")))] +#[cfg(target_os = "windows")] +fn parent_boot_time(pid: u32) -> Option { + // Windows process creation time is a FILETIME — 100-nanosecond intervals + // since 1601-01-01 UTC, stable across the lifetime of the process and + // not affected by reboots in the way Linux clock-tick `starttime` is. + // Combined with the pid, this gives us the same invariant as the unix + // paths: a PID-reuse collision would have to land on the *exact same* + // creation timestamp to be confused for the original process. + use winapi::shared::minwindef::{FALSE, FILETIME}; + use winapi::um::handleapi::CloseHandle; + use winapi::um::processthreadsapi::{GetProcessTimes, OpenProcess}; + use winapi::um::winnt::PROCESS_QUERY_LIMITED_INFORMATION; + + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if handle.is_null() { + return None; + } + let mut creation = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut exit = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut kernel = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut user = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let ok = GetProcessTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user); + CloseHandle(handle); + if ok == FALSE { + return None; + } + Some(filetime_to_u64(creation)) + } +} + +#[cfg(target_os = "windows")] +fn filetime_to_u64(ft: winapi::shared::minwindef::FILETIME) -> u64 { + ((ft.dwHighDateTime as u64) << 32) | (ft.dwLowDateTime as u64) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] fn parent_boot_time(_pid: u32) -> Option { None } @@ -1719,6 +1832,41 @@ mod tests { assert_ne!(id, new_session_uuid()); } + #[cfg(target_os = "windows")] + #[test] + fn filetime_to_u64_combines_high_and_low_halves() { + use super::filetime_to_u64; + use winapi::shared::minwindef::FILETIME; + // FILETIME is two 32-bit halves. The high half shifts into the + // top 32 bits; low half occupies the bottom 32. + let ft = FILETIME { + dwLowDateTime: 0x12345678, + dwHighDateTime: 0x9ABCDEF0, + }; + assert_eq!(filetime_to_u64(ft), 0x9ABCDEF012345678); + + // Round-trips: any u64 → FILETIME → u64 is identity. + let original: u64 = 0xDEAD_BEEF_CAFE_BABE; + let ft = FILETIME { + dwLowDateTime: original as u32, + dwHighDateTime: (original >> 32) as u32, + }; + assert_eq!(filetime_to_u64(ft), original); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_snapshot_includes_self_and_parent() { + // Sanity check that the toolhelp32 snapshot at least sees our own + // pid and that it has a non-zero parent pid. Catches gross runtime + // failures (missing winapi linkage, snapshot perm errors, etc.) + // without coupling to specific platform versions. + let snapshot = super::build_windows_snapshot().expect("snapshot"); + let me = snapshot.get(&std::process::id()).expect("self pid present"); + assert!(me.ppid != 0, "parent pid should be non-zero"); + assert!(!me.command.is_empty(), "command should be populated"); + } + #[test] fn new_session_uuid_does_not_match_cli_fallback_regex() { // Cross-check against the dbt-side regex