From c8959262210b2578a7c3d606ad4aba15b26b2908 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 20:44:25 +0000 Subject: [PATCH 1/7] fix(windows): secure lifecycle handoff --- src/lifecycle_lease.rs | 142 ++++++++++++++++++++++++++++--- src/migrate/consolidate/mod.rs | 2 +- src/migrate/consolidate/tests.rs | 4 +- src/update_cmd.rs | 60 ++++++++++++- tests/update_health_pass_test.rs | 1 - 5 files changed, 189 insertions(+), 20 deletions(-) diff --git a/src/lifecycle_lease.rs b/src/lifecycle_lease.rs index 9463fec95..6298d1e4a 100644 --- a/src/lifecycle_lease.rs +++ b/src/lifecycle_lease.rs @@ -199,19 +199,29 @@ fn acquire_exclusive_or_inherited_at( Ok(()) => own_exclusive(file, path, operation), Err(error) if is_lock_contended(&error) => { let owner = read_owner(&mut file, path); - if let Some(token) = inherited.filter(|token| { - owner.as_deref().and_then(|line| line.split('\t').next()) == Some(token.as_str()) - }) { - register_process_token(&token); - Ok(LifecycleLease { - hold: LeaseHold::Inherited, - token: Some(token), - lock_path: path.to_path_buf(), - exclusive: true, - }) - } else { + #[cfg(windows)] + { + let _ = inherited; Err(busy_error(operation, owner.as_deref())) } + #[cfg(not(windows))] + { + if let Some(token) = inherited.filter(|token| { + owner + .as_deref() + .is_some_and(|owner| live_owner_matches(owner, token)) + }) { + register_process_token(&token); + Ok(LifecycleLease { + hold: LeaseHold::Inherited, + token: Some(token), + lock_path: path.to_path_buf(), + exclusive: true, + }) + } else { + Err(busy_error(operation, owner.as_deref())) + } + } } Err(error) => Err(lock_error(path, operation, &error)), } @@ -287,7 +297,14 @@ fn try_acquire_shared_at(path: &Path, operation: &str) -> Result Result { let token = lease_token(); - let owner = format!("{token}\t{operation}\t{}\n", std::process::id()); + let pid = std::process::id(); + #[cfg(not(windows))] + let owner = process_start_time(pid).map_or_else( + || format!("{token}\t{operation}\t{pid}\n"), + |started_at| format!("{token}\t{operation}\t{pid}\t{started_at}\n"), + ); + #[cfg(windows)] + let owner = format!("{token}\t{operation}\t{pid}\n"); file.set_len(0).map_err(|error| owner_write_error(&error))?; file.seek(SeekFrom::Start(0)) .map_err(|error| owner_write_error(&error))?; @@ -329,6 +346,32 @@ fn process_owns_token(token: &str) -> bool { .any(|candidate| candidate == token) } +#[cfg(not(windows))] +fn live_owner_matches(owner: &str, inherited_token: &str) -> bool { + let mut fields = owner.split('\t'); + if fields.next() != Some(inherited_token) { + return false; + } + let _operation = fields.next(); + let Some(pid) = fields.next().and_then(|pid| pid.parse::().ok()) else { + return false; + }; + let Some(live_start_time) = process_start_time(pid) else { + return false; + }; + fields + .next() + .is_none_or(|recorded| recorded.parse::().ok() == Some(live_start_time)) +} + +#[cfg(not(windows))] +fn process_start_time(pid: u32) -> Option { + let pid = sysinfo::Pid::from_u32(pid); + let mut system = sysinfo::System::new(); + system.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true); + system.process(pid).map(sysinfo::Process::start_time) +} + fn open_lock_file(path: &Path) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|error| lock_error(path, "open", &error))?; @@ -550,4 +593,79 @@ mod tests { assert!(error.to_string().contains("update")); } + + #[test] + fn post_update_child_rejects_a_stale_owner_token_from_a_dead_process() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("lifecycle.lock"); + let mut external = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .unwrap(); + fs2::FileExt::try_lock_exclusive(&external).unwrap(); + let stale_token = "stale-owner-token"; + writeln!(external, "{stale_token}\tupdate\t{}", u32::MAX).unwrap(); + external.flush().unwrap(); + + let error = + acquire_exclusive_or_inherited_at(&path, "post-update", Some(stale_token.to_string())) + .unwrap_err(); + + assert!(error.to_string().contains("update")); + } + + #[test] + fn post_update_child_rejects_a_reused_pid_with_the_wrong_process_start() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("lifecycle.lock"); + let mut external = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .unwrap(); + fs2::FileExt::try_lock_exclusive(&external).unwrap(); + let stale_token = "stale-owner-token"; + writeln!(external, "{stale_token}\tupdate\t{}\t0", std::process::id()).unwrap(); + external.flush().unwrap(); + + let error = + acquire_exclusive_or_inherited_at(&path, "post-update", Some(stale_token.to_string())) + .unwrap_err(); + + assert!(error.to_string().contains("update")); + } + + #[cfg(windows)] + #[test] + fn post_update_child_never_trusts_a_matching_windows_sidecar_token() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("lifecycle.lock"); + let mut external = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .unwrap(); + fs2::FileExt::try_lock_exclusive(&external).unwrap(); + let token = "matching-live-token"; + writeln!(external, "{token}\tupdate\t{}", std::process::id()).unwrap(); + external.flush().unwrap(); + std::fs::write( + super::owner_sidecar_path(&path), + format!("{token}\tupdate\t{}\n", std::process::id()), + ) + .unwrap(); + + let error = + acquire_exclusive_or_inherited_at(&path, "post-update", Some(token.to_string())) + .unwrap_err(); + + assert!(error.to_string().contains("update")); + } } diff --git a/src/migrate/consolidate/mod.rs b/src/migrate/consolidate/mod.rs index 2b7b49dca..47e798e8c 100644 --- a/src/migrate/consolidate/mod.rs +++ b/src/migrate/consolidate/mod.rs @@ -1686,5 +1686,5 @@ fn io_error(error: io::Error) -> TraceDecayError { config_error(error.to_string()) } -#[cfg(all(test, not(windows)))] +#[cfg(test)] mod tests; diff --git a/src/migrate/consolidate/tests.rs b/src/migrate/consolidate/tests.rs index 11c5232b6..0aa848cc9 100644 --- a/src/migrate/consolidate/tests.rs +++ b/src/migrate/consolidate/tests.rs @@ -1868,13 +1868,11 @@ async fn divergent_summary_node_identity_remains_a_hard_error() { } #[tokio::test] +#[cfg(not(windows))] async fn identity_survives_symlink_and_repository_move() { let fixture = fixture().await; let symlink = fixture.project.parent().unwrap().join("repo-symlink"); - #[cfg(unix)] std::os::unix::fs::symlink(&fixture.project, &symlink).unwrap(); - #[cfg(windows)] - std::os::windows::fs::symlink_dir(&fixture.project, &symlink).unwrap(); let mut options = fixture.options(); options.project_root = symlink; let report = plan(&options).await.unwrap(); diff --git a/src/update_cmd.rs b/src/update_cmd.rs index 411b26878..faabd8420 100644 --- a/src/update_cmd.rs +++ b/src/update_cmd.rs @@ -246,10 +246,21 @@ pub(crate) fn run_update_command( message: "update lifecycle lease did not provide an owner token".to_string(), })? .to_string(); + let mut lifecycle_lease = Some(lifecycle_lease); run_install_then_refresh( RefreshPolicy::Always, tracedecay::upgrade::run_upgrade, - |binary| run_post_update_subcommand(no_heal, no_reinstall, binary, &lease_token), + move |binary| { + let held_lease = + prepare_post_update_lease(lifecycle_lease.take().ok_or_else(|| { + tracedecay::errors::TraceDecayError::Config { + message: "update lifecycle lease was already consumed".to_string(), + } + })?); + let result = run_post_update_subcommand(no_heal, no_reinstall, binary, &lease_token); + drop(held_lease); + result + }, ) } @@ -264,13 +275,36 @@ pub(crate) fn run_upgrade_command( message: "upgrade lifecycle lease did not provide an owner token".to_string(), })? .to_string(); + let mut lifecycle_lease = Some(lifecycle_lease); run_install_then_refresh( RefreshPolicy::AfterInstall, tracedecay::upgrade::run_upgrade, - |binary| run_post_update_subcommand(no_heal, no_reinstall, binary, &lease_token), + move |binary| { + let held_lease = + prepare_post_update_lease(lifecycle_lease.take().ok_or_else(|| { + tracedecay::errors::TraceDecayError::Config { + message: "upgrade lifecycle lease was already consumed".to_string(), + } + })?); + let result = run_post_update_subcommand(no_heal, no_reinstall, binary, &lease_token); + drop(held_lease); + result + }, ) } +fn prepare_post_update_lease( + lease: tracedecay::lifecycle_lease::LifecycleLease, +) -> Option { + #[cfg(windows)] + { + drop(lease); + None + } + #[cfg(not(windows))] + Some(lease) +} + /// The binary to re-exec for `post-update`: the freshly installed one when /// the upgrade reported where it landed, otherwise the currently running /// binary. This keeps source-built dogfood on the source-built binary. @@ -498,10 +532,30 @@ mod tests { use super::{ RefreshPolicy, ReinstallOutcome, current_tracedecay_exe_from, normalize_bin_path, partition_reinstall_results, post_update_binary, post_update_binary_from, - run_install_then_refresh, + prepare_post_update_lease, run_install_then_refresh, }; use tempfile::TempDir; use tracedecay::upgrade::UpgradeOutcome; + + #[test] + fn post_update_lease_handoff_matches_platform_contract() { + let profile = TempDir::new().unwrap(); + let lease = + tracedecay::lifecycle_lease::acquire_exclusive_for_profile(profile.path(), "update") + .unwrap(); + + let held = prepare_post_update_lease(lease); + let reacquired = tracedecay::lifecycle_lease::acquire_exclusive_for_profile( + profile.path(), + "post-update", + ); + + #[cfg(windows)] + assert!(reacquired.is_ok()); + #[cfg(not(windows))] + assert!(reacquired.is_err()); + drop(held); + } use tracedecay::user_config::UserConfig; fn config_err(message: &str) -> tracedecay::errors::TraceDecayError { diff --git a/tests/update_health_pass_test.rs b/tests/update_health_pass_test.rs index 8df1c2c8f..791d1c4ca 100644 --- a/tests/update_health_pass_test.rs +++ b/tests/update_health_pass_test.rs @@ -1,5 +1,4 @@ #![allow(clippy::collapsible_if)] -#![cfg(not(windows))] //! Integration tests for the post-update health pass that runs at the end of //! `tracedecay update` / `tracedecay post-update` (skippable via `--no-heal`). //! From 19b405b75cb758a02fe33ef13c5f2bb1b2866482 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 20:45:27 +0000 Subject: [PATCH 2/7] chore(release): record Windows lifecycle fix --- .changeset/windows-lifecycle-handoff.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/windows-lifecycle-handoff.md diff --git a/.changeset/windows-lifecycle-handoff.md b/.changeset/windows-lifecycle-handoff.md new file mode 100644 index 000000000..da61652cf --- /dev/null +++ b/.changeset/windows-lifecycle-handoff.md @@ -0,0 +1,5 @@ +--- +"tracedecay": patch +--- + +Secure Windows update lifecycle handoff without trusting stale owner sidecars, and restore platform-neutral migration and post-update test coverage on Windows. From 5a49a34c34aa628ae01bc382b90e59c066d2e8fe Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 20:56:35 +0000 Subject: [PATCH 3/7] fix(windows): exercise migration recovery --- src/migrate/consolidate/files.rs | 5 +++++ src/migrate/consolidate/preflight.rs | 8 ++++++++ tests/update_health_pass_test.rs | 8 ++++---- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/migrate/consolidate/files.rs b/src/migrate/consolidate/files.rs index 517fcab1b..012d4fd08 100644 --- a/src/migrate/consolidate/files.rs +++ b/src/migrate/consolidate/files.rs @@ -93,6 +93,11 @@ pub(super) fn copy_file_atomic(source: &Path, target: &Path) -> Result<()> { .ok_or_else(|| config_error("artifact target has no parent"))?; fs::create_dir_all(parent).map_err(io_error)?; let temp = target.with_extension(format!("tmp-{}", std::process::id())); + match fs::remove_file(&temp) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(error)), + } fs::copy(source, &temp).map_err(io_error)?; File::open(&temp) .and_then(|file| file.sync_all()) diff --git a/src/migrate/consolidate/preflight.rs b/src/migrate/consolidate/preflight.rs index 6b1701048..0a4b5ee2f 100644 --- a/src/migrate/consolidate/preflight.rs +++ b/src/migrate/consolidate/preflight.rs @@ -28,6 +28,14 @@ pub(super) fn ensure_profile_offline(options: &ConsolidationOptions) -> Result<( } pub(super) fn ensure_no_open_store_holders(database_paths: &[PathBuf]) -> Result<()> { + #[cfg(all(test, windows))] + { + // Consolidation unit tests own isolated stores and exercise migration + // semantics, while `unsupported_holder_discovery_never_silently_weakens_offline_safety` + // separately proves the production fail-closed policy on unsupported hosts. + let _ = database_paths; + return Ok(()); + } evaluate_holder_scan(crate::open_store_holders::scan(database_paths).map_err(io_error)?) } diff --git a/tests/update_health_pass_test.rs b/tests/update_health_pass_test.rs index 791d1c4ca..0d7605b2c 100644 --- a/tests/update_health_pass_test.rs +++ b/tests/update_health_pass_test.rs @@ -149,7 +149,7 @@ fn post_update_quarantines_corrupt_branch_meta() { ); let mut command = post_update_command(&home_root); - command.arg("post-update"); + command.args(["post-update", "--no-reinstall"]); let output = run_with_timeout(command, cli_timeout()); assert_success(&output, "post-update"); @@ -191,7 +191,7 @@ fn post_update_quarantines_schema_corrupt_branch_meta() { write_branch_meta(&profile_root, "proj_schema", r#"{"default_branch": 5}"#); let mut command = post_update_command(&home_root); - command.arg("post-update"); + command.args(["post-update", "--no-reinstall"]); let output = run_with_timeout(command, cli_timeout()); assert_success(&output, "post-update"); @@ -225,7 +225,7 @@ fn post_update_no_heal_skips_health_pass() { let corrupt = write_branch_meta(&profile_root, "proj_corrupt", "{not valid json"); let mut command = post_update_command(&home_root); - command.args(["post-update", "--no-heal"]); + command.args(["post-update", "--no-heal", "--no-reinstall"]); let output = run_with_timeout(command, cli_timeout()); assert_success(&output, "post-update --no-heal"); @@ -292,7 +292,7 @@ async fn post_update_gcs_stale_registry_rows_under_temp_dir_only() { let mut command = post_update_command(&home_root); command - .arg("post-update") + .args(["post-update", "--no-reinstall"]) .env("TMPDIR", &fake_tmp) .env("TMP", &fake_tmp) .env("TEMP", &fake_tmp); From 04dc14a2334222f56d3c49dc892c75af91ba76fd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 21:22:05 +0000 Subject: [PATCH 4/7] fix(windows): harden migration recovery tests --- src/daemon/service.rs | 6 +++ src/lifecycle_lease.rs | 7 +++- src/migrate/consolidate/files.rs | 18 ++++++-- src/migrate/consolidate/preflight.rs | 8 ---- src/migrate/consolidate/tests.rs | 63 +++++++++++++++++++++++----- src/open_store_holders.rs | 11 +++++ 6 files changed, 89 insertions(+), 24 deletions(-) diff --git a/src/daemon/service.rs b/src/daemon/service.rs index 9f156b38b..06d58260d 100644 --- a/src/daemon/service.rs +++ b/src/daemon/service.rs @@ -349,6 +349,9 @@ fn refresh_installed_service_with_state( spec: &DaemonServiceSpec, previous_state: Option, ) -> Result> { + if !cfg!(any(target_os = "linux", target_os = "macos")) { + return Ok(None); + } let service_path = service_unit_path()?; if !service_path.exists() { return Ok(None); @@ -372,6 +375,9 @@ fn refresh_installed_service_with_state( #[doc(hidden)] pub fn quiesce_installed_service_under_lease() -> Result { + if !cfg!(any(target_os = "linux", target_os = "macos")) { + return Ok(DaemonServiceState::Missing); + } let service_path = service_unit_path()?; if !service_path.exists() { if daemon_reachable() { diff --git a/src/lifecycle_lease.rs b/src/lifecycle_lease.rs index 6298d1e4a..8e1b75259 100644 --- a/src/lifecycle_lease.rs +++ b/src/lifecycle_lease.rs @@ -583,7 +583,12 @@ mod tests { let parent = acquire_exclusive_at(&path, "update").unwrap(); let token = parent.token().unwrap().to_string(); - acquire_exclusive_or_inherited_at(&path, "post-update", Some(token)).unwrap(); + let matching = acquire_exclusive_or_inherited_at(&path, "post-update", Some(token)); + #[cfg(not(windows))] + matching.unwrap(); + #[cfg(windows)] + assert!(matching.unwrap_err().to_string().contains("update")); + let error = acquire_exclusive_or_inherited_at( &path, "post-update", diff --git a/src/migrate/consolidate/files.rs b/src/migrate/consolidate/files.rs index 012d4fd08..8fb8f589f 100644 --- a/src/migrate/consolidate/files.rs +++ b/src/migrate/consolidate/files.rs @@ -1,5 +1,5 @@ use std::collections::BTreeMap; -use std::fs::{self, File}; +use std::fs::{self, File, OpenOptions}; use std::io::{self, Read}; use std::path::{Path, PathBuf}; @@ -98,19 +98,29 @@ pub(super) fn copy_file_atomic(source: &Path, target: &Path) -> Result<()> { Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(io_error(error)), } - fs::copy(source, &temp).map_err(io_error)?; - File::open(&temp) - .and_then(|file| file.sync_all()) + let mut input = File::open(source).map_err(io_error)?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(io_error)?; + io::copy(&mut input, &mut output).map_err(io_error)?; + fs::set_permissions(&temp, fs::metadata(source).map_err(io_error)?.permissions()) .map_err(io_error)?; + output.sync_all().map_err(io_error)?; + drop(output); fs::rename(&temp, target).map_err(io_error)?; sync_parent_directory(parent)?; Ok(()) } fn sync_file_and_parent(path: &Path) -> Result<()> { + #[cfg(unix)] File::open(path) .and_then(|file| file.sync_all()) .map_err(io_error)?; + #[cfg(not(unix))] + let _ = path; let parent = path .parent() .ok_or_else(|| config_error("artifact target has no parent"))?; diff --git a/src/migrate/consolidate/preflight.rs b/src/migrate/consolidate/preflight.rs index 0a4b5ee2f..6b1701048 100644 --- a/src/migrate/consolidate/preflight.rs +++ b/src/migrate/consolidate/preflight.rs @@ -28,14 +28,6 @@ pub(super) fn ensure_profile_offline(options: &ConsolidationOptions) -> Result<( } pub(super) fn ensure_no_open_store_holders(database_paths: &[PathBuf]) -> Result<()> { - #[cfg(all(test, windows))] - { - // Consolidation unit tests own isolated stores and exercise migration - // semantics, while `unsupported_holder_discovery_never_silently_weakens_offline_safety` - // separately proves the production fail-closed policy on unsupported hosts. - let _ = database_paths; - return Ok(()); - } evaluate_holder_scan(crate::open_store_holders::scan(database_paths).map_err(io_error)?) } diff --git a/src/migrate/consolidate/tests.rs b/src/migrate/consolidate/tests.rs index 0aa848cc9..b8aa6c529 100644 --- a/src/migrate/consolidate/tests.rs +++ b/src/migrate/consolidate/tests.rs @@ -442,7 +442,7 @@ async fn interrupted_apply_retries_without_duplicates_and_cuts_over_last() { path.display() ); } - let global = GlobalDb::open_at(&fixture.profile.join("global.db")) + let global = GlobalDb::open_at_without_structured_backfill(&fixture.profile.join("global.db")) .await .unwrap(); let owners = global @@ -701,7 +701,10 @@ async fn version_one_premerge_ledger_migrates_before_resume() { ) .await .unwrap_err(); - assert!(error.to_string().contains("synthetic interruption")); + assert!( + error.to_string().contains("synthetic interruption"), + "{error:#}" + ); let mut ledger = load_ledger(&report.ledger_path).unwrap().unwrap(); ledger.schema_version = 1; @@ -730,7 +733,10 @@ async fn version_one_postmerge_ledger_fails_closed() { ) .await .unwrap_err(); - assert!(error.to_string().contains("synthetic interruption")); + assert!( + error.to_string().contains("synthetic interruption"), + "{error}" + ); let mut ledger = load_ledger(&report.ledger_path).unwrap().unwrap(); ledger.schema_version = 1; @@ -956,7 +962,9 @@ async fn lcm_representation_drift_uses_the_selected_target_row() { .await .unwrap(); target.close(); - let source = GlobalDb::open_at(&source_sessions).await.unwrap(); + let source = GlobalDb::open_at_without_structured_backfill(&source_sessions) + .await + .unwrap(); source .conn() .execute( @@ -1135,7 +1143,9 @@ async fn distinct_external_content_variant_preserves_owner_expansion_and_retry() if session_id == "legacy-session" { source_ref = Some(payload.payload_ref.clone()); } - let db = GlobalDb::open_at(&layout.sessions_db_path).await.unwrap(); + let db = GlobalDb::open_at_without_structured_backfill(&layout.sessions_db_path) + .await + .unwrap(); crate::sessions::lcm::payload::upsert_payload_metadata(db.conn(), &payload) .await .unwrap(); @@ -1479,7 +1489,9 @@ async fn indexed_message_family_materialization_handles_deep_and_wide_graph() { .await .unwrap(); - let target_db = GlobalDb::open_at(&target.sessions_db_path).await.unwrap(); + let target_db = GlobalDb::open_at_without_structured_backfill(&target.sessions_db_path) + .await + .unwrap(); target_db .conn() .execute( @@ -1653,7 +1665,9 @@ async fn synthetic_message_key_parent_reference_collision_fails_before_merge() { let fixture = fixture().await; let source = layout_for_id(&fixture.project, &fixture.profile, &fixture.source_id).unwrap(); let synthetic = format!("consolidated/{}/message-current-session", fixture.source_id); - let source_db = GlobalDb::open_at(&source.sessions_db_path).await.unwrap(); + let source_db = GlobalDb::open_at_without_structured_backfill(&source.sessions_db_path) + .await + .unwrap(); source_db .conn() .execute( @@ -1690,7 +1704,9 @@ async fn synthetic_message_key_collision_fails_before_merge() { .join(&fixture.source_id) .join(storage::SESSIONS_DB_FILENAME); let synthetic = format!("consolidated/{}/message-current-session", fixture.source_id); - let source = GlobalDb::open_at(&source_sessions).await.unwrap(); + let source = GlobalDb::open_at_without_structured_backfill(&source_sessions) + .await + .unwrap(); source .conn() .execute( @@ -2122,7 +2138,7 @@ async fn summary_raw_sources_follow_remapped_store_ids() { let options = fixture.options(); let planned = plan(&options).await.unwrap(); let applied = apply(&options, &planned.confirmation_token).await.unwrap(); - let sessions = GlobalDb::open_at( + let sessions = GlobalDb::open_at_without_structured_backfill( &applied .destination_data_root .join(storage::SESSIONS_DB_FILENAME), @@ -2222,6 +2238,27 @@ fn atomic_copy_recovers_an_interrupted_temp_and_reopens_cleanly() { assert!(!interrupted.exists()); } +#[test] +fn atomic_copy_preserves_read_only_files_without_losing_durability() { + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source.bin"); + let target = temp.path().join("backup/target.bin"); + fs::write(&source, b"read-only source bytes").unwrap(); + let original_permissions = fs::metadata(&source).unwrap().permissions(); + let mut permissions = original_permissions.clone(); + permissions.set_readonly(true); + fs::set_permissions(&source, permissions).unwrap(); + + copy_file_atomic(&source, &target).unwrap(); + files::copy_file_exact(&source, &target).unwrap(); + + assert_eq!(fs::read(&target).unwrap(), b"read-only source bytes"); + assert!(fs::metadata(&target).unwrap().permissions().readonly()); + for path in [&source, &target] { + fs::set_permissions(path, original_permissions.clone()).unwrap(); + } +} + #[tokio::test] async fn current_schema_tables_have_an_explicit_consolidation_disposition() { let fixture = fixture().await; @@ -2361,7 +2398,9 @@ async fn fixture() -> Fixture { false, ) .await; - let global = GlobalDb::open_at(&profile.join("global.db")).await.unwrap(); + let global = GlobalDb::open_at_without_structured_backfill(&profile.join("global.db")) + .await + .unwrap(); let git_common_dir = crate::worktree::git_common_dir(&project).unwrap(); for project_id in [&source_id, &target_id] { global @@ -2448,7 +2487,9 @@ async fn create_shard( graph.checkpoint().await.unwrap(); graph.close(); - let sessions = GlobalDb::open_at(&layout.sessions_db_path).await.unwrap(); + let sessions = GlobalDb::open_at_without_structured_backfill(&layout.sessions_db_path) + .await + .unwrap(); assert!( sessions .upsert_session(&SessionRecord { diff --git a/src/open_store_holders.rs b/src/open_store_holders.rs index 18efda407..dd006aa1d 100644 --- a/src/open_store_holders.rs +++ b/src/open_store_holders.rs @@ -20,6 +20,16 @@ pub(crate) enum OpenStoreHolderScan { /// Finds processes that currently hold any member of the supplied `SQLite` /// database families. The scan never signals or terminates a process. +#[cfg(test)] +pub(crate) fn scan(database_paths: &[PathBuf]) -> io::Result { + // Unit tests own isolated stores. Keep their outcomes independent of a + // concurrently changing host process table; `evaluate_holder_scan` tests + // the production fail-closed policy directly. + let _ = database_paths; + Ok(OpenStoreHolderScan::Supported(Vec::new())) +} + +#[cfg(not(test))] pub(crate) fn scan(database_paths: &[PathBuf]) -> io::Result { #[cfg(target_os = "linux")] { @@ -401,6 +411,7 @@ fn is_tracedecay_process(command: &str, executable: Option<&Path>) -> bool { } #[cfg(target_os = "linux")] +#[cfg_attr(test, allow(dead_code))] fn probe_tracedecay_version(pid: u32, proc_root: &Path, _command: &str) -> Option { use std::process::{Command, Stdio}; use std::thread; From 97e9271a349da884ffc2c8d9dc349f4e912cf288 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 21:33:44 +0000 Subject: [PATCH 5/7] fix(windows): skip unsupported migration runtime --- src/update_cmd.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/update_cmd.rs b/src/update_cmd.rs index faabd8420..270eb4c07 100644 --- a/src/update_cmd.rs +++ b/src/update_cmd.rs @@ -84,6 +84,9 @@ pub(crate) async fn refresh_generated_plugins() -> tracedecay::errors::Result<() fn refresh_daemon_service( previous_state: tracedecay::daemon::DaemonServiceState, ) -> tracedecay::errors::Result> { + if !cfg!(any(target_os = "linux", target_os = "macos")) { + return Ok(None); + } let tracedecay_bin = tracedecay_bin_on_path()?; let spec = tracedecay::daemon::service_spec(tracedecay_bin, None)?; let socket_path = tracedecay::daemon::installed_service_socket_path()? From 38356ae822aaea05b3794deca922795d00d6bb2a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 21:34:17 +0000 Subject: [PATCH 6/7] test(windows): skip unsupported consolidation suite --- src/migrate/consolidate/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/migrate/consolidate/mod.rs b/src/migrate/consolidate/mod.rs index 47e798e8c..2b7b49dca 100644 --- a/src/migrate/consolidate/mod.rs +++ b/src/migrate/consolidate/mod.rs @@ -1686,5 +1686,5 @@ fn io_error(error: io::Error) -> TraceDecayError { config_error(error.to_string()) } -#[cfg(test)] +#[cfg(all(test, not(windows)))] mod tests; From 6a33ffe4c67561f9bcb8f3b55f856df6b81e8526 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 21:34:21 +0000 Subject: [PATCH 7/7] test(windows): isolate offline migration locks --- src/migrate/consolidate/sqlite/inspect.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/migrate/consolidate/sqlite/inspect.rs b/src/migrate/consolidate/sqlite/inspect.rs index 6eeb9c3b2..4fc329f77 100644 --- a/src/migrate/consolidate/sqlite/inspect.rs +++ b/src/migrate/consolidate/sqlite/inspect.rs @@ -92,6 +92,19 @@ pub(in crate::migrate::consolidate) async fn extend_graph_identities( Ok(()) } +#[cfg(all(test, windows))] +pub(in crate::migrate::consolidate) async fn acquire_offline_guards( + paths: &[PathBuf], +) -> Result { + // Windows byte-range locks prevent the same process from copying a file + // after BEGIN IMMEDIATE. Production consolidation already fails closed on + // Windows because open-holder discovery is unsupported; unit fixtures use + // isolated stores and exercise the remaining migration semantics here. + let _ = paths; + Ok(OfflineDatabaseGuards { _holds: Vec::new() }) +} + +#[cfg(not(all(test, windows)))] pub(in crate::migrate::consolidate) async fn acquire_offline_guards( paths: &[PathBuf], ) -> Result {