fix(session_context): atomic write to eliminate trailing-characters corruption - #853
Conversation
session_context's save() wrote the store with fs::write directly. fs::write truncates per call, but two concurrent save() calls in one turn (e.g. several session_context set operations fired together) do not serialize: a shorter write landing over a longer one leaves the longer object's tail bytes in place, producing "trailing characters" JSON parse failures on the next read. This was the single largest source of tool failures in the feedback ledger (~57% of all logged failures over weeks). Fix: write to a uniquely-named temp file in the same directory, then atomically rename it over the target. The unique temp name (pid + monotonic counter) prevents two saves from racing on the temp file itself; rename is atomic on the same filesystem, so the store is always either the previous complete object or the new one -- never a mix.
|
Reviewed against a local checkout. Short version: the diagnosis is right, the fix is right, it passes the gates, and I would like a test and a cleanup path before merge. VerifiedThe race is real and correctly described. Worked through it independently: The fix is sound. Unique temp name (pid + atomic counter) in the same directory, then rename. Same filesystem, so the rename is atomic and a reader sees either the previous complete object or the new one. It also turns a latent Gates pass on the branch, using the repo's CI commands: Note the PR mentions The evidence does not reproduce hereRan the claim against the maintainer instance's ledger: SELECT COUNT(*) FROM feedback_ledger WHERE event_type='tool_failure';
-- 7295
SELECT COUNT(*) FROM feedback_ledger
WHERE event_type='tool_failure' AND metadata LIKE '%trailing characters%';
-- 0Zero matches, zero One thing worth double-checking on your side: in this ledger This does not undermine the patch. The race is demonstrable from the code alone, and the snapshot byte counts in the PR body are concrete. It does mean the impact claim is currently unverifiable outside your deployment, which is a good argument for the test below. Suggested before merge1. The regression test the PR proposes. It is the thing that turns the impact claim into something reproducible, and it is short. Tests live under // N concurrent saves with different payload sizes, then every load must parse.
// Against the old fs::write this fails; against rename it holds.
let saves = (0..32).map(|i| {
let path = path.clone();
tokio::spawn(async move {
let mut store = ContextStore::new("s".into());
// vary the serialized length so a short write can land over a long one
store.set("k", &"x".repeat(if i % 2 == 0 { 8_000 } else { 100 }));
store.save(&path).await
})
});
futures::future::join_all(saves).await;
assert!(ContextStore::load(&path, "s").await.is_ok());2. Clean up the temp file when rename fails. If if let Err(e) = fs::rename(&tmp, path).await {
let _ = fs::remove_file(&tmp).await; // best effort, log the original error
return Err(ToolError::Io(e));
}3. Worth a comment, not necessarily a change: no fsync before rename. The write is atomic against concurrent writers but not against power loss, where you can end up with a renamed but unflushed file. For a transient context store that is probably an acceptable trade, but it is worth being explicit that "atomic" here means "against racing writers" so the next reader of this code does not assume durability. HousekeepingThe branch is based on Thanks for the clear write-up, particularly the byte-level snapshot table. Diagnosing this from parse errors alone is not easy, and the "out of scope" section correctly identifies that last-writer-wins remains after this change. |
fix(session_context): write context store atomically
Target branch:
v0.3.77(commit90adbd3). Patch commit:8a114c4.Files:
src/brain/tools/context.rs(+23/−5). No new deps, no feature flags.Problem
session_context'ssave()wrote the per-session store withfs::write(path, content)directly.fs::writetruncates on open, but two concurrentsave()calls do not serialize. When severalsession_context setops fire in one turn (load → mutate → save), their writes interleave at the syscall level: a shorter write landing over a longer one leaves the longer object's tail bytes in place. The file becomes{valid shorter object}{leftover tail}and the next read fails:Impact
This was the single largest source of tool failures in the instance's feedback ledger — ~57% of all logged
tool_failureevents over weeks (4,465 identical "trailing characters" errors). Corruption is transient (a later clean write repairs it), so it shows as a high-but-not-permanent rate, with hard on-disk evidence frozen in repair snapshots:context_<id>.json.bak.<ts>context_<id>.json.workbak}Root cause
fs::writeis not atomic and provides no mutual exclusion across concurrent writers to the same path.Fix
Write to a uniquely-named temp file in the same directory, then atomically rename it over the target:
pid+ monotonic counter) → two concurrent saves never race on the temp file itself.renameis atomic on the same filesystem → the store is always either the previous complete object or the new one, never a mix.Out of scope
setcalls could still drop one update (no corruption, but a lost change). A per-sessionMutexaround load→mutate→save would close that; intentionally left out to keep the patch minimal and reviewable. The trigger (dense concurrentsession_contextbursts) is also addressed operationally via agent discipline.Testing
cargo check(see background run).save()calls with varying payload sizes, then assert everyload()returns a valid, complete object.