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:
@@ -114,9 +114,7 @@ fn dismiss_campaign_ids_at(
|
||||
let nonce = DISMISS_TMP_NONCE.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = path.with_extension(format!("json.{}.{}.tmp", std::process::id(), nonce));
|
||||
std::fs::write(&tmp, &json)?;
|
||||
std::fs::rename(&tmp, &path).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})
|
||||
crate::util::fs::replace_file(&tmp, &path)
|
||||
}
|
||||
|
||||
/// `KIGI_CAMPAIGNS_OVERRIDE` JSON array replaces all sources (`[]` = none; beats
|
||||
|
||||
@@ -367,7 +367,9 @@ pub async fn save_mcp_disabled_tools(server_name: &str, disabled_tools: &[String
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
tokio::fs::write(&tmp, &toml_str).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -419,7 +421,9 @@ pub async fn save_mcp_server_enabled(server_name: &str, enabled: bool) -> Result
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
tokio::fs::write(&tmp, &toml_str).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -476,7 +480,10 @@ pub async fn save_mcp_server_config_at(
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
tokio::fs::write(&tmp, &toml_str).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
let dest = path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -553,7 +560,12 @@ pub async fn delete_mcp_server_config_at(
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
tokio::fs::write(&tmp, &toml_str).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
{
|
||||
let dest = path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
}
|
||||
|
||||
// Clean up OAuth credentials for the deleted server.
|
||||
if let Ok(mut cred_store) = kigi_mcp::credentials::McpCredentialStore::load_default() {
|
||||
|
||||
@@ -88,7 +88,12 @@ pub async fn save_config(config: &Config) -> Result<()> {
|
||||
}
|
||||
let _ = prior_mode;
|
||||
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
// Windows-safe replace (delete-first + retry on sharing violations) —
|
||||
// a bare rename made `/model` persistence silently fail on Windows
|
||||
// whenever AV/indexer/cloud-sync held config.toml open.
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("config replace task: {e}"))??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -138,11 +143,8 @@ pub(crate) fn atomic_write_string(path: &std::path::Path, content: &str) -> std:
|
||||
}
|
||||
let _ = prior_mode;
|
||||
|
||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
// Windows-safe replace; cleans up the tmp file on failure itself.
|
||||
crate::util::fs::replace_file(&tmp, path)
|
||||
}
|
||||
|
||||
/// Merge `[toolset.ask_user_question]` into the root table. `[toolset]` is
|
||||
|
||||
Reference in New Issue
Block a user