fix(fs): Windows-safe atomic replace everywhere — model switch now sticks

Root cause of 'model+effort switch works on Mac, not on Windows': the
switch APPLIES in-session (the dispatch/apply chain is platform-identical,
verified adversarially) but its persistence never sticks on Windows.
Every tmp+rename atomic write except auth/storage.rs committed with a
bare fs::rename, and Windows MoveFileExW(REPLACE_EXISTING) fails with a
sharing violation whenever AV/search-indexer/cloud-sync transiently holds
the destination open. Consequences: [models].default never persisted
(next launch = original model), the session summary's current model never
persisted (resume = original model), and the models cache went silently
stale (all its write errors were swallowed).

- New kigi_shell_base::util::fs::replace_file — THE commit step for
  tmp+rename: plain rename on Unix; on Windows delete-first + two short
  backoffs (the pattern auth/storage.rs shipped first), tmp cleaned on
  failure, error always returned. Windows branch type-checked against
  x86_64-pc-windows-msvc.
- Adopted at every replace site: config.toml (save_config /
  atomic_write_string / mcp saves), models cache (plus unique tmp
  suffixes and tracing::warn on failure — writes were fully silent),
  session storage (summary/current-model, jsonl, plan/signals/
  announcement/goal/graph state), auth.json, active-sessions registry,
  prompt history, claude/kimi import, campaigns state, goal artifacts.
  Directory-move renames (worktree pool, corrupt-file backups) keep
  plain rename — their destinations don't pre-exist.

Verified: kigi-shell + kigi-shell-base 5318 tests green, clippy clean,
msvc-target check of the new cfg(windows) code clean.
This commit is contained in:
2026-07-22 19:42:13 -04:00
parent 815bd99356
commit 27d009cb6e
18 changed files with 191 additions and 62 deletions
@@ -0,0 +1,87 @@
//! Filesystem primitives shared across the shell.
use std::io;
use std::path::Path;
/// Replace `dest` with `tmp` — the commit step of every tmp+rename atomic
/// write in the product. This is the ONE place that knows how to make that
/// commit stick on Windows; call sites must never inline a bare
/// `fs::rename` replace again.
///
/// Unix `rename(2)` replaces atomically and needs no help. Windows
/// `MoveFileExW(REPLACE_EXISTING)` fails with a sharing violation while
/// ANOTHER process (antivirus scanner, search indexer, cloud sync) holds
/// `dest` open — the classic "persists on macOS, silently doesn't on
/// Windows" failure (a model switch that never sticks, a stale models
/// cache). On Windows a failed rename therefore deletes the destination
/// first (the pattern `auth/storage.rs` shipped first) and retries with two
/// short back-offs for scanners that hold the file for a few milliseconds.
///
/// On final failure the tmp file is removed (no litter) and the error is
/// returned — callers decide severity, but MUST at least log it (errors
/// never pass silently).
pub fn replace_file(tmp: &Path, dest: &Path) -> io::Result<()> {
let result = replace_file_inner(tmp, dest);
if result.is_err() {
let _ = std::fs::remove_file(tmp);
}
result
}
#[cfg(not(windows))]
fn replace_file_inner(tmp: &Path, dest: &Path) -> io::Result<()> {
std::fs::rename(tmp, dest)
}
#[cfg(windows)]
fn replace_file_inner(tmp: &Path, dest: &Path) -> io::Result<()> {
let mut last = match std::fs::rename(tmp, dest) {
Ok(()) => return Ok(()),
Err(e) => e,
};
for backoff_ms in [0u64, 10, 50] {
if backoff_ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(backoff_ms));
}
// Delete-first: marks an open-with-delete-sharing file for deletion
// and clears the way for a plain rename; harmless when absent.
let _ = std::fs::remove_file(dest);
match std::fs::rename(tmp, dest) {
Ok(()) => return Ok(()),
Err(e) => last = e,
}
}
Err(last)
}
#[cfg(test)]
mod tests {
use super::*;
/// The common contract on every platform: replace over an existing
/// destination, create a missing one, and error (cleaning the tmp)
/// when the tmp itself is missing.
#[test]
fn replace_file_commits_and_cleans_up() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("target.json");
let tmp = dir.path().join("target.json.tmp");
// Create-missing.
std::fs::write(&tmp, b"v1").unwrap();
replace_file(&tmp, &dest).expect("create");
assert_eq!(std::fs::read(&dest).unwrap(), b"v1");
assert!(!tmp.exists(), "tmp must be consumed");
// Replace-existing.
std::fs::write(&tmp, b"v2").unwrap();
replace_file(&tmp, &dest).expect("replace");
assert_eq!(std::fs::read(&dest).unwrap(), b"v2");
assert!(!tmp.exists());
// Missing tmp → error, dest untouched.
let err = replace_file(&tmp, &dest).expect_err("missing tmp must fail");
assert_eq!(err.kind(), io::ErrorKind::NotFound);
assert_eq!(std::fs::read(&dest).unwrap(), b"v2");
}
}
@@ -1,4 +1,5 @@
pub mod event_id;
pub mod fs;
pub mod kigi_home;
pub mod secure_file;
pub mod tips;