-
Notifications
You must be signed in to change notification settings - Fork 534
fix: publish and recover multiscan locks atomically #196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| import { execFile as execFileCallback } from "node:child_process"; | ||
| import { randomUUID } from "node:crypto"; | ||
| import { | ||
| link, | ||
| lstat, | ||
| mkdir, | ||
| open, | ||
|
|
@@ -28,6 +29,13 @@ const REQUIRED_ARTIFACTS = [ | |
| "coverage.json", | ||
| "report.md", | ||
| ]; | ||
| const MAX_LOCK_OWNER_BYTES = 4 * 1024; | ||
| const MAX_LOCK_OWNER_PID = 2_147_483_647; | ||
|
|
||
| interface MultiscanLockOwner { | ||
| pid: number; | ||
| token?: string; | ||
| } | ||
|
|
||
| interface MultiscanTask { | ||
| id: string; | ||
|
|
@@ -267,30 +275,173 @@ async function appendReceipt(path: string, receipt: string): Promise<void> { | |
|
|
||
| async function acquireLock(output: string): Promise<() => Promise<void>> { | ||
| const path = join(output, ".lock"); | ||
| const ownerPath = join(path, "owner.json"); | ||
| const recovery = join(output, ".lock.recovery"); | ||
| const token = randomUUID(); | ||
| const pending = join(output, `.lock.pending-${token}`); | ||
| const owner = `${JSON.stringify({ pid: process.pid, token })}\n`; | ||
| const file = await open(pending, "wx", 0o600); | ||
| try { | ||
| try { | ||
| await file.writeFile(owner, "utf8"); | ||
| await file.sync(); | ||
| } finally { | ||
| await file.close(); | ||
| } | ||
|
|
||
| for (;;) { | ||
| try { | ||
| await link(pending, path); | ||
| const recovering = await readLockOwner(recovery); | ||
| if ( | ||
| recovering !== null && | ||
| recovering.token !== token && | ||
| processIsRunning(recovering.pid) | ||
| ) { | ||
| await releaseLock(path, token); | ||
| throw new Error("A multiscan supervisor is already running."); | ||
| } | ||
| return async () => await releaseLock(path, token); | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; | ||
| } | ||
|
|
||
| const existing = await readLockOwner(path); | ||
| if (existing !== null && processIsRunning(existing.pid)) { | ||
| throw new Error("A multiscan supervisor is already running."); | ||
| } | ||
|
|
||
| try { | ||
| await link(pending, recovery); | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; | ||
| const recovering = await readLockOwner(recovery); | ||
| if (recovering !== null && processIsRunning(recovering.pid)) { | ||
| throw new Error("A multiscan supervisor is already running."); | ||
| } | ||
| await rm(recovery, { force: true }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a crashed Useful? React with 👍 / 👎. |
||
| continue; | ||
| } | ||
|
|
||
| try { | ||
| const current = await readLockOwner(path); | ||
| if (current !== null && processIsRunning(current.pid)) { | ||
| throw new Error("A multiscan supervisor is already running."); | ||
| } | ||
| const metadata = await lstat(path).catch((error: unknown) => { | ||
| if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; | ||
| throw error; | ||
| }); | ||
| if (metadata === null) continue; | ||
| if (metadata.isDirectory()) { | ||
| const stale = join(output, `.lock.stale-${randomUUID()}`); | ||
| await rename(path, stale); | ||
| try { | ||
| await rename(pending, path); | ||
| } finally { | ||
| await rm(stale, { recursive: true, force: true }); | ||
| } | ||
| } else { | ||
| await rename(pending, path); | ||
| } | ||
| return async () => await releaseLock(path, token); | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; | ||
| throw error; | ||
| } finally { | ||
| await releaseLock(recovery, token); | ||
| } | ||
| } | ||
| } finally { | ||
| await rm(pending, { force: true }); | ||
| } | ||
| } | ||
|
|
||
| async function readLockOwner(path: string): Promise<MultiscanLockOwner | null> { | ||
| let metadata; | ||
| try { | ||
| await mkdir(path, { mode: 0o700 }); | ||
| metadata = await lstat(path); | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; | ||
| const { pid } = JSON.parse(await readFile(ownerPath, "utf8")) as { | ||
| pid: number; | ||
| }; | ||
| if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; | ||
| throw error; | ||
| } | ||
| let ownerPath = path; | ||
| if (metadata.isDirectory()) { | ||
| ownerPath = join(path, "owner.json"); | ||
| try { | ||
| process.kill(pid, 0); | ||
| throw new Error("A multiscan supervisor is already running."); | ||
| } catch (failure) { | ||
| if ((failure as NodeJS.ErrnoException).code !== "ESRCH") throw failure; | ||
| metadata = await lstat(ownerPath); | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; | ||
| throw error; | ||
| } | ||
| const stale = join(output, `.lock.stale-${randomUUID()}`); | ||
| await rename(path, stale); | ||
| await rm(stale, { recursive: true }); | ||
| return await acquireLock(output); | ||
| } | ||
| await writeFile(ownerPath, `${JSON.stringify({ pid: process.pid })}\n`, { | ||
| flag: "wx", | ||
| mode: 0o600, | ||
| }); | ||
| return async () => rm(path, { recursive: true }); | ||
| if ( | ||
| !metadata.isFile() || | ||
| metadata.isSymbolicLink() || | ||
| metadata.size > MAX_LOCK_OWNER_BYTES | ||
| ) { | ||
| return null; | ||
| } | ||
| let value: unknown; | ||
| try { | ||
| value = JSON.parse(await readFile(ownerPath, "utf8")); | ||
| } catch (error) { | ||
| if ( | ||
| error instanceof SyntaxError || | ||
| (error as NodeJS.ErrnoException).code === "ENOENT" | ||
| ) { | ||
| return null; | ||
| } | ||
| throw error; | ||
| } | ||
| if ( | ||
| typeof value !== "object" || | ||
| value === null || | ||
| !("pid" in value) || | ||
| !Number.isSafeInteger(value.pid) || | ||
| (value.pid as number) <= 0 || | ||
| (value.pid as number) > MAX_LOCK_OWNER_PID || | ||
| ("token" in value && typeof value.token !== "string") | ||
| ) { | ||
| return null; | ||
| } | ||
| return { | ||
| pid: value.pid as number, | ||
| ...("token" in value ? { token: value.token as string } : {}), | ||
| }; | ||
| } | ||
|
|
||
| function processIsRunning(pid: number): boolean { | ||
| try { | ||
| process.kill(pid, 0); | ||
| return true; | ||
| } catch (error) { | ||
| const code = (error as NodeJS.ErrnoException).code; | ||
| if (code === "ESRCH") return false; | ||
| if (code === "EPERM") return true; | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| async function releaseLock(path: string, token: string): Promise<void> { | ||
| const owner = await readLockOwner(path); | ||
| if (owner?.token !== token) return; | ||
|
|
||
| const released = `${path}.released-${randomUUID()}`; | ||
| try { | ||
| await rename(path, released); | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code === "ENOENT") return; | ||
| throw error; | ||
| } | ||
|
|
||
| try { | ||
| const moved = await readLockOwner(released); | ||
| if (moved?.token !== token) { | ||
| await link(released, path); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a legacy directory lock is substituted after the ownership check at line 426 but before the rename at line 431, this code moves that competing lock aside and then attempts to restore it with Useful? React with 👍 / 👎. |
||
| } | ||
| } finally { | ||
| await rm(released, { force: true }); | ||
| } | ||
| } | ||
|
|
||
| async function ensureManifest( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
During legacy-directory recovery, there is a gap between moving the directory at line 337 and installing the recoverer's lock at line 339. A contender can hard-link its pending file into
.lockduring that gap, but if the recovery marker is released between itslstatandreadFile,readLockOwner(recovery)returnsnulland this return admits it even though the recoverer has already replaced its lock. Confirm that.lockstill contains this contender's token before returning, otherwise both supervisors can run concurrently.Useful? React with 👍 / 👎.