Skip to content

Commit 210adf1

Browse files
authored
fix(agents): retry transient stale session locks
Follow-up to #88658. Retries transient stale session-lock acquire failures when diagnostics show the old stale report disappeared, was replaced by a fresh valid lock, or was replaced by a fresh payload-less lock still inside the mtime/orphan grace window. Preserves typed `SessionWriteLockStaleError` diagnostics for still-present live OpenClaw-owned stale locks. Proof: 53 focused session-write-lock tests passed locally and in the agents-core CI shard; `pnpm tsgo:test:src`, touched-file oxlint, `git diff --check`, and autoreview passed locally. CI run 26716843811 has unrelated failures in UI deadcode/types and bash-tools tests; session-write-lock tests passed in that run. Refs #87217.
1 parent 51228ae commit 210adf1

2 files changed

Lines changed: 274 additions & 17 deletions

File tree

src/agents/session-write-lock.test.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fsSync from "node:fs";
33
import fs from "node:fs/promises";
44
import os from "node:os";
55
import path from "node:path";
6+
import { fileURLToPath } from "node:url";
67
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
78
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
89
import { SessionWriteLockStaleError } from "./session-write-lock-error.js";
@@ -87,6 +88,19 @@ async function writeCurrentProcessLock(lockPath: string, extra?: Record<string,
8788
);
8889
}
8990

91+
function readFilePathToString(filePath: Parameters<typeof fs.readFile>[0]): string | undefined {
92+
if (typeof filePath === "string") {
93+
return filePath;
94+
}
95+
if (Buffer.isBuffer(filePath)) {
96+
return filePath.toString("utf8");
97+
}
98+
if (filePath instanceof URL) {
99+
return fileURLToPath(filePath);
100+
}
101+
return undefined;
102+
}
103+
90104
async function withSymlinkedSessionPaths(
91105
run: (params: {
92106
sessionReal: string;
@@ -453,6 +467,157 @@ describe("acquireSessionWriteLock", () => {
453467
});
454468
});
455469

470+
it("retries when a stale lock report disappears before diagnostics", async () => {
471+
await withTempSessionLockFile(async ({ sessionFile, lockPath }) => {
472+
const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "openclaw"], {
473+
stdio: "ignore",
474+
});
475+
if (!owner.pid) {
476+
throw new Error("missing lock owner pid");
477+
}
478+
await fs.writeFile(
479+
lockPath,
480+
JSON.stringify({
481+
pid: owner.pid,
482+
createdAt: new Date(Date.now() - 120_000).toISOString(),
483+
}),
484+
"utf8",
485+
);
486+
487+
const originalReadFile = fs.readFile.bind(fs);
488+
let lockReads = 0;
489+
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation((async (
490+
filePath,
491+
options,
492+
) => {
493+
const lockFilePath = readFilePathToString(filePath);
494+
if (lockFilePath && path.basename(lockFilePath) === path.basename(lockPath)) {
495+
lockReads += 1;
496+
if (lockReads === 3) {
497+
await fs.rm(lockFilePath, { force: true });
498+
await fs.rm(lockPath, { force: true });
499+
throw Object.assign(new Error("lock disappeared"), { code: "ENOENT" });
500+
}
501+
}
502+
return await originalReadFile(filePath, options as never);
503+
}) as typeof fs.readFile);
504+
505+
try {
506+
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 10 });
507+
await lock.release();
508+
expect(lockReads).toBeGreaterThanOrEqual(3);
509+
await expectPathMissing(lockPath);
510+
} finally {
511+
readFileSpy.mockRestore();
512+
owner.kill("SIGTERM");
513+
}
514+
});
515+
});
516+
517+
it("retries when a stale lock report is replaced before diagnostics", async () => {
518+
await withTempSessionLockFile(async ({ sessionFile, lockPath }) => {
519+
const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "openclaw"], {
520+
stdio: "ignore",
521+
});
522+
if (!owner.pid) {
523+
throw new Error("missing lock owner pid");
524+
}
525+
await fs.writeFile(
526+
lockPath,
527+
JSON.stringify({
528+
pid: owner.pid,
529+
createdAt: new Date(Date.now() - 120_000).toISOString(),
530+
}),
531+
"utf8",
532+
);
533+
534+
const originalReadFile = fs.readFile.bind(fs);
535+
let lockReads = 0;
536+
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation((async (
537+
filePath,
538+
options,
539+
) => {
540+
const lockFilePath = readFilePathToString(filePath);
541+
if (lockFilePath && path.basename(lockFilePath) === path.basename(lockPath)) {
542+
lockReads += 1;
543+
if (lockReads === 3) {
544+
await fs.rm(lockFilePath, { force: true });
545+
await fs.rm(lockPath, { force: true });
546+
await fs.writeFile(
547+
lockFilePath,
548+
JSON.stringify({ pid: owner.pid, createdAt: new Date().toISOString() }),
549+
"utf8",
550+
);
551+
setTimeout(() => {
552+
void fs.rm(lockFilePath, { force: true });
553+
}, 10);
554+
throw Object.assign(new Error("lock disappeared"), { code: "ENOENT" });
555+
}
556+
}
557+
return await originalReadFile(filePath, options as never);
558+
}) as typeof fs.readFile);
559+
560+
try {
561+
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 10 });
562+
await lock.release();
563+
expect(lockReads).toBeGreaterThanOrEqual(3);
564+
await expectPathMissing(lockPath);
565+
} finally {
566+
readFileSpy.mockRestore();
567+
owner.kill("SIGTERM");
568+
}
569+
});
570+
});
571+
572+
it("retries when a stale lock report is replaced by a fresh payload-less lock", async () => {
573+
await withTempSessionLockFile(async ({ sessionFile, lockPath }) => {
574+
const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "openclaw"], {
575+
stdio: "ignore",
576+
});
577+
if (!owner.pid) {
578+
throw new Error("missing lock owner pid");
579+
}
580+
await fs.writeFile(
581+
lockPath,
582+
JSON.stringify({
583+
pid: owner.pid,
584+
createdAt: new Date(Date.now() - 120_000).toISOString(),
585+
}),
586+
"utf8",
587+
);
588+
589+
const originalReadFile = fs.readFile.bind(fs);
590+
let lockReads = 0;
591+
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation((async (
592+
filePath,
593+
options,
594+
) => {
595+
const lockFilePath = readFilePathToString(filePath);
596+
if (lockFilePath && path.basename(lockFilePath) === path.basename(lockPath)) {
597+
lockReads += 1;
598+
if (lockReads === 3) {
599+
await fs.rm(lockFilePath, { force: true });
600+
await fs.writeFile(lockFilePath, "", "utf8");
601+
setTimeout(() => {
602+
void fs.rm(lockFilePath, { force: true });
603+
}, 10);
604+
}
605+
}
606+
return await originalReadFile(filePath, options as never);
607+
}) as typeof fs.readFile);
608+
609+
try {
610+
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 800, staleMs: 10 });
611+
await lock.release();
612+
expect(lockReads).toBeGreaterThanOrEqual(3);
613+
await expectPathMissing(lockPath);
614+
} finally {
615+
readFileSpy.mockRestore();
616+
owner.kill("SIGTERM");
617+
}
618+
});
619+
});
620+
456621
it("watchdog releases stale in-process locks", async () => {
457622
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-"));
458623
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
@@ -600,6 +765,14 @@ describe("acquireSessionWriteLock", () => {
600765
});
601766
});
602767

768+
it("preserves one acquire timeout budget across retries", () => {
769+
expect(testing.resolveRemainingAcquireTimeoutMs(500, 1_000, 1_125)).toBe(375);
770+
expect(testing.resolveRemainingAcquireTimeoutMs(500, 1_000, 1_500)).toBe(0);
771+
expect(testing.resolveRemainingAcquireTimeoutMs(Number.POSITIVE_INFINITY, 1_000, 9_000)).toBe(
772+
Number.POSITIVE_INFINITY,
773+
);
774+
});
775+
603776
it("uses resolved stale policy when cleaning stale lock files", async () => {
604777
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-policy-"));
605778
const sessionsDir = path.join(root, "sessions");

src/agents/session-write-lock.ts

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -389,29 +389,45 @@ function unregisterCleanupHandlers(): void {
389389
cleanupState.registered = false;
390390
}
391391

392+
function parseLockPayload(raw: string): LockFilePayload | null {
393+
const parsed = JSON.parse(raw) as Record<string, unknown>;
394+
const payload: LockFilePayload = {};
395+
if (isValidLockNumber(parsed.pid) && parsed.pid > 0) {
396+
payload.pid = parsed.pid;
397+
}
398+
if (typeof parsed.createdAt === "string") {
399+
payload.createdAt = parsed.createdAt;
400+
}
401+
if (isValidLockNumber(parsed.starttime)) {
402+
payload.starttime = parsed.starttime;
403+
}
404+
if (isValidLockNumber(parsed.maxHoldMs) && parsed.maxHoldMs > 0) {
405+
payload.maxHoldMs = parsed.maxHoldMs;
406+
}
407+
return payload;
408+
}
409+
392410
async function readLockPayload(lockPath: string): Promise<LockFilePayload | null> {
393411
try {
394412
const raw = await fs.readFile(lockPath, "utf8");
395-
const parsed = JSON.parse(raw) as Record<string, unknown>;
396-
const payload: LockFilePayload = {};
397-
if (isValidLockNumber(parsed.pid) && parsed.pid > 0) {
398-
payload.pid = parsed.pid;
399-
}
400-
if (typeof parsed.createdAt === "string") {
401-
payload.createdAt = parsed.createdAt;
402-
}
403-
if (isValidLockNumber(parsed.starttime)) {
404-
payload.starttime = parsed.starttime;
405-
}
406-
if (isValidLockNumber(parsed.maxHoldMs) && parsed.maxHoldMs > 0) {
407-
payload.maxHoldMs = parsed.maxHoldMs;
408-
}
409-
return payload;
413+
return parseLockPayload(raw);
410414
} catch {
411415
return null;
412416
}
413417
}
414418

419+
async function readLockPayloadForDiagnostics(
420+
lockPath: string,
421+
): Promise<{ payload: LockFilePayload | null; missing: boolean }> {
422+
try {
423+
const raw = await fs.readFile(lockPath, "utf8");
424+
return { payload: parseLockPayload(raw), missing: false };
425+
} catch (error) {
426+
const code = (error as { code?: string } | null)?.code;
427+
return { payload: null, missing: code === "ENOENT" };
428+
}
429+
}
430+
415431
async function resolveNormalizedSessionFile(sessionFile: string): Promise<string> {
416432
const resolvedSessionFile = path.resolve(sessionFile);
417433
const sessionDir = path.dirname(resolvedSessionFile);
@@ -626,6 +642,40 @@ function resolveOrphanLockPayloadGraceMs(timeoutMs: number): number {
626642
return ORPHAN_LOCK_PAYLOAD_GRACE_MS;
627643
}
628644

645+
function resolveRemainingAcquireTimeoutMs(
646+
timeoutMs: number,
647+
startedAtMs: number,
648+
nowMs: number,
649+
): number {
650+
if (timeoutMs === Number.POSITIVE_INFINITY) {
651+
return Number.POSITIVE_INFINITY;
652+
}
653+
const elapsedMs = Math.max(0, nowMs - startedAtMs);
654+
return Math.max(0, timeoutMs - elapsedMs);
655+
}
656+
657+
async function shouldRetryStaleAcquireFailure(params: {
658+
lockPath: string;
659+
lockMissingAtDiagnostics: boolean;
660+
inspected: LockInspectionDetails;
661+
heldByThisProcess: boolean;
662+
staleMs: number;
663+
nowMs: number;
664+
orphanPayloadGraceMs: number;
665+
}): Promise<boolean> {
666+
if (params.lockMissingAtDiagnostics) {
667+
return true;
668+
}
669+
return !(await shouldReportContendedLockStale({
670+
lockPath: params.lockPath,
671+
details: params.inspected,
672+
heldByThisProcess: params.heldByThisProcess,
673+
staleMs: params.staleMs,
674+
nowMs: params.nowMs,
675+
orphanPayloadGraceMs: params.orphanPayloadGraceMs,
676+
}));
677+
}
678+
629679
async function shouldRemoveLockDuringCleanup(
630680
lockPath: string,
631681
details: LockInspectionDetails,
@@ -845,12 +895,30 @@ export async function acquireSessionWriteLock(params: {
845895
const normalizedSessionFile = await resolveNormalizedSessionFile(sessionFile);
846896
const lockPath = `${normalizedSessionFile}.lock`;
847897
await fs.mkdir(sessionDir, { recursive: true });
898+
const startedAtMs = Date.now();
848899

849900
while (true) {
901+
const remainingTimeoutMs = resolveRemainingAcquireTimeoutMs(timeoutMs, startedAtMs, Date.now());
902+
if (remainingTimeoutMs <= 0) {
903+
const payload = await readLockPayload(lockPath);
904+
const nowMs = Date.now();
905+
const heldByThisProcess = sessionLockHeldByThisProcess(normalizedSessionFile);
906+
const inspected = inspectLockPayloadForSession({
907+
payload,
908+
staleMs,
909+
nowMs,
910+
heldByThisProcess,
911+
reclaimLockWithoutStarttime: true,
912+
readOwnerProcessArgs: readProcessArgsSync,
913+
respectMaxHold: !heldByThisProcess,
914+
});
915+
const owner = describeLockOwnerForError({ payload, inspected });
916+
throw new SessionWriteLockTimeoutError({ timeoutMs, owner, lockPath });
917+
}
850918
try {
851919
const lock = await SESSION_LOCKS.acquire(sessionFile, {
852920
staleMs,
853-
timeoutMs,
921+
timeoutMs: remainingTimeoutMs,
854922
retry: { minTimeout: 50, maxTimeout: 1000, factor: 1 },
855923
staleRecovery: "remove-if-unchanged",
856924
allowReentrant,
@@ -914,7 +982,8 @@ export async function acquireSessionWriteLock(params: {
914982
throw err;
915983
}
916984
const errorLockPath = (err as { lockPath?: string }).lockPath ?? lockPath;
917-
const payload = await readLockPayload(errorLockPath);
985+
const { payload, missing: lockMissingAtDiagnostics } =
986+
await readLockPayloadForDiagnostics(errorLockPath);
918987
const nowMs = Date.now();
919988
const heldByThisProcess = sessionLockHeldByThisProcess(normalizedSessionFile);
920989
const inspected = inspectLockPayloadForSession({
@@ -928,6 +997,20 @@ export async function acquireSessionWriteLock(params: {
928997
});
929998
const owner = describeLockOwnerForError({ payload, inspected });
930999
if (isFileLockError(err, "file_lock_stale")) {
1000+
if (
1001+
resolveRemainingAcquireTimeoutMs(timeoutMs, startedAtMs, Date.now()) > 0 &&
1002+
(await shouldRetryStaleAcquireFailure({
1003+
lockPath: errorLockPath,
1004+
lockMissingAtDiagnostics,
1005+
inspected,
1006+
heldByThisProcess,
1007+
staleMs,
1008+
nowMs,
1009+
orphanPayloadGraceMs,
1010+
}))
1011+
) {
1012+
continue;
1013+
}
9311014
throw new SessionWriteLockStaleError({
9321015
owner,
9331016
lockPath: errorLockPath,
@@ -945,6 +1028,7 @@ export const testing = {
9451028
inspectLockPayloadForTest: inspectLockPayload,
9461029
releaseAllLocksSync,
9471030
runLockWatchdogCheck,
1031+
resolveRemainingAcquireTimeoutMs,
9481032
setProcessStartTimeResolverForTest(resolver: ((pid: number) => number | null) | null): void {
9491033
resolveProcessStartTimeForLock = resolver ?? getProcessStartTime;
9501034
},

0 commit comments

Comments
 (0)