Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 46 additions & 12 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>)> = 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]
Expand Down
152 changes: 150 additions & 2 deletions src/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,11 @@ fn process_snapshot() -> &'static HashMap<u32, ProcessNode> {
{
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()
}
Expand Down Expand Up @@ -336,6 +340,67 @@ fn build_unix_snapshot() -> Option<HashMap<u32, ProcessNode>> {
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<HashMap<u32, ProcessNode>> {
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::<PROCESSENTRY32W>() 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`.
Expand Down Expand Up @@ -838,7 +903,55 @@ fn parent_boot_time(pid: u32) -> Option<u64> {
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<u64> {
// 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<u64> {
None
}
Expand Down Expand Up @@ -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
Expand Down
Loading