import * as fs from "node:fs"; import { randomBytes } from "node:crypto"; import { type RecordedProcess, validateSignalTarget } from "../worker/ownership.ts"; import type { RunPaths } from "../worker/status.ts"; import { writeJsonAtomic } from "../worker/status.ts"; import type { ControlKind } from "./request.ts"; export type { ControlKind } from "./request.ts"; export interface ControlWriteResult { written: boolean; nudged: boolean; degraded: boolean; /** * True when the durable request may still execute even though the call failed. * The caller must not report a clean failure: R-CTRL-1 makes the file * authoritative, so an un-rolled-back file is a pending instruction. */ indeterminate?: boolean; detail?: string; } export interface ControlPlaneDeps { write?: (file: string, value: unknown) => void; remove?: (file: string) => void; nudge?: (pid: number) => void; validate?: (recorded: RecordedProcess) => { ok: true; pid: number } | { ok: false; reason: string }; } export function controlFile(paths: RunPaths, kind: ControlKind): string { return kind === "stop" ? paths.stop : paths.interrupt; } /** Remove a rolled-back control file, distinguishing "already consumed" from "still there". */ function rollback(file: string, remove: (target: string) => void): { removed: boolean; detail?: string } { try { remove(file); return { removed: true }; } catch (error) { const code = (error as NodeJS.ErrnoException).code; // ENOENT is the good case: a concurrent worker check consumed the request // between the failed nudge and this rollback (R-CTRL-5's idempotence). if (code === "ENOENT") return { removed: true }; return { removed: false, detail: (error as Error).message }; } } /** * R-CTRL-1: the filesystem record is written **first** and is authoritative; the * signal is only a latency optimisation (R-CTRL-2). * * `recorded` is the run's process as it appears on disk, not a pid. Every signal * goes through `validateSignalTarget` first, because a recorded pid can have been * reused and `SIGUSR2`'s default disposition is to terminate whatever now owns it. * A target that cannot be proven is reported as a degraded fast path — the durable * request still lands. */ export function writeControlFirst( paths: RunPaths, kind: ControlKind, payload: Record, recorded: RecordedProcess | null, deps: ControlPlaneDeps = {}, ): ControlWriteResult { const file = controlFile(paths, kind); const write = deps.write ?? writeJsonAtomic; const remove = deps.remove ?? ((target: string) => fs.rmSync(target, { force: true })); const nudge = deps.nudge ?? signalInboxCheck; const validate = deps.validate ?? ((target: RecordedProcess) => validateSignalTarget(target)); let written = false; let writeError: Error | undefined; try { write(file, payload); written = true; } catch (error) { writeError = error as Error; } if (recorded === null) { if (writeError !== undefined) throw new Error(`control file could not be written at ${file}: ${writeError.message}`); return { written, nudged: false, degraded: false }; } const verdict = validate(recorded); if (!verdict.ok) { if (writeError !== undefined) { throw new Error(`control file could not be written at ${file}: ${writeError.message}; the signal fast path is also unavailable (${verdict.reason})`); } // Not an error: the request is durable and the worker's own poll will see it // within CONTROL_POLL_MS. Skipping an unprovable signal is the whole point. return { written: true, nudged: false, degraded: true, detail: `signal fast path skipped (${verdict.reason}); the filesystem request remains authoritative` }; } try { nudge(verdict.pid); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOSYS") { if (writeError !== undefined) throw new Error(`control file could not be written at ${file}: ${writeError.message}; signal fast path is unavailable (ENOSYS)`); return { written: true, nudged: false, degraded: false, detail: "signal fast path unavailable (ENOSYS); filesystem request remains authoritative" }; } if (written) { const rolledBack = rollback(file, remove); if (!rolledBack.removed) { // The authoritative request is still on disk and a future poll or revive // will execute it. Reporting a clean failure here would be a lie. const detail = `signal failed (${(error as Error).message}) and the control request at ${file} could not be rolled back ` + `(${rolledBack.detail ?? "unknown error"}); it may still execute`; if (writeError !== undefined) throw new Error(`control file write failed (${writeError.message}); ${detail}`); return { written: true, nudged: false, degraded: true, indeterminate: true, detail }; } } if (writeError !== undefined) { throw new Error(`control file write failed (${writeError.message}); signal fast path also failed (${(error as Error).message})`); } throw error; } if (writeError !== undefined) { throw new Error(`control file could not be written at ${file}: ${writeError.message}; the signal fast path was attempted but cannot replace the durable request`); } return { written: true, nudged: true, degraded: false }; } export function signalInboxCheck(pid: number): void { if (pid <= 0) throw new Error(`invalid worker pid ${pid}`); const signal: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2"; // A nudge is not termination, and it is never sent to a process group: group // signalling would also hit the worker's active bash child, whose default // SIGUSR2 action may be to terminate. The pid itself has already been proven to // be the recorded worker by `validateSignalTarget`. process.kill(pid, signal); } export function newControlRequestId(now = Date.now()): string { return `c_${now.toString(36)}_${randomBytes(10).toString("base64url")}`; }