Skip to content

Commit b8875d0

Browse files
authored
fix: make multiscan supervisor locks safe across container restarts (#287)
* fix: recover stale multiscan supervisor locks across containers * fix: preserve replacement supervisor locks after owner write failures
1 parent 07585bf commit b8875d0

2 files changed

Lines changed: 524 additions & 14 deletions

File tree

sdk/typescript/src/multiscan.ts

Lines changed: 144 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import {
99
rename,
1010
rm,
1111
truncate,
12+
utimes,
1213
writeFile,
1314
} from "node:fs/promises";
15+
import { hostname } from "node:os";
1416
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
1517
import { promisify } from "node:util";
1618
import Papa from "papaparse";
@@ -28,6 +30,8 @@ const REQUIRED_ARTIFACTS = [
2830
"coverage.json",
2931
"report.md",
3032
];
33+
const LOCK_LEASE_MS = 30_000;
34+
const LOCK_HEARTBEAT_MS = 5_000;
3135

3236
interface MultiscanTask {
3337
id: string;
@@ -276,25 +280,153 @@ async function acquireLock(output: string): Promise<() => Promise<void>> {
276280
await mkdir(path, { mode: 0o700 });
277281
} catch (error) {
278282
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
279-
const { pid } = JSON.parse(await readFile(ownerPath, "utf8")) as {
280-
pid: number;
283+
const existing = await inspectLock(path);
284+
if (!existing.stale) {
285+
throw new Error("A multiscan supervisor is already running.");
286+
}
287+
await recoverLock(output, path, existing.owner);
288+
return await acquireLock(output);
289+
}
290+
const owner = `${JSON.stringify({
291+
pid: process.pid,
292+
ownerId: randomUUID(),
293+
hostname: hostname(),
294+
processStartedAt: performance.timeOrigin,
295+
})}\n`;
296+
await writeFile(ownerPath, owner, { flag: "wx", mode: 0o600 });
297+
298+
let heartbeat = Promise.resolve();
299+
const timer = setInterval(() => {
300+
heartbeat = heartbeat
301+
.then(async () => {
302+
if ((await readFile(ownerPath, "utf8")) !== owner) return;
303+
const now = new Date();
304+
await utimes(ownerPath, now, now);
305+
})
306+
.catch(() => {});
307+
}, LOCK_HEARTBEAT_MS);
308+
timer.unref();
309+
310+
return async () => {
311+
clearInterval(timer);
312+
await heartbeat;
313+
const current = await readFile(ownerPath, "utf8").catch(
314+
(error: NodeJS.ErrnoException) => {
315+
if (error.code !== "ENOENT") throw error;
316+
return undefined;
317+
},
318+
);
319+
if (current === owner) await rm(path, { recursive: true });
320+
};
321+
}
322+
323+
async function inspectLock(
324+
path: string,
325+
): Promise<{ owner: string | undefined; stale: boolean }> {
326+
const ownerPath = join(path, "owner.json");
327+
let owner: string;
328+
let modifiedAt: number;
329+
try {
330+
owner = await readFile(ownerPath, "utf8");
331+
modifiedAt = (await lstat(ownerPath)).mtimeMs;
332+
} catch (error) {
333+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
334+
return {
335+
owner: undefined,
336+
stale: Date.now() - (await lstat(path)).mtimeMs > LOCK_LEASE_MS,
281337
};
282-
try {
283-
process.kill(pid, 0);
338+
}
339+
340+
let identity: {
341+
pid?: number;
342+
ownerId?: string;
343+
hostname?: string;
344+
processStartedAt?: number;
345+
};
346+
try {
347+
identity = JSON.parse(owner) as typeof identity;
348+
} catch {
349+
return { owner, stale: Date.now() - modifiedAt > LOCK_LEASE_MS };
350+
}
351+
352+
if (
353+
typeof identity.ownerId === "string" &&
354+
typeof identity.hostname === "string" &&
355+
typeof identity.processStartedAt === "number"
356+
) {
357+
const sameProcess =
358+
identity.pid === process.pid &&
359+
identity.hostname === hostname() &&
360+
identity.processStartedAt === performance.timeOrigin;
361+
return {
362+
owner,
363+
stale: !sameProcess && Date.now() - modifiedAt > LOCK_LEASE_MS,
364+
};
365+
}
366+
367+
if (
368+
identity.pid === undefined ||
369+
!Number.isSafeInteger(identity.pid) ||
370+
identity.pid < 1
371+
) {
372+
return { owner, stale: Date.now() - modifiedAt > LOCK_LEASE_MS };
373+
}
374+
try {
375+
process.kill(identity.pid, 0);
376+
} catch (error) {
377+
if ((error as NodeJS.ErrnoException).code === "ESRCH") {
378+
return { owner, stale: true };
379+
}
380+
if ((error as NodeJS.ErrnoException).code === "EPERM") {
381+
return { owner, stale: false };
382+
}
383+
throw error;
384+
}
385+
return {
386+
owner,
387+
stale:
388+
identity.pid === process.pid &&
389+
modifiedAt + 1_000 < performance.timeOrigin,
390+
};
391+
}
392+
393+
async function recoverLock(
394+
output: string,
395+
path: string,
396+
expectedOwner: string | undefined,
397+
): Promise<void> {
398+
const recoveryPath = join(path, ".recovering");
399+
let claim;
400+
try {
401+
claim = await open(recoveryPath, "wx", 0o600);
402+
} catch (error) {
403+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
404+
if (Date.now() - (await lstat(recoveryPath)).mtimeMs > LOCK_LEASE_MS) {
405+
await rm(recoveryPath, { force: true });
406+
return await recoverLock(output, path, expectedOwner);
407+
}
408+
throw new Error("A multiscan supervisor is already running.");
409+
}
410+
throw error;
411+
}
412+
await claim.close();
413+
414+
let moved = false;
415+
try {
416+
const current = await inspectLock(path);
417+
if (
418+
current.owner !== expectedOwner ||
419+
(expectedOwner !== undefined && !current.stale)
420+
) {
284421
throw new Error("A multiscan supervisor is already running.");
285-
} catch (failure) {
286-
if ((failure as NodeJS.ErrnoException).code !== "ESRCH") throw failure;
287422
}
288423
const stale = join(output, `.lock.stale-${randomUUID()}`);
289424
await rename(path, stale);
425+
moved = true;
290426
await rm(stale, { recursive: true });
291-
return await acquireLock(output);
427+
} finally {
428+
if (!moved) await rm(recoveryPath, { force: true });
292429
}
293-
await writeFile(ownerPath, `${JSON.stringify({ pid: process.pid })}\n`, {
294-
flag: "wx",
295-
mode: 0o600,
296-
});
297-
return async () => rm(path, { recursive: true });
298430
}
299431

300432
async function ensureManifest(

0 commit comments

Comments
 (0)