Skip to content

Commit 409d1a7

Browse files
clawsweeper[bot]spencer2211claudeTakhoffman
authored
fix(agents): release session write lock if fence read throws on prompt release (#89811)
Summary: - The PR makes prompt-release fence bookkeeping exception-safe so the session write lock is released even when fence reads throw, and adds a regression test for that path. - PR surface: Source +6, Tests +27. Total +33 across 2 files. - Reproducibility: yes. source-reproducible with provided real-output proof: current main clears `heldLock` be ... ire timing out after an injected `EIO`. I did not run the harness locally because this review is read-only. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(agents): release session write lock if fence read throws on promp… Validation: - ClawSweeper review passed for head 394d978. - Required merge gates passed before the squash merge. Prepared head SHA: 394d978 Review: #89811 (comment) Co-authored-by: Spencer Fuller <spencer.p.fuller@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
1 parent d31f4e2 commit 409d1a7

2 files changed

Lines changed: 44 additions & 11 deletions

File tree

src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const lockOptions = {
3434
const tempDirs: string[] = [];
3535

3636
afterEach(async () => {
37+
vi.restoreAllMocks();
3738
resetEmbeddedAttemptSessionFileOwnersForTest();
3839
resetSessionWriteLockStateForTest();
3940
for (const dir of tempDirs.splice(0)) {
@@ -136,6 +137,32 @@ describe("embedded attempt session lock lifecycle", () => {
136137
expect(releases).toEqual(["held"]);
137138
});
138139

140+
it("releases the eagerly-held lock when the fence read throws during prompt release", async () => {
141+
const release = vi.fn(async () => {});
142+
const acquireSessionWriteLockLocalFad845 = vi.fn(async () => ({ release }));
143+
const controller = await createEmbeddedAttemptSessionLockController({
144+
acquireSessionWriteLock: acquireSessionWriteLockLocalFad845,
145+
lockOptions,
146+
});
147+
148+
// Simulate a transient, non-ENOENT filesystem error (e.g. EIO/EMFILE) while the
149+
// prompt-release path reads the session-file fence. This fires AFTER the controller
150+
// has cleared its in-memory `heldLock` reference but BEFORE the underlying file lock
151+
// is released, which is the window that orphans the lock.
152+
const statError = Object.assign(new Error("simulated I/O failure"), { code: "EIO" });
153+
const statSpy = vi.spyOn(fs, "stat").mockRejectedValueOnce(statError);
154+
155+
try {
156+
await expect(controller.releaseForPrompt()).rejects.toThrow();
157+
158+
// The underlying file lock must still be released so later turns do not wait for
159+
// the full maxHoldMs watchdog before the stale lease is reclaimed.
160+
expect(release).toHaveBeenCalledTimes(1);
161+
} finally {
162+
statSpy.mockRestore();
163+
}
164+
});
165+
139166
it("releaseHeldLockForAbort and dispose are idempotent in succession (#86816)", async () => {
140167
const release = vi.fn(async () => {});
141168
const acquireSessionWriteLockLocal26 = vi.fn(async () => ({ release }));

src/agents/embedded-agent-runner/run/attempt.session-lock.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -801,17 +801,23 @@ export async function createEmbeddedAttemptSessionLockController(params: {
801801
}
802802
const lock = heldLock;
803803
heldLock = undefined;
804-
const fingerprint = await readSessionFileFingerprint(params.lockOptions.sessionFile);
805-
const ownedWrite = ownedSessionFileWrites.get(sessionFileFenceKey);
806-
const trustedGeneration = trustSessionFileState(sessionFileFenceKey, fingerprint);
807-
fenceFingerprint = fingerprint;
808-
fenceSnapshot = await readSessionFileFenceSnapshot(params.lockOptions.sessionFile);
809-
fenceGeneration =
810-
ownedWrite && sameSessionFileFingerprint(ownedWrite.fingerprint, fingerprint)
811-
? ownedWrite.generation
812-
: (trustedGeneration ?? fenceGeneration);
813-
fenceActive = true;
814-
await lock.release();
804+
// Clearing `heldLock` transfers release ownership to this block. Fence reads can
805+
// throw after that transfer; release the underlying file lock anyway so later
806+
// turns do not wait for the maxHoldMs watchdog.
807+
try {
808+
const fingerprint = await readSessionFileFingerprint(params.lockOptions.sessionFile);
809+
const ownedWrite = ownedSessionFileWrites.get(sessionFileFenceKey);
810+
const trustedGeneration = trustSessionFileState(sessionFileFenceKey, fingerprint);
811+
fenceFingerprint = fingerprint;
812+
fenceSnapshot = await readSessionFileFenceSnapshot(params.lockOptions.sessionFile);
813+
fenceGeneration =
814+
ownedWrite && sameSessionFileFingerprint(ownedWrite.fingerprint, fingerprint)
815+
? ownedWrite.generation
816+
: (trustedGeneration ?? fenceGeneration);
817+
fenceActive = true;
818+
} finally {
819+
await lock.release();
820+
}
815821
} finally {
816822
finishHeldLockDrain(drainOwner);
817823
}

0 commit comments

Comments
 (0)