Skip to content

Commit 2696c8b

Browse files
committed
fix(agents): preserve stale lock acquire budget
1 parent 8d16c24 commit 2696c8b

2 files changed

Lines changed: 69 additions & 3 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,6 +1179,52 @@ describe("acquireSessionWriteLock", () => {
11791179
});
11801180
});
11811181

1182+
it("preserves one acquire timeout budget after stale lock retries", async () => {
1183+
if (process.platform !== "linux") {
1184+
return;
1185+
}
1186+
await withTempSessionLockFile(async ({ sessionFile, lockPath }) => {
1187+
let nowMs = 1_000;
1188+
const dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => nowMs);
1189+
const acquireTimeouts: number[] = [];
1190+
testing.setSessionLockAcquireForTest(async (_targetPath, options) => {
1191+
acquireTimeouts.push(options.timeoutMs ?? -1);
1192+
if (acquireTimeouts.length % 2 === 1) {
1193+
nowMs += 89;
1194+
throw Object.assign(new Error("stale"), {
1195+
code: "file_lock_stale",
1196+
lockPath,
1197+
});
1198+
}
1199+
throw Object.assign(new Error("timeout"), {
1200+
code: "file_lock_timeout",
1201+
lockPath,
1202+
});
1203+
});
1204+
try {
1205+
await expect(
1206+
acquireSessionWriteLock({
1207+
sessionFile,
1208+
timeoutMs: 90,
1209+
allowReentrant: false,
1210+
}),
1211+
).rejects.toThrow(/session file locked/);
1212+
1213+
expect(acquireTimeouts).toEqual([90, 1]);
1214+
await expect(
1215+
acquireSessionWriteLock({
1216+
sessionFile,
1217+
timeoutMs: 90,
1218+
allowReentrant: false,
1219+
}),
1220+
).rejects.toThrow(/session file locked/);
1221+
expect(acquireTimeouts).toEqual([90, 1, 90, 1]);
1222+
} finally {
1223+
dateNowSpy.mockRestore();
1224+
}
1225+
});
1226+
});
1227+
11821228
it("reclaims orphan lock files without starttime when PID matches current process", async () => {
11831229
await withTempSessionLockFile(async ({ sessionFile, lockPath }) => {
11841230
// Simulate an old-format lock file left behind by a previous process

src/agents/session-write-lock.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type fsSync from "node:fs";
33
import fs from "node:fs/promises";
44
import path from "node:path";
55
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
6-
import { createFileLockManager } from "../infra/file-lock-manager.js";
6+
import { createFileLockManager, type FileLockManager } from "../infra/file-lock-manager.js";
77
import { readGatewayProcessArgsSync as readProcessArgsSync } from "../infra/gateway-processes.js";
88
import { getProcessStartTime, isPidAlive } from "../shared/pid-alive.js";
99
import { SessionWriteLockTimeoutError } from "./session-write-lock-error.js";
@@ -77,8 +77,10 @@ type LockInspectionDetails = Pick<
7777
"pid" | "pidAlive" | "createdAt" | "ageMs" | "stale" | "staleReasons"
7878
>;
7979

80+
type SessionLockAcquire = FileLockManager["acquire"];
8081
const SESSION_LOCKS = createFileLockManager("openclaw.session-write-lock");
8182
let resolveProcessStartTimeForLock = getProcessStartTime;
83+
let acquireSessionLockForTest: SessionLockAcquire | null = null;
8284

8385
function isFileLockError(error: unknown, code: string): boolean {
8486
return (error as { code?: unknown } | null)?.code === code;
@@ -818,10 +820,24 @@ export async function acquireSessionWriteLock(params: {
818820
await fs.mkdir(sessionDir, { recursive: true });
819821

820822
while (true) {
823+
const elapsedBeforeAcquireMs = Date.now() - startedAt;
824+
const acquireTimeoutMs =
825+
timeoutMs === Number.POSITIVE_INFINITY
826+
? timeoutMs
827+
: Math.max(0, timeoutMs - elapsedBeforeAcquireMs);
828+
if (acquireTimeoutMs <= 0) {
829+
await throwSessionWriteLockTimeout({
830+
timeoutMs,
831+
lockPath,
832+
});
833+
}
834+
821835
try {
822-
const lock = await SESSION_LOCKS.acquire(sessionFile, {
836+
const acquireSessionLock =
837+
acquireSessionLockForTest ?? SESSION_LOCKS.acquire.bind(SESSION_LOCKS);
838+
const lock = await acquireSessionLock(sessionFile, {
823839
staleMs,
824-
timeoutMs,
840+
timeoutMs: acquireTimeoutMs,
825841
retry: { minTimeout: 50, maxTimeout: 1000, factor: 1 },
826842
staleRecovery: "remove-if-unchanged",
827843
allowReentrant,
@@ -915,6 +931,9 @@ export const testing = {
915931
setProcessStartTimeResolverForTest(resolver: ((pid: number) => number | null) | null): void {
916932
resolveProcessStartTimeForLock = resolver ?? getProcessStartTime;
917933
},
934+
setSessionLockAcquireForTest(acquire: SessionLockAcquire | null): void {
935+
acquireSessionLockForTest = acquire;
936+
},
918937
};
919938

920939
export async function drainSessionWriteLockStateForTest(): Promise<void> {
@@ -928,5 +947,6 @@ export function resetSessionWriteLockStateForTest(): void {
928947
stopWatchdogTimer();
929948
unregisterCleanupHandlers();
930949
resolveProcessStartTimeForLock = getProcessStartTime;
950+
acquireSessionLockForTest = null;
931951
}
932952
export { testing as __testing };

0 commit comments

Comments
 (0)