Skip to content

Commit 2f0b5b3

Browse files
bellmanGajae Code
authored andcommitted
fix: wrap concurrent ENOENT as domain-specific session error (#112)
Session save_to_path now wraps ENOENT errors from rotate and atomic write with a clear "possible concurrent modification" message instead of surfacing raw OS errno. Helps operators debugging race conditions when multiple claw invocations touch the same session file. Generated with https://github.com/Yeachan-Heo/gajae-code Co-authored-by: Gajae Code <dev@gajae-code.com>
1 parent c848eeb commit 2f0b5b3

2 files changed

Lines changed: 26 additions & 3 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3394,7 +3394,7 @@ ear], /color [scheme], /effort [low|medium|high], /fast, /summary, /tag [label],
33943394

33953395
**Source.** Jobdori dogfood 2026-04-18 against `/tmp/cdHH` on main HEAD `b2366d1` in response to Clawhip pinpoint nudge at `1494872623782301817`. Joins **silent-flag / documented-but-unenforced** (#96–#101, #104, #108) on the command-dispatch-semantics axis — eighth instance of "documented behavior differs from actual." Joins **unplumbed-subsystem / CLI-advertised-but-unreachable** (#78, #96, #100, #102, #103, #107, #109) as the eighth surface where the spec advertises a capability the implementation doesn't deliver. Joins **truth-audit / diagnostic-integrity** (#80–#87, #89, #100, #102, #103, #105, #107, #109, #110) — `/providers` silently returns doctor output under the wrong kind label; help lies about capability. Natural bundle: **#78 + #96 + #111** — three-way "declared but not implemented as declared" triangle (CLI route never constructed + help resume-safe leaks stubs + slash command dispatches to wrong handler). Also **#96 + #108 + #111** — full `--help`/dispatch surface hygiene quartet covering help-filter-leaks + subcommand typo fallthrough + slash-command mis-dispatch. Session tally: ROADMAP #111.
33963396

3397-
112. **Concurrent claw invocations that touch the same session file (e.g. two `/clear --confirm` or two `/compact` calls on the same session-id race) fail intermittently with a raw OS errno — `{"type":"error","error":"No such file or directory (os error 2)"}` — instead of a domain-specific concurrent-modification error. There is no file locking, no read-modify-write protection, no rename-race guard. The loser of the race gets ENOENT because the winner rotated, renamed, or deleted the session file between the loser's `fs::read_to_string` and its own `fs::write`. A claw orchestrating multiple lanes that happen to share a session id (because the operator reuses one, or because a CI matrix is re-running with the same state) gets unpredictable partial failures with un-actionable raw-io errors** — dogfooded 2026-04-18 on main HEAD `a049bd2` from `/tmp/cdII`. Five concurrent `/compact` calls on the same session: 4 succeed, 1 fails with `os error 2`. Two concurrent `/clear --confirm` calls: same pattern.
3397+
112. **DONE — Concurrent claw invocations that touch the same session file (e.g. two `/clear --confirm` or two `/compact` calls on the same session-id race) fail intermittently with a raw OS errno — `{"type":"error","error":"No such file or directory (os error 2)"}` — instead of a domain-specific concurrent-modification error. There is no file locking, no read-modify-write protection, no rename-race guard. The loser of the race gets ENOENT because the winner rotated, renamed, or deleted the session file between the loser's `fs::read_to_string` and its own `fs::write`. A claw orchestrating multiple lanes that happen to share a session id (because the operator reuses one, or because a CI matrix is re-running with the same state) gets unpredictable partial failures with un-actionable raw-io errors** — dogfooded 2026-04-18 on main HEAD `a049bd2` from `/tmp/cdII`. Five concurrent `/compact` calls on the same session: 4 succeed, 1 fails with `os error 2`. Two concurrent `/clear --confirm` calls: same pattern.
33983398

33993399
**Concrete repro.**
34003400
```

rust/crates/runtime/src/session.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,31 @@ impl Session {
231231
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SessionError> {
232232
let path = path.as_ref();
233233
let snapshot = self.render_jsonl_snapshot()?;
234-
rotate_session_file_if_needed(path)?;
235-
write_atomic(path, &snapshot)?;
234+
// #112: wrap ENOENT during rotate as concurrent modification
235+
match rotate_session_file_if_needed(path) {
236+
Ok(()) => {}
237+
Err(SessionError::Io(ref io_err)) if io_err.kind() == std::io::ErrorKind::NotFound => {
238+
return Err(SessionError::Io(std::io::Error::new(
239+
std::io::ErrorKind::NotFound,
240+
format!(
241+
"session file was removed during save (possible concurrent modification): {io_err}"
242+
),
243+
)));
244+
}
245+
Err(e) => return Err(e),
246+
}
247+
write_atomic(path, &snapshot).map_err(|e| {
248+
// #112: wrap ENOENT during write as concurrent modification
249+
match &e {
250+
SessionError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => {
251+
SessionError::Io(std::io::Error::new(
252+
std::io::ErrorKind::NotFound,
253+
format!("session file was removed during write (possible concurrent modification): {io_err}"),
254+
))
255+
}
256+
_ => e,
257+
}
258+
})?;
236259
cleanup_rotated_logs(path)?;
237260
Ok(())
238261
}

0 commit comments

Comments
 (0)