/** * The admission acknowledgement fence: proof that a *running* daemon, not just * a daemon-shaped process, has seen the current install and admitted work under * it. * * Setup and upgrade both need that proof and neither can get it from the store, * because a daemon that never reached its first tick would leave the last * daemon's rows sitting there looking like an answer. So the record is written * by the tick itself, stamped with the generation of the process that wrote it, * and `wakeDaemon` is here beside it as the one nudge that shortens the wait for * it to appear. * * Kept out of `admission.ts` (the top-level gate module) and out of * `admission-pass.ts` (the tick's use of it) on purpose: this is neither a gate * nor a pass, it is the handshake either side of a restart reads. */ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { stateDir } from "../config.ts"; import { httpAuthHeader } from "../http-token.ts"; import { SYSTEMD_UNIT, livingDaemon, probeUnit } from "../lifecycle.ts"; import { pauseInstance } from "../pause.ts"; // acknowledgement (#651, review #3) // // The setup-fence's "acknowledgement" must come from the daemon itself, not a // second synchronous worker count: a tick that passed its pause gate before // the fence landed can sit in awaited tracker/routing/admission work and claim // after the count and before setup mutates. Two places make that impossible // and record it durably: // // - the tick's pause gate re-acknowledges every held pass (the daemon has // reached its admission boundary and claims nothing); // - the claim itself re-checks the pause immediately before creating the run // row, so a tick already past its gate when the fence landed refuses at // the claim — and writes the acknowledgement — instead of admitting. // // The acknowledgement file names the exact pause instance observed and the // daemon generation that observed it, so the setup barrier can prove the // acknowledged fence is the fence it froze and the acknowledgement belongs to // the daemon it began with. /** The durable admission acknowledgement a daemon writes when it observes a * pause fence at an admission boundary. */ export interface AdmissionAckRecord { /** The pause instance observed (source token, reason, creation instant). */ pause: { source: string; reason?: string; since: number }; /** The daemon generation that observed it — {@link daemonGeneration}. */ daemon: string; /** When the daemon observed the fence. */ observedAt: number; } /** The acknowledgement file path. One per host: the fence is host-global. */ export function admissionAckPath(): string { return join(stateDir(), "admission-ack.json"); } /** * The generation identity of the running daemon, or `undefined` when nothing * provably runs. The live pidfile record wins (its pid and boot instant * identify the exact instance, #377); a record-less ACTIVE unit is still a * running daemon and its MainPID is the generation that lets a fence spot a * supervisor restart (#651 review #2). Shared by the ack writer and the setup * barrier's identity so both sides of the acknowledgement name the same * instance by the same rule. */ export function daemonGeneration(): string | undefined { const daemon = livingDaemon(); if (daemon !== undefined) return `${daemon.pid}@${daemon.startedAt}`; const ownership = probeUnit(SYSTEMD_UNIT); return ownership.kind === "active" ? `systemd:${ownership.pid}` : undefined; } /** Reads the current admission acknowledgement, if one is readable. A corrupt * or unreadable file is absence — the barrier fails closed on the absence. */ export function readAdmissionAck(): AdmissionAckRecord | undefined { let parsed: unknown; try { parsed = JSON.parse(readFileSync(admissionAckPath(), "utf8")); } catch { return undefined; } if (parsed === null || typeof parsed !== "object") return undefined; const r = parsed as Record; const pause = r["pause"]; const daemon = r["daemon"]; const observedAt = r["observedAt"]; if (pause === null || typeof pause !== "object") return undefined; const p = pause as Record; const source = p["source"]; const since = p["since"]; const reason = p["reason"]; if (typeof source !== "string" || source.length === 0) return undefined; if (typeof since !== "number" || !Number.isFinite(since)) return undefined; if (reason !== undefined && typeof reason !== "string") return undefined; if (typeof daemon !== "string" || daemon.length === 0) return undefined; if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) return undefined; return { pause: { source, since, ...(reason === undefined ? {} : { reason }) }, daemon, observedAt }; } /** * Writes the admission acknowledgement for the pause the daemon just observed. * The observed instance is the most restrictive fence in force: a host-global * sentinel gates every project and is the pause a host-wide transaction (setup, * an all-projects hold) froze, so it wins over a project-scoped sentinel; * otherwise the project's own sentinel is the effective one for the daemon's * claims, matching {@link pauseInstance}. Recording the project sentinel while * a global fence is also in force would name a narrower, older hold instead of * the fence that actually gates the host — and a barrier that froze the global * sentinel could then never match its own acknowledgement (#651 review #4). A * no-op when no pause is readable: there is nothing to acknowledge. The * generation is the writing daemon's own (the record it owns, or its * supervised MainPID). */ export function writeAdmissionAck(project: string): void { // `pauseInstance()` reads only the global sentinel; while it is in force it // is the fence that gates every project, and the durable record must name it // rather than a per-project hold that predates it. const pause = pauseInstance() ?? pauseInstance(project); if (pause === undefined) return; const generation = daemonGeneration(); if (generation === undefined) return; const path = admissionAckPath(); mkdirSync(dirname(path), { recursive: true }); const record: AdmissionAckRecord = { pause, daemon: generation, observedAt: Date.now() }; const tmp = `${path}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`; try { writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }); renameSync(tmp, path); } catch (err) { rmSync(tmp, { force: true }); throw err; } } /** Wake the daemon's dispatch loop to prompt a pass (best-effort; used by the * setup barrier so a live daemon acknowledges the fence without waiting for * its next five-minute tick). A refused/failed wake just lengthens the wait; * the barrier's deadline is what bounds it. * * `POST /wake` is authenticated (Phase 4), so this reads the token the daemon * minted at start. An absent token means no daemon has ever started under this * `$OMP_CONDUCTOR_HOME`, so there is nothing to wake: skip the request rather * than spend a round trip earning a 401. Still void, still best-effort — the * contract is unchanged, only the reason a wake can be a no-op is wider. */ export async function wakeDaemon(port: number): Promise { const auth = httpAuthHeader(); if (auth === undefined) return; try { await fetch(`http://127.0.0.1:${port}/wake`, { method: "POST", headers: auth, signal: AbortSignal.timeout(1_500), }); } catch { // Best-effort by contract: the caller's bounded wait is the backstop. } }