Skip to content

Commit a1eb765

Browse files
njuboy11steipete
andauthored
fix(session-lock): enforce maxHoldMs in shouldReclaim during lock acquisition (#85764)
* fix(session-lock): enforce maxHoldMs in shouldReclaim during lock acquisition - Adds optional maxHoldMs parameter to inspectLockPayload - Inspect now marks locks as stale when held longer than maxHoldMs - Passes maxHoldMs through inspectLockPayloadForSession - acquireSessionWriteLock's shouldReclaim callback now passes maxHoldMs This ensures that when a live process holds a lock for longer than maxHoldMs (default 5min), other processes can reclaim it during acquisition — matching the watchdog's existing enforcement. Previously shouldReclaim only used staleMs (30min default), meaning a lock held for 10+ minutes by a live PID would never be reclaimable, causing 60s timeout failures and gateway freezes. Closes #85762 * fix(session-lock): add dead-PID fast-path before retry loop Adds a fast-path check at the top of acquireSessionWriteLock: if the lock file's owner PID is dead, remove it immediately before entering the retry loop. This saves up to timeoutMs (60s) of futile waiting when the previous lock holder has died. The shouldReclaim callback already handles this case, but only iteratively through the retry loop. The fast-path eliminates that unnecessary delay. * fix(session-lock): enforce max hold during acquisition * fix(session-lock): revalidate max hold safely * fix(session-lock): honor holder max-hold policy * fix(session-lock): keep cleanup from reclaiming live holders * fix(session-lock): remove stale locks only when unchanged * fix(session-lock): skip self-held max-hold reclaim * fix(ci): refresh gateway protocol checks --------- Co-authored-by: njuboy11 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent a1c2d09 commit a1eb765

5 files changed

Lines changed: 149 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai
7575
- Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.
7676
- Checks/Windows: run managed child commands through explicit `cmd.exe` wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.
7777
- Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.
78+
- Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.
7879
- Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.
7980
- Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.
8081
- Channels: honor `/verbose on` for tool/progress summaries across direct chats, groups, channels, and forum topics while preserving quiet default behavior. (#85488) Thanks @kurplunkin.

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,8 @@ public struct AgentParams: Codable, Sendable {
755755
public let internalruntimehandoffid: String?
756756
public let internalevents: [[String: AnyCodable]]?
757757
public let inputprovenance: [String: AnyCodable]?
758+
public let suppresspromptpersistence: Bool?
759+
public let sessioneffects: AnyCodable?
758760
public let sourcereplydeliverymode: AnyCodable?
759761
public let disablemessagetool: Bool?
760762
public let voicewaketrigger: String?
@@ -794,6 +796,8 @@ public struct AgentParams: Codable, Sendable {
794796
internalruntimehandoffid: String?,
795797
internalevents: [[String: AnyCodable]]?,
796798
inputprovenance: [String: AnyCodable]?,
799+
suppresspromptpersistence: Bool?,
800+
sessioneffects: AnyCodable?,
797801
sourcereplydeliverymode: AnyCodable?,
798802
disablemessagetool: Bool?,
799803
voicewaketrigger: String?,
@@ -832,6 +836,8 @@ public struct AgentParams: Codable, Sendable {
832836
self.internalruntimehandoffid = internalruntimehandoffid
833837
self.internalevents = internalevents
834838
self.inputprovenance = inputprovenance
839+
self.suppresspromptpersistence = suppresspromptpersistence
840+
self.sessioneffects = sessioneffects
835841
self.sourcereplydeliverymode = sourcereplydeliverymode
836842
self.disablemessagetool = disablemessagetool
837843
self.voicewaketrigger = voicewaketrigger
@@ -872,6 +878,8 @@ public struct AgentParams: Codable, Sendable {
872878
case internalruntimehandoffid = "internalRuntimeHandoffId"
873879
case internalevents = "internalEvents"
874880
case inputprovenance = "inputProvenance"
881+
case suppresspromptpersistence = "suppressPromptPersistence"
882+
case sessioneffects = "sessionEffects"
875883
case sourcereplydeliverymode = "sourceReplyDeliveryMode"
876884
case disablemessagetool = "disableMessageTool"
877885
case voicewaketrigger = "voiceWakeTrigger"

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,66 @@ describe("acquireSessionWriteLock", () => {
311311
});
312312
});
313313

314+
it("marks live lock payloads stale once they exceed max hold", () => {
315+
const nowMs = Date.now();
316+
const inspected = testing.inspectLockPayloadForTest(
317+
{
318+
pid: process.pid,
319+
createdAt: new Date(nowMs - 30_000).toISOString(),
320+
maxHoldMs: 10_000,
321+
},
322+
60_000,
323+
nowMs,
324+
{ respectMaxHold: true },
325+
);
326+
327+
expect(inspected.stale).toBe(true);
328+
expect(inspected.staleReasons).toEqual(["hold-exceeded"]);
329+
});
330+
331+
it("keeps live lock payloads fresh until their recorded holder max hold expires", () => {
332+
const nowMs = Date.now();
333+
const inspected = testing.inspectLockPayloadForTest(
334+
{
335+
pid: process.pid,
336+
createdAt: new Date(nowMs - 30_000).toISOString(),
337+
maxHoldMs: 60_000,
338+
},
339+
60_000,
340+
nowMs,
341+
{ respectMaxHold: true },
342+
);
343+
344+
expect(inspected.stale).toBe(false);
345+
expect(inspected.staleReasons).toEqual([]);
346+
});
347+
348+
it("does not reclaim an active in-process lock through max-hold acquisition", async () => {
349+
await withTempSessionLockFile(async ({ sessionFile, lockPath }) => {
350+
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, maxHoldMs: 1 });
351+
await fs.writeFile(
352+
lockPath,
353+
JSON.stringify({
354+
pid: process.pid,
355+
createdAt: new Date(Date.now() - 30_000).toISOString(),
356+
maxHoldMs: 1,
357+
}),
358+
"utf8",
359+
);
360+
361+
await expect(
362+
acquireSessionWriteLock({
363+
sessionFile,
364+
timeoutMs: 5,
365+
staleMs: 60_000,
366+
allowReentrant: false,
367+
}),
368+
).rejects.toThrow(/session file locked/);
369+
await expect(fs.access(lockPath)).resolves.toBeUndefined();
370+
await lock.release();
371+
});
372+
});
373+
314374
it("watchdog releases stale in-process locks", async () => {
315375
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-"));
316376
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
@@ -457,6 +517,47 @@ describe("acquireSessionWriteLock", () => {
457517
}
458518
});
459519

520+
it("does not clean live OpenClaw locks just because holder max hold expired", async () => {
521+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-policy-"));
522+
const sessionsDir = path.join(root, "sessions");
523+
await fs.mkdir(sessionsDir, { recursive: true });
524+
const nowMs = Date.now();
525+
const lockPath = path.join(sessionsDir, "held-past-max.jsonl.lock");
526+
527+
try {
528+
await fs.writeFile(
529+
lockPath,
530+
JSON.stringify({
531+
pid: process.pid,
532+
createdAt: new Date(nowMs - 30_000).toISOString(),
533+
maxHoldMs: 10_000,
534+
}),
535+
"utf8",
536+
);
537+
538+
const result = await cleanStaleLockFiles({
539+
sessionsDir,
540+
staleMs: 60_000,
541+
nowMs,
542+
removeStale: true,
543+
readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "agent"],
544+
});
545+
546+
expect(lockCleanupRecords(result.locks)).toEqual([
547+
{
548+
name: "held-past-max.jsonl.lock",
549+
removed: false,
550+
stale: false,
551+
staleReasons: [],
552+
},
553+
]);
554+
expect(result.cleaned).toEqual([]);
555+
await expect(fs.access(lockPath)).resolves.toBeUndefined();
556+
} finally {
557+
await fs.rm(root, { recursive: true, force: true });
558+
}
559+
});
560+
460561
it("clamps max hold for effectively no-timeout runs", () => {
461562
expect(
462563
resolveSessionLockMaxHoldFromTimeout({

src/agents/session-write-lock.ts

Lines changed: 38 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type LockFilePayload = {
1212
createdAt?: string;
1313
/** Process start time in clock ticks (from /proc/pid/stat field 22). */
1414
starttime?: number;
15+
maxHoldMs?: number;
1516
};
1617

1718
function isValidLockNumber(value: unknown): value is number {
@@ -378,6 +379,9 @@ async function readLockPayload(lockPath: string): Promise<LockFilePayload | null
378379
if (isValidLockNumber(parsed.starttime)) {
379380
payload.starttime = parsed.starttime;
380381
}
382+
if (isValidLockNumber(parsed.maxHoldMs) && parsed.maxHoldMs > 0) {
383+
payload.maxHoldMs = parsed.maxHoldMs;
384+
}
381385
return payload;
382386
} catch {
383387
return null;
@@ -449,6 +453,7 @@ function inspectLockPayload(
449453
payload: LockFilePayload | null,
450454
staleMs: number,
451455
nowMs: number,
456+
opts: { respectMaxHold?: boolean } = {},
452457
): LockInspectionDetails {
453458
const pid = isValidLockNumber(payload?.pid) && payload.pid > 0 ? payload.pid : null;
454459
const pidAlive = pid !== null ? isPidAlive(pid) : false;
@@ -481,6 +486,16 @@ function inspectLockPayload(
481486
} else if (ageMs > staleMs) {
482487
staleReasons.push("too-old");
483488
}
489+
const holderMaxHoldMs =
490+
isValidLockNumber(payload?.maxHoldMs) && payload.maxHoldMs > 0 ? payload.maxHoldMs : undefined;
491+
if (
492+
opts.respectMaxHold === true &&
493+
typeof holderMaxHoldMs === "number" &&
494+
ageMs !== null &&
495+
ageMs > holderMaxHoldMs
496+
) {
497+
staleReasons.push("hold-exceeded");
498+
}
484499

485500
return {
486501
pid,
@@ -552,39 +567,6 @@ function sessionLockHeldByThisProcess(normalizedSessionFile: string): boolean {
552567
);
553568
}
554569

555-
async function removeReportedStaleLockIfStillStale(params: {
556-
lockPath: string;
557-
normalizedSessionFile: string;
558-
staleMs: number;
559-
readOwnerProcessArgs?: SessionLockOwnerProcessArgsReader;
560-
}): Promise<boolean> {
561-
const nowMs = Date.now();
562-
const payload = await readLockPayload(params.lockPath);
563-
if (payload === null) {
564-
try {
565-
await fs.access(params.lockPath);
566-
} catch (error) {
567-
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
568-
return true;
569-
}
570-
throw error;
571-
}
572-
}
573-
const inspected = inspectLockPayloadForSession({
574-
payload,
575-
staleMs: params.staleMs,
576-
nowMs,
577-
heldByThisProcess: sessionLockHeldByThisProcess(params.normalizedSessionFile),
578-
reclaimLockWithoutStarttime: true,
579-
readOwnerProcessArgs: params.readOwnerProcessArgs ?? readProcessArgsSync,
580-
});
581-
if (!(await shouldReclaimContendedLockFile(params.lockPath, inspected, params.staleMs, nowMs))) {
582-
return false;
583-
}
584-
await fs.rm(params.lockPath, { force: true });
585-
return true;
586-
}
587-
588570
function shouldTreatAsOrphanSelfLock(params: {
589571
payload: LockFilePayload | null;
590572
heldByThisProcess: boolean;
@@ -616,8 +598,11 @@ function inspectLockPayloadForSession(params: {
616598
heldByThisProcess: boolean;
617599
reclaimLockWithoutStarttime: boolean;
618600
readOwnerProcessArgs: SessionLockOwnerProcessArgsReader;
601+
respectMaxHold?: boolean;
619602
}): LockInspectionDetails {
620-
const inspected = inspectLockPayload(params.payload, params.staleMs, params.nowMs);
603+
const inspected = inspectLockPayload(params.payload, params.staleMs, params.nowMs, {
604+
respectMaxHold: params.respectMaxHold,
605+
});
621606
if (
622607
shouldTreatAsOrphanSelfLock({
623608
payload: params.payload,
@@ -745,18 +730,20 @@ export async function acquireSessionWriteLock(params: {
745730
const normalizedSessionFile = await resolveNormalizedSessionFile(sessionFile);
746731
const lockPath = `${normalizedSessionFile}.lock`;
747732
await fs.mkdir(sessionDir, { recursive: true });
733+
748734
while (true) {
749735
try {
750736
const lock = await SESSION_LOCKS.acquire(sessionFile, {
751737
staleMs,
752738
timeoutMs,
753739
retry: { minTimeout: 50, maxTimeout: 1000, factor: 1 },
740+
staleRecovery: "remove-if-unchanged",
754741
allowReentrant,
755742
metadata: { maxHoldMs },
756743
payload: () => {
757744
const createdAt = new Date().toISOString();
758745
const starttime = resolveProcessStartTimeForLock(process.pid);
759-
const lockPayload: LockFilePayload = { pid: process.pid, createdAt };
746+
const lockPayload: LockFilePayload = { pid: process.pid, createdAt, maxHoldMs };
760747
if (starttime !== null) {
761748
lockPayload.starttime = starttime;
762749
}
@@ -770,24 +757,27 @@ export async function acquireSessionWriteLock(params: {
770757
heldByThisProcess,
771758
reclaimLockWithoutStarttime: true,
772759
readOwnerProcessArgs: readProcessArgsSync,
760+
respectMaxHold: !heldByThisProcess,
761+
});
762+
return await shouldReclaimContendedLockFile(lockPath, inspected, staleMs, nowMs);
763+
},
764+
shouldRemoveStaleLock: async ({ lockPath, normalizedTargetPath, payload }) => {
765+
const nowMs = Date.now();
766+
const heldByThisProcess = sessionLockHeldByThisProcess(normalizedTargetPath);
767+
const inspected = inspectLockPayloadForSession({
768+
payload: payload as LockFilePayload | null,
769+
staleMs,
770+
nowMs,
771+
heldByThisProcess,
772+
reclaimLockWithoutStarttime: true,
773+
readOwnerProcessArgs: readProcessArgsSync,
774+
respectMaxHold: !heldByThisProcess,
773775
});
774776
return await shouldReclaimContendedLockFile(lockPath, inspected, staleMs, nowMs);
775777
},
776778
});
777779
return { release: lock.release };
778780
} catch (err) {
779-
if (isFileLockError(err, "file_lock_stale")) {
780-
const staleLockPath = (err as { lockPath?: string }).lockPath ?? lockPath;
781-
if (
782-
await removeReportedStaleLockIfStillStale({
783-
lockPath: staleLockPath,
784-
normalizedSessionFile,
785-
staleMs,
786-
})
787-
) {
788-
continue;
789-
}
790-
}
791781
if (!isFileLockError(err, "file_lock_timeout")) {
792782
throw err;
793783
}
@@ -802,6 +792,7 @@ export async function acquireSessionWriteLock(params: {
802792
export const testing = {
803793
cleanupSignals: [...CLEANUP_SIGNALS],
804794
handleTerminationSignal,
795+
inspectLockPayloadForTest: inspectLockPayload,
805796
releaseAllLocksSync,
806797
runLockWatchdogCheck,
807798
setProcessStartTimeResolverForTest(resolver: ((pid: number) => number | null) | null): void {

src/gateway/server-methods/agent.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1667,6 +1667,7 @@ describe("gateway agent handler", () => {
16671667
it("keeps backend internal session-effect runs out of visible gateway state", async () => {
16681668
primeMainAgentRun({ cfg: mocks.loadConfigReturn });
16691669
mocks.agentCommand.mockClear();
1670+
mocks.updateSessionStore.mockClear();
16701671
mocks.registerAgentRunContext.mockClear();
16711672
const context = makeContext();
16721673

0 commit comments

Comments
 (0)