fix(session): hold a single-writer lock on session JSONL files - #168
Closed
jasonkneen wants to merge 1 commit into
Closed
jasonkneen wants to merge 1 commit into
jasonkneen wants to merge 1 commit into
Conversation
Invariant: each durable record has exactly one writer. SessionManager appended
to the session JSONL with appendFileSync and only used O_EXCL on the first
flush, so two processes resuming the same session interleaved parentId chains
and silently forked the tree.
A persisting SessionManager now takes an advisory <sessionFile>.lock (O_EXCL,
{pid, hostname, startedAt}) lazily before its first write, and releases it on
setSessionFile/newSession/createBranchedSession, the new dispose(), runtime
session teardown, and best-effort on process exit. Same-host locks whose pid
is dead are reclaimed; a live foreign owner yields a clear error naming the
pid and host. In-memory and read-only sessions create no lock, and listing
ignores .lock files.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved critical and moderate lock ownership, cleanup, and read-only mutation issues remain.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (3)
What changed in this PR
Adds lazy advisory single-writer locks for session JSONL files, including stale-lock recovery, lifecycle integration, and test coverage.
Changes:
- Adds PID/hostname ownership, reference counting, and exit cleanup.
- Integrates locking with persistence, session transitions, runtime teardown, and renaming.
- Adds unit and end-to-end conflict tests.
| File | Summary and final findings |
|---|---|
packages/coding-agent/test/session-manager/writer-lock.test.ts |
Adds session-manager lock coverage. |
packages/coding-agent/test/agent-session-writer-lock.test.ts |
Adds end-to-end conflict handling coverage. |
packages/coding-agent/src/core/session-writer-lock.ts |
Implements locking. Critical (3 votes): malformed or replaced locks may be removed without confirmed ownership. Moderate (1 vote): owner publication is non-atomic. |
packages/coding-agent/src/core/session-manager.ts |
Integrates lock lifecycle. Critical (1 vote): read-only loading can modify an unterminated file without a lock. Moderate (1 vote): export migration locks may remain held. Moderate (1 vote): failed branch replacement paths may abandon locks. |
packages/coding-agent/src/core/agent-session-runtime.ts |
Adds runtime cleanup. Moderate (1 vote): teardown callback errors can skip disposal. Moderate (1 vote): quit callback errors can skip disposal. Moderate (2 votes): lifecycle-race replacements can retain locks. |
apps/cli/src/ui/interactive-mode.ts |
Disposes transient rename managers. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| } | ||
|
|
||
| private _setSessionFile(sessionFile: string, preloadedFileEntries?: FileEntry[]): void { | ||
| this._releaseWriterLock(); |
| /** Remove a lock file unless it has since been taken over by another process. */ | ||
| function unlinkIfOurs(lockPath: string): void { | ||
| const owner = readOwner(lockPath); | ||
| if (owner && (owner.pid !== process.pid || owner.hostname !== hostname())) return; |
Comment on lines
+265
to
+267
| // Release the outgoing session file's writer lock so other processes can | ||
| // resume it. A manager reused by the replacement reacquires it on write. | ||
| session.sessionManager.dispose(); |
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.


Problem
SessionManager._persist(packages/coding-agent/src/core/session-manager.ts ~1016-1043) appends to the session JSONL withappendFileSync. The only exclusivity wasopenSync(..., "wx")on the very first flush (~1031). If two processes resume the same session (two terminals running--resume/--session <same file>, or a respawned subagent child racing its predecessor), both append. TheirparentIdchains interleave and the tree forks silently. That breaks the invariant that each durable record has exactly one writer.Fix
packages/coding-agent/src/core/session-writer-lock.tsadds an advisory<sessionFile>.lock. It is created withO_EXCL(openSync(path, "wx", 0o600)) and holds{pid, hostname, startedAt}.proper-lockfile(settings/auth/trust) and the step-cronwx+ mtime lock are short-lived mutexes with mtime staleness. A lock held for a whole session would need a keepalive timer. The conflict error also could not name the owning pid/host. The step-cronopenSync(..., "wx")shape is kept, but mtime staleness is replaced with a pid liveness check.process.kill(pid, 0)gives ESRCH. A stale lock is reclaimed with unlink and one morewxattempt. EPERM counts as alive. A lock from another host is always treated as held.process.once("exit")the process makes a best-effort release of every lock it still holds, but only when the lock file still names this pid.SessionManagertakes the lock lazily, right before its first write: at the top of_persist(both the append path and the firstwxflush) and in_rewriteFile(empty-file init, migration rewrite,createBranchedSession). No lock is created for in-memory managers or for sessions that are only opened to read or list, such asexport-htmland the session picker.setSessionFile,newSession,createBranchedSession(before it switches to the new file) and the newSessionManager.dispose().dispose()only releases the lock. The manager takes it again if it writes later.AgentSessionRuntime.teardownCurrentanddisposenow callsessionManager.dispose(). A resume, fork or quit therefore frees the outgoing file for other processes instead of holding it until this process exits.interactive-moderename-from-selector now callsmgr.dispose()afterappendSessionInfo, so the transient manager does not keep the renamed file locked.Session <file> is already open in another process (pid X on host Y). Close it or use --fork to continue in a new session. If that process is gone, delete <file>.lock..jsonl.lockdoes not end in.jsonl, solist,listAllandfindMostRecentSessionalready skip it. A test now pins that.Test
packages/coding-agent/test/session-manager/writer-lock.test.tshas 10 tests:process.ppid) makes append throw the clear error and leaves the file unchangedspawnSyncchild) is reclaimednewSessionandsetSessionFilecreateBranchedSession.lockpackages/coding-agent/test/agent-session-writer-lock.test.tsis an end-to-end check.AgentSession.prompt()on a resumed session that another process has locked rejects with the lock error. The model is never called and the file is unchanged. This shows the error reaches the interactive loop'sshowErrorand print mode's prompt rejection handling.manager.dispose is not a function, andexpected false to be truefor the lock file existing). After the fix, all pass.Risk / behaviour change
--resumea locked session and view it. The error shows when the user sends the first prompt. That prompt fails before any model call and nothing is persisted. The CLI flows were not redesigned to lock at open time, becauseSessionManager.openis also used read-only (export-html, the picker)..lockfile behind. The next writer on the same host reclaims it automatically because the pid is dead. No SIGINT/SIGTERM handlers were added. A normal exit releases locks through theexithook.fileEntries. The prompt fails, so this matches the existing behaviour of a failed append.--mode rpc --session-id <subagent-uuid>with its own session id and file, so children never share the parent's file. A lane respawn resumes the same id. If the old child is somehow still alive, the new one now fails loudly instead of interleaving. A dead predecessor's lock is reclaimed.Merge note: interaction between #168 (session writer lock) and #172 (subagent inherit settings)
When both
fix/harness-session-writer-lockandfix/harness-subagent-inherit-settingsare merged, replacing an idle keep-alive subagent lane (on a parent permission change) resumes its transcript right after the old child is told to stop. The old child may still hold the session writer lock, so the replacement can fail with "session is already open". Whichever merges second should await the old child's exit before spawning the replacement.https://claude.ai/code/session_01GUdnnHEaDThHUATSwXBpV9