Skip to content

Commit 817dcd6

Browse files
matinclaude
andcommitted
fix(agents): bound abort-path session lock release; force-release on unsettled retained writes (#6)
A turn timeout aborts the run, but releaseHeldLockForAbort delegated to the graceful fence release, whose waitForRetainedLockIdle is unbounded. A retained transcript write pinned behind a hung provider stream (one that ignores abort, e.g. a stalled streamGenerateContent) therefore held the session .jsonl lock until the maxHoldMs watchdog (5-30 min), and every turn in between failed with SessionWriteLockTimeoutError. releaseHeldLockForAbort now races the graceful path against the abort settle bound (OPENCLAW_EMBEDDED_ABORT_SETTLE_TIMEOUT_MS). When the bound expires the underlying file lock is force-released and the controller poisoned via takeoverDetected, so the aborted run cannot perform torn writes after losing ownership. Retained writes that settle within the bound keep the existing graceful fence semantics. Prod incident: tulgey#225 (membrane 2026-06-01..03). Completes the lock-leak family of #87278 / #88623 / #89811, which release on settled aborts but not on aborts a hung provider call never lets settle. Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 01d6904 commit 817dcd6

2 files changed

Lines changed: 162 additions & 1 deletion

File tree

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,73 @@ describe("embedded attempt session lock lifecycle", () => {
163163
}
164164
});
165165

166+
it("force-releases the held lock when a retained write never settles (tulgey#225)", async () => {
167+
const release = vi.fn(async () => {});
168+
const acquireSessionWriteLockStuck = vi.fn(async () => ({ release }));
169+
const controller = await createEmbeddedAttemptSessionLockController({
170+
acquireSessionWriteLock: acquireSessionWriteLockStuck,
171+
lockOptions,
172+
abortReleaseTimeoutMs: 20,
173+
});
174+
175+
let markWriteStarted!: () => void;
176+
const writeStarted = new Promise<void>((resolve) => {
177+
markWriteStarted = resolve;
178+
});
179+
// A retained-lock write pinned behind a provider call that never settles
180+
// (hung stream that ignores abort).
181+
void controller.withSessionWriteLock(async () => {
182+
markWriteStarted();
183+
await new Promise<void>(() => {});
184+
});
185+
await writeStarted;
186+
187+
await controller.releaseHeldLockForAbort();
188+
189+
expect(release).toHaveBeenCalledTimes(1);
190+
// The aborted run lost ownership; later writes from it must not tear the
191+
// session file out from under the next turn's lock.
192+
await expect(controller.withSessionWriteLock(async () => {})).rejects.toBeInstanceOf(
193+
EmbeddedAttemptSessionTakeoverError,
194+
);
195+
await controller.dispose();
196+
expect(release).toHaveBeenCalledTimes(1);
197+
});
198+
199+
it("abort release stays graceful when retained writes settle within the bound (tulgey#225)", async () => {
200+
const release = vi.fn(async () => {});
201+
const acquireSessionWriteLockSettles = vi.fn(async () => ({ release }));
202+
const controller = await createEmbeddedAttemptSessionLockController({
203+
acquireSessionWriteLock: acquireSessionWriteLockSettles,
204+
lockOptions,
205+
abortReleaseTimeoutMs: 5_000,
206+
});
207+
208+
let finishWrite!: () => void;
209+
const writeCanFinish = new Promise<void>((resolve) => {
210+
finishWrite = resolve;
211+
});
212+
let markWriteStarted!: () => void;
213+
const writeStarted = new Promise<void>((resolve) => {
214+
markWriteStarted = resolve;
215+
});
216+
const activeWrite = controller.withSessionWriteLock(async () => {
217+
markWriteStarted();
218+
await writeCanFinish;
219+
});
220+
await writeStarted;
221+
222+
const abortRelease = controller.releaseHeldLockForAbort();
223+
finishWrite();
224+
await activeWrite;
225+
await abortRelease;
226+
227+
expect(release).toHaveBeenCalledTimes(1);
228+
// Graceful release keeps the fence active — no takeover poisoning.
229+
await expect(controller.withSessionWriteLock(async () => {})).resolves.toBeUndefined();
230+
await controller.dispose();
231+
});
232+
166233
it("releaseHeldLockForAbort and dispose are idempotent in succession (#86816)", async () => {
167234
const release = vi.fn(async () => {});
168235
const acquireSessionWriteLockLocal26 = vi.fn(async () => ({ release }));

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

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { resolveGlobalSingleton } from "../../../shared/global-singleton.js";
88
import { isSessionWriteLockAcquireError } from "../../session-write-lock-error.js";
99
import type { acquireSessionWriteLock } from "../../session-write-lock.js";
1010
import { resolveEmbeddedSessionFileKey } from "../session-file-key.js";
11+
import { resolveEmbeddedAbortSettleTimeoutMs } from "./attempt.abort-settle-timeout.js";
1112

1213
type SessionLock = Awaited<ReturnType<typeof acquireSessionWriteLock>>;
1314
type AcquireSessionWriteLock = typeof acquireSessionWriteLock;
@@ -586,6 +587,8 @@ export type EmbeddedAttemptSessionLockController = {
586587
export async function createEmbeddedAttemptSessionLockController(params: {
587588
acquireSessionWriteLock: AcquireSessionWriteLock;
588589
lockOptions: LockOptions;
590+
/** Bounded wait before an abort release force-releases the held lock. */
591+
abortReleaseTimeoutMs?: number;
589592
}): Promise<EmbeddedAttemptSessionLockController> {
590593
const acquireLock = async (): Promise<SessionLock> =>
591594
await params.acquireSessionWriteLock({
@@ -823,6 +826,97 @@ export async function createEmbeddedAttemptSessionLockController(params: {
823826
}
824827
}
825828

829+
/**
830+
* Abort-path release. Races the graceful fence release against a bounded
831+
* settle window. A retained-lock write pinned behind an unsettled provider
832+
* call (a hung stream that ignores abort) keeps `waitForRetainedLockIdle`
833+
* pending forever; without the bound, the lock is only reclaimed by the
834+
* `maxHoldMs` watchdog and every turn in between fails with
835+
* `SessionWriteLockTimeoutError` (tulgey#225, upstream #86816 family).
836+
* On timeout the underlying file lock is force-released and the controller
837+
* is poisoned (`takeoverDetected`) so the aborted run cannot perform torn
838+
* writes after losing ownership.
839+
*/
840+
async function releaseHeldLockForAbortBounded(): Promise<void> {
841+
if (!heldLock) {
842+
await waitForHeldLockDrain();
843+
return;
844+
}
845+
const boundMs = params.abortReleaseTimeoutMs ?? resolveEmbeddedAbortSettleTimeoutMs();
846+
const raceWithBound = async <T>(promise: Promise<T>): Promise<T | "abort-bound"> => {
847+
let timer: NodeJS.Timeout | undefined;
848+
try {
849+
return await Promise.race([
850+
promise,
851+
new Promise<"abort-bound">((resolve) => {
852+
timer = setTimeout(() => resolve("abort-bound"), boundMs);
853+
timer.unref?.();
854+
}),
855+
]);
856+
} finally {
857+
if (timer) {
858+
clearTimeout(timer);
859+
}
860+
}
861+
};
862+
const forceRelease = async (): Promise<void> => {
863+
if (!heldLock) {
864+
return;
865+
}
866+
const lock = heldLock;
867+
heldLock = undefined;
868+
takeoverDetected = true;
869+
await lock.release();
870+
};
871+
872+
const drainAcquisition = beginHeldLockDrain();
873+
const drainOwner = await raceWithBound(drainAcquisition);
874+
if (drainOwner === "abort-bound") {
875+
// Another release path owns the drain and is itself stuck. Take release
876+
// ownership directly (clearing `heldLock` makes the stuck path no-op when
877+
// it resumes) and hand the eventually-acquired drain straight back.
878+
void drainAcquisition.then((owner) => finishHeldLockDrain(owner));
879+
await forceRelease();
880+
return;
881+
}
882+
try {
883+
const idle = await raceWithBound(waitForRetainedLockIdle());
884+
if (!heldLock) {
885+
return;
886+
}
887+
if (idle === true) {
888+
// Retained writes settled in time: keep the graceful fence semantics.
889+
const lock = heldLock;
890+
heldLock = undefined;
891+
try {
892+
const fingerprint = await readSessionFileFingerprint(params.lockOptions.sessionFile);
893+
const ownedWrite = ownedSessionFileWrites.get(sessionFileFenceKey);
894+
const trustedGeneration = trustSessionFileState(sessionFileFenceKey, fingerprint);
895+
fenceFingerprint = fingerprint;
896+
fenceSnapshot = await readSessionFileFenceSnapshot(params.lockOptions.sessionFile);
897+
fenceGeneration =
898+
ownedWrite && sameSessionFileFingerprint(ownedWrite.fingerprint, fingerprint)
899+
? ownedWrite.generation
900+
: (trustedGeneration ?? fenceGeneration);
901+
fenceActive = true;
902+
} finally {
903+
await lock.release();
904+
}
905+
return;
906+
}
907+
if (idle === false) {
908+
// Abort issued from inside an active write context; mirror the
909+
// graceful path and leave release to that write's unwind.
910+
return;
911+
}
912+
// Bounded wait expired: a retained write is pinned behind an unsettled
913+
// provider call. Force-release so the next turn can acquire the lock.
914+
await forceRelease();
915+
} finally {
916+
finishHeldLockDrain(drainOwner);
917+
}
918+
}
919+
826920
async function takeHeldLockAfterRetainedIdle(): Promise<SessionLock | undefined> {
827921
if (!heldLock) {
828922
return undefined;
@@ -902,7 +996,7 @@ export async function createEmbeddedAttemptSessionLockController(params: {
902996
await releaseHeldLockWithFence();
903997
},
904998
async releaseHeldLockForAbort(): Promise<void> {
905-
await releaseHeldLockWithFence();
999+
await releaseHeldLockForAbortBounded();
9061000
},
9071001
refreshAfterOwnedSessionWrite(): void {
9081002
if (fenceActive && !takeoverDetected) {

0 commit comments

Comments
 (0)