fix(store): harden JSON-file conversation store — atomic write race (#502) + list shape-guard (#501) - #512
Draft
TYRMars wants to merge 2 commits into
Draft
fix(store): harden JSON-file conversation store — atomic write race (#502) + list shape-guard (#501)#512TYRMars wants to merge 2 commits into
TYRMars wants to merge 2 commits into
Conversation
atomicWrite derived its staging file solely from the target path (`<path>.tmp`), so all concurrent writers of one row shared a single tmp. Writer B truncated and rewrote A's tmp; whoever renamed first consumed it, and the loser's rename hit ENOENT and its write was silently dropped — reachable via double-submit / retry / two tabs on one conversation through the catch-and-ignore REST message routes. Make the staging name unique per write (pid + randomUUID) so each writer's rename is independent and last-write-wins holds cleanly, and remove the tmp in a `finally` so a failed write or lost rename race leaves no litter. Fixes every JSON-file store plus @jarvis/observability and @jarvis/learning, which all route writes through this helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmFYS8i3bgR4a2JSNQUkHw
list() blind-cast every parsed .json in the base dir to OnDiskConversation and dereferenced `stored.messages.length` with no shape guard and no per-file try/catch. A foreign-but-well-formed JSON file (e.g. JsonFileWorkspaceStore's workspaces.json flushed into the same dir, a truncated-but-parseable row, or a `..`-partitioned foreign row) parses fine, has no `messages` array, and throws a TypeError that escapes list() and permanently 500s GET /v1/conversations — the sidebar goes empty and stays empty. Validate before dereferencing (skip when `id` isn't a string or `messages` isn't an array) and wrap the per-file body in try/catch so one bad entry is dropped rather than killing the listing — matching the tolerance activity-store and observability/json-file already have. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmFYS8i3bgR4a2JSNQUkHw
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two related robustness fixes to
packages/store/src/json-file.ts(the default JSON-file conversation store, which also backs@jarvis/observabilityand@jarvis/learning).#502 — silent data loss on concurrent writes
atomicWritederived its staging file solely from the target path (`${filePath}.tmp`), so all concurrent writers of one row shared one.tmp. Writer B truncates/rewrites A's tmp; whoever renames first consumes it, and the secondrenamehitsENOENTand drops that writer's turn silently. Reachable without a second socket —POST /v1/conversations/:id/messagesand.../messages/streamhave no in-flight guard and both catch-and-ignore the save failure, so a double-submit / retry / two tabs on one conversation loses a whole turn with nothing shown to the user.Fix: stage to a per-write unique tmp —
`${filePath}.${process.pid}.${randomUUID()}.tmp`— so each writer'srenameis independent and last-write-wins holds cleanly; remove the tmp in afinallyso a failed write / lost race leaves no litter.#501 — one foreign
.jsonpermanently 500sGET /v1/conversationslist()blind-cast every parsed.jsonin the base dir toOnDiskConversationand dereferencedstored.messages.lengthwith no shape guard and no per-filetry/catch. A foreign-but-well-formed JSON object (e.g.JsonFileWorkspaceStore'sworkspaces.jsonflushed into the same dir, a truncated-but-parseable row, or a..-partitioned foreign row) parses fine, has nomessagesarray, and throws aTypeErrorthat escapeslist()and permanently 500s the endpoint — the sidebar goes empty and stays empty.Fix: validate before dereferencing (skip when
idisn't a string ormessagesisn't an array) and wrap the per-file body intry/catch, matching the toleranceactivity-storeandobservability/json-filealready have.Testing
packages/store/src/atomic-write.test.ts: 12 writers race one target → noENOENT, final file is exactly one full payload (no torn write), no litter. Plus a single-writer exact-contents check.store.test.ts:list()skipsworkspaces.json, a wrong-shape row, and a top-level array without throwing; the real conversation still lists.store.test.tsJSON-file contract suite: 20/20 pass.pnpm --filter @jarvis/store typecheckclean; eslint clean on changed files.Closes #502, #501.
🤖 Generated with Claude Code