Skip to content

Commit c430fcd

Browse files
authored
fix(agents): memoize session lock owner args
Memoize owner process argv lookups per PID during `cleanStaleLockFiles`, and yield between lock entries so startup cleanup does not monopolize the event loop while inspecting many session locks. This keeps lock classification semantics unchanged while avoiding repeated synchronous process-args reads for lock clusters owned by the same PID, especially the Windows PowerShell path. Fixes #86509. Verification: - `git diff --check origin/main...HEAD` - focused TSX harness against the current-main merge result: `session-lock memo regression harness passed` Thanks @openperf. Co-authored-by: openperf <[email protected]>
1 parent 0f49bbb commit c430fcd

2 files changed

Lines changed: 91 additions & 1 deletion

File tree

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,78 @@ describe("acquireSessionWriteLock", () => {
742742
}
743743
});
744744

745+
it("memoizes readOwnerProcessArgs across locks with the same pid in one sweep (#86509)", async () => {
746+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-"));
747+
const sessionsDir = path.join(root, "sessions");
748+
await fs.mkdir(sessionsDir, { recursive: true });
749+
const nowMs = Date.now();
750+
const lockCount = 5;
751+
try {
752+
for (let i = 0; i < lockCount; i++) {
753+
await fs.writeFile(
754+
path.join(sessionsDir, `same-pid-${i}.jsonl.lock`),
755+
JSON.stringify({ pid: process.pid, createdAt: new Date(nowMs).toISOString() }),
756+
"utf8",
757+
);
758+
}
759+
const readArgsCalls: number[] = [];
760+
const readOwnerProcessArgs = (pid: number) => {
761+
readArgsCalls.push(pid);
762+
return ["node", "/srv/app/dist/index.js"];
763+
};
764+
const result = await cleanStaleLockFiles({
765+
sessionsDir,
766+
staleMs: 30_000,
767+
nowMs,
768+
removeStale: true,
769+
readOwnerProcessArgs,
770+
});
771+
expect(result.cleaned).toHaveLength(lockCount);
772+
// Without memo this would be `lockCount`; the per-pid cache collapses it to a single call.
773+
expect(readArgsCalls).toEqual([process.pid]);
774+
} finally {
775+
await fs.rm(root, { recursive: true, force: true });
776+
}
777+
});
778+
779+
it("does not poison the per-pid memo when readOwnerProcessArgs throws (#86509)", async () => {
780+
// A helper one layer up (`readOwnerProcessArgs`) already catches thrown resolvers and
781+
// returns null, so `cleanStaleLockFiles` never propagates the throw — but a naive memo
782+
// could still cache that null-equivalent failure and short-circuit later locks for the
783+
// same pid. The fix writes the cache only after the resolver returns, so each lock
784+
// retries the resolver fresh after a throw.
785+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-"));
786+
const sessionsDir = path.join(root, "sessions");
787+
await fs.mkdir(sessionsDir, { recursive: true });
788+
const nowMs = Date.now();
789+
const lockCount = 3;
790+
try {
791+
for (let i = 0; i < lockCount; i++) {
792+
await fs.writeFile(
793+
path.join(sessionsDir, `throwing-${i}.jsonl.lock`),
794+
JSON.stringify({ pid: process.pid, createdAt: new Date(nowMs).toISOString() }),
795+
"utf8",
796+
);
797+
}
798+
let throwCalls = 0;
799+
const result = await cleanStaleLockFiles({
800+
sessionsDir,
801+
staleMs: 30_000,
802+
nowMs,
803+
removeStale: true,
804+
readOwnerProcessArgs: () => {
805+
throwCalls++;
806+
throw new Error("transient resolver failure");
807+
},
808+
});
809+
// Resolver is invoked once per lock — the throw is not cached as a no-args entry.
810+
expect(throwCalls).toBe(lockCount);
811+
expect(result.cleaned).toHaveLength(0);
812+
} finally {
813+
await fs.rm(root, { recursive: true, force: true });
814+
}
815+
});
816+
745817
it("keeps fresh live .jsonl lock files with OpenClaw or unknown owners", async () => {
746818
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-"));
747819
const sessionsDir = path.join(root, "sessions");

src/agents/session-write-lock.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,20 @@ export async function cleanStaleLockFiles(params: {
657657
);
658658
const removeStale = params.removeStale !== false;
659659
const nowMs = params.nowMs ?? Date.now();
660-
const ownerProcessArgsReader = params.readOwnerProcessArgs ?? readProcessArgsSync;
660+
const baseOwnerProcessArgsReader = params.readOwnerProcessArgs ?? readProcessArgsSync;
661+
// Memoize per-invocation: many locks in the same sweep often share a pid (gateway, MCP),
662+
// and resolving owner argv is the most expensive per-lock syscall (PowerShell on Windows
663+
// is ~0.5–1s per pid) — pids do not recycle within a single sweep. (#86509)
664+
const ownerArgsByPid = new Map<number, string[] | null>();
665+
const ownerProcessArgsReader: SessionLockOwnerProcessArgsReader = (pid) => {
666+
const cached = ownerArgsByPid.get(pid);
667+
if (cached !== undefined) {
668+
return cached;
669+
}
670+
const args = baseOwnerProcessArgsReader(pid);
671+
ownerArgsByPid.set(pid, args);
672+
return args;
673+
};
661674

662675
let entries: fsSync.Dirent[] = [];
663676
try {
@@ -677,6 +690,11 @@ export async function cleanStaleLockFiles(params: {
677690
.toSorted((a, b) => a.name.localeCompare(b.name));
678691

679692
for (const entry of lockEntries) {
693+
// Yield to the event loop between locks so concurrent timers/HTTP polling can run
694+
// while this sweep does per-lock sync syscalls (isPidAlive, /proc reads, PowerShell). (#86509)
695+
await new Promise<void>((resolve) => {
696+
setImmediate(resolve);
697+
});
680698
const lockPath = path.join(sessionsDir, entry.name);
681699
const payload = await readLockPayload(lockPath);
682700
const inspected = inspectLockPayloadForSession({

0 commit comments

Comments
 (0)