From d7b1a3522ecdeca5653f9ed46742f634fb6f11a6 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Sun, 4 Oct 2026 21:32:17 -0400 Subject: [PATCH 1/5] fix: preserve exact-lease HTTPS release diagnostics --- docs/guides/native-candidate.md | 9 + src/backends/native-https-owner-protocol.ts | 8 +- src/backends/native-https-owner-server.ts | 67 +++++- src/backends/native-https-owner.ts | 12 +- src/backends/native-https-release-failure.ts | 127 +++++++++++ tests/native-https-owner.test.ts | 90 ++++++++ tests/native-https-release-failure.test.ts | 217 +++++++++++++++++++ 7 files changed, 521 insertions(+), 9 deletions(-) create mode 100644 src/backends/native-https-release-failure.ts create mode 100644 tests/native-https-release-failure.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 5e2927637..f388b1a50 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1401,3 +1401,12 @@ This record is diagnostic evidence only: it does not acknowledge retirement or permit a replacement owner. A missing record (including a helper crash before publication) leaves the cause unknown. Preserve the retained owner and finalization records for inspection; do not delete them to force another startup. + +When an HTTPS owner refuses an exact lease release, it now attempts a bounded +failure response containing only the release stage and a reviewed native error +code. The client verifies the complete lease identity and rejects extra or +noncanonical protocol data. This response is not a release acknowledgement and +never triggers a retry: transport loss still leaves the outcome unconfirmed. +For example, an observed `graph-verification: provider_busy` would locate the +refusal without exposing native stderr; it would not prove cleanup completed or +permit another owner to start. Preserve the original records while investigating. diff --git a/src/backends/native-https-owner-protocol.ts b/src/backends/native-https-owner-protocol.ts index ac2c43af6..b46b25ebf 100644 --- a/src/backends/native-https-owner-protocol.ts +++ b/src/backends/native-https-owner-protocol.ts @@ -272,7 +272,13 @@ export function decodeNativeHttpsOwnerFrame(bytes: Buffer): unknown { throw nativeHttpsOwnerRefused(); } try { - return JSON.parse(bytes.toString("utf8")); + const value: unknown = JSON.parse(bytes.toString("utf8")); + // This private protocol is emitted by encodeNativeHttpsOwnerFrame. Require + // its exact encoding so duplicate keys cannot turn a refusal into an ack. + if (!bytes.equals(encodeNativeHttpsOwnerFrame(value))) { + throw nativeHttpsOwnerRefused(); + } + return value; } catch { throw nativeHttpsOwnerRefused(); } diff --git a/src/backends/native-https-owner-server.ts b/src/backends/native-https-owner-server.ts index 3600e15a9..0c0602c0b 100644 --- a/src/backends/native-https-owner-server.ts +++ b/src/backends/native-https-owner-server.ts @@ -38,6 +38,11 @@ import { nativeHttpsRemoveFile, nativeHttpsWriteNew, } from "./native-https-owner-storage.ts"; +import { + type NativeHttpsReleaseStage, + nativeHttpsReleaseFailureFrame, + sendNativeHttpsReleaseFailure, +} from "./native-https-release-failure.ts"; import { NativeHttpsStartupError, recordNativeHttpsStartupFailure, @@ -45,6 +50,15 @@ import { import { startNativeProjectHttps } from "./native-project-https.ts"; import { invokeNativeRuntime } from "./native-runtime-client.ts"; +type ReleaseProgress = { stage: NativeHttpsReleaseStage }; +function markReleaseStage( + progress: ReleaseProgress | undefined, + stage: NativeHttpsReleaseStage +): void { + if (progress) { + progress.stage = stage; + } +} type Frontend = Awaited>; export interface NativeHttpsOwnerServerDependencies { readonly start: (binding: NativeHttpsOwnerBinding) => Promise; @@ -322,13 +336,19 @@ export async function serveNativeHttpsOwner(opts: { socket: Socket; identity: NativeHttpsLeaseIdentity; file?: NativeHttpsFileIdentity; + progress: ReleaseProgress; }) => { + markReleaseStage(released?.progress, "owner-validation"); await checkPaths(); + markReleaseStage(released?.progress, "idle-verification"); await opts.dependencies.verifyIdle(binding); state = "closing"; + markReleaseStage(released?.progress, "frontend-close"); await frontend?.close(); + markReleaseStage(released?.progress, "owner-retirement"); await checkPaths(); if (released) { + released.progress.stage = "release-publication"; await nativeHttpsRecordRelease({ version: 1, identity: released.identity, @@ -343,6 +363,7 @@ export async function serveNativeHttpsOwner(opts: { } leases.delete(released.identity.leaseId); } + markReleaseStage(released?.progress, "owner-retirement"); if (leases.size !== 0 || (await readdir(leaseRoot)).length !== 0) { throw nativeHttpsOwnerRefused(); } @@ -435,8 +456,10 @@ export async function serveNativeHttpsOwner(opts: { }; const releaseUnadmittedLease = async ( socket: Socket, - identity: NativeHttpsLeaseIdentity + identity: NativeHttpsLeaseIdentity, + progress: ReleaseProgress ) => { + progress.stage = "lease-validation"; if ( !sameNativeHttpsLease( nativeHttpsLeaseIdentity(configuration, identity), @@ -445,7 +468,9 @@ export async function serveNativeHttpsOwner(opts: { ) { throw nativeHttpsOwnerRefused(); } + progress.stage = "graph-verification"; await opts.dependencies.verify(binding, identity, "release"); + progress.stage = "lease-validation"; try { await lstat(join(leaseRoot, `${identity.leaseId}.json`)); throw nativeHttpsOwnerRefused(); @@ -455,9 +480,10 @@ export async function serveNativeHttpsOwner(opts: { } } if (leases.size === 0) { - await retire({ socket, identity }); + await retire({ socket, identity, progress }); return; } + progress.stage = "release-publication"; // A persisted acquire intent may never have been delivered. Only this // generation's live serialized owner can certify it was never admitted. try { @@ -482,7 +508,11 @@ export async function serveNativeHttpsOwner(opts: { ); return; }; - const handle = async (socket: Socket, request: NativeHttpsOwnerRequest) => { + const handle = async ( + socket: Socket, + request: NativeHttpsOwnerRequest, + progress: ReleaseProgress + ) => { if (state !== "running" || frontendFailed) { throw nativeHttpsOwnerRefused(); } @@ -490,18 +520,26 @@ export async function serveNativeHttpsOwner(opts: { if (request.operation === "acquire") { return acquireLease(socket, request); } + progress.stage = "lease-validation"; const entry = leases.get(request.identity.leaseId); if (!entry) { - return releaseUnadmittedLease(socket, request.identity); + return releaseUnadmittedLease(socket, request.identity, progress); } if (!sameNativeHttpsLease(entry.identity, request.identity)) { throw nativeHttpsOwnerRefused(); } + progress.stage = "graph-verification"; await opts.dependencies.verify(binding, entry.identity, "release"); if (leases.size === 1) { - await retire({ socket, identity: entry.identity, file: entry.file }); + await retire({ + socket, + identity: entry.identity, + file: entry.file, + progress, + }); return; } + progress.stage = "release-publication"; await nativeHttpsRecordRelease({ version: 1, identity: entry.identity, @@ -567,8 +605,23 @@ export async function serveNativeHttpsOwner(opts: { bytes = Buffer.alloc(0); busy = true; socket.setTimeout(0); - void serialize(() => handle(socket, request)) - .catch(() => { + const progress: ReleaseProgress = { stage: "owner-validation" }; + void serialize(() => handle(socket, request, progress)) + .catch(async (error: unknown) => { + if (request.operation === "release") { + try { + await sendNativeHttpsReleaseFailure( + socket, + nativeHttpsReleaseFailureFrame({ + identity: request.identity, + stage: progress.stage, + error, + }) + ); + } catch { + // Diagnostic delivery never grants authority or prevents the existing refusal. + } + } socket.destroy(); // If retirement began, never accept another lease in a half-closed state. if (state === "closing") { diff --git a/src/backends/native-https-owner.ts b/src/backends/native-https-owner.ts index b4ab4ee25..753bdf0ed 100644 --- a/src/backends/native-https-owner.ts +++ b/src/backends/native-https-owner.ts @@ -36,6 +36,7 @@ import { nativeHttpsReadRetiredOwner, nativeHttpsWriteNew, } from "./native-https-owner-storage.ts"; +import { nativeHttpsReleaseReplyError } from "./native-https-release-failure.ts"; import { NativeHttpsStartupError, readNativeHttpsStartupFailure, @@ -377,7 +378,16 @@ export async function requestNativeHttpsOwner( return; } try { - finish(undefined, decodeNativeHttpsOwnerFrame(bytes)); + const reply = decodeNativeHttpsOwnerFrame(bytes); + if ( + isRecord(value) && + value.operation === "release" && + isNativeHttpsLeaseIdentity(value.identity) + ) { + finish(nativeHttpsReleaseReplyError(reply, value.identity), reply); + return; + } + finish(undefined, reply); } catch { finish(nativeHttpsOwnerRefused()); } diff --git a/src/backends/native-https-release-failure.ts b/src/backends/native-https-release-failure.ts new file mode 100644 index 000000000..69fccda6a --- /dev/null +++ b/src/backends/native-https-release-failure.ts @@ -0,0 +1,127 @@ +import type { Socket } from "node:net"; +import { isRecord } from "../lib/guards.ts"; +import { + encodeNativeHttpsOwnerFrame, + isNativeHttpsLeaseIdentity, + type NativeHttpsLeaseIdentity, + nativeHttpsOwnerRefused, + sameNativeHttpsLease, +} from "./native-https-owner-protocol.ts"; +import { NativeRuntimeRequestError } from "./native-runtime-client.ts"; + +const STAGES = [ + "owner-validation", + "lease-validation", + "graph-verification", + "idle-verification", + "frontend-close", + "release-publication", + "owner-retirement", +] as const; +export type NativeHttpsReleaseStage = (typeof STAGES)[number]; +const CODES = new Set([ + "provider_busy", + "provider_state", + "foreign_state", + "invalid_receipt", + "recovery_required", + "host_endpoint_identity", + "engine_protocol", +]); + +/** A failure response carries no proof of effects: successful retirement requires its normal acknowledgement. */ +export function nativeHttpsReleaseFailureFrame(opts: { + readonly identity: NativeHttpsLeaseIdentity; + readonly stage: NativeHttpsReleaseStage; + readonly error: unknown; +}): Buffer { + if ( + !( + isNativeHttpsLeaseIdentity(opts.identity) && + STAGES.some((stage) => stage === opts.stage) + ) + ) { + throw nativeHttpsOwnerRefused(); + } + const nativeCode = + opts.error instanceof NativeRuntimeRequestError && + opts.error.nativeCode && + CODES.has(opts.error.nativeCode) + ? opts.error.nativeCode + : null; + return encodeNativeHttpsOwnerFrame({ + version: 1, + ok: false, + operation: "release", + identity: opts.identity, + stage: opts.stage, + nativeCode, + }); +} + +/** Reject extra fields, arbitrary diagnostics and another attempt's response before reporting any code. */ +export function parseNativeHttpsReleaseFailure( + value: unknown, + identity: NativeHttpsLeaseIdentity +): Error { + if ( + !( + isRecord(value) && + Object.keys(value).sort().join() === + "identity,nativeCode,ok,operation,stage,version" && + value.version === 1 && + value.ok === false && + value.operation === "release" && + isNativeHttpsLeaseIdentity(value.identity) && + sameNativeHttpsLease(identity, value.identity) && + STAGES.some((stage) => stage === value.stage) && + (value.nativeCode === null || + (typeof value.nativeCode === "string" && CODES.has(value.nativeCode))) + ) + ) { + return nativeHttpsOwnerRefused(); + } + return new Error( + `Native HTTPS release is unconfirmed (${value.stage}${value.nativeCode ? `: ${value.nativeCode}` : ""}); ownership evidence is retained, no request was replayed. Values omitted.` + ); +} + +/** Best-effort bounded delivery, then the caller closes the failed request connection. */ +export async function sendNativeHttpsReleaseFailure( + socket: Socket, + frame: Buffer +): Promise { + await new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + socket.off("close", finish); + socket.off("error", finish); + resolve(); + }; + const timer = setTimeout(finish, 1000); + socket.once("close", finish); + socket.once("error", finish); + try { + socket.write(frame, finish); + } catch { + finish(); + } + }); +} + +/** A diagnostic, malformed reply, or lost response never acknowledges release. */ +export function nativeHttpsReleaseReplyError( + value: unknown, + identity: NativeHttpsLeaseIdentity +): Error | undefined { + if ( + isRecord(value) && + Object.keys(value).sort().join() === "ok,released,version" && + value.version === 1 && + value.ok === true && + value.released === identity.leaseId + ) { + return; + } + return parseNativeHttpsReleaseFailure(value, identity); +} diff --git a/tests/native-https-owner.test.ts b/tests/native-https-owner.test.ts index 655784cd4..e7369fc92 100644 --- a/tests/native-https-owner.test.ts +++ b/tests/native-https-owner.test.ts @@ -76,6 +76,7 @@ async function fixture( readonly startFailure?: boolean; readonly hardExit?: boolean; readonly holdRetirement?: boolean; + readonly releaseFault?: "graph-verification" | "idle-verification"; /** Fault injected between control-socket publication and its first observation. */ readonly afterPublish?: "fail" | "replace"; } = {} @@ -93,6 +94,8 @@ import { appendFile, chmod, readFile, unlink, writeFile } from "node:fs/promises import { NativeRuntimeRequestError } from ${JSON.stringify(resolve(import.meta.dir, "../src/backends/native-runtime-client.ts"))}; const home = ${JSON.stringify(home)}; const fault = ${JSON.stringify(options.afterPublish ?? null)}; +const releaseFault = ${JSON.stringify(options.releaseFault ?? null)}; +const failRelease = (stage) => { if (releaseFault === stage) throw new NativeRuntimeRequestError({ message: "/private/fixture/secret-CANARY", nativeCode: "provider_busy" }); }; await writeFile(home + "/helper-pid", String(process.pid)); ${options.hardExit ? "process.exit(7);" : ""} const never = ${options.immediateExit ? 'Promise.resolve({component:"fixture",code:1})' : "new Promise(() => {})"}; @@ -107,6 +110,7 @@ try { }; }, verify: async (_binding, lease, phase) => { + if (phase === "release") { await appendFile(home + "/release-verifies", "verify\\n"); failRelease("graph-verification"); } if (phase === "release" && await readFile(home + "/clean-" + lease.run, "utf8") !== "clean") { throw new Error("unproven"); } }, afterPublish: async (path) => { @@ -116,6 +120,7 @@ try { if (fault === "replace") { await unlink(path); await writeFile(path, "foreign replacement", { mode: 0o644 }); await chmod(path, 0o644); } }, verifyIdle: async () => { + failRelease("idle-verification"); ${ options.holdRetirement ? `await writeFile(home + "/retire-entered", "yes"); @@ -936,3 +941,88 @@ test("helper exit without a failure receipt remains unknown and cannot authorize readFile(join(nativeHttpsOwnerRoot(f.home), "startup-failure.json")) ).rejects.toThrow(); }, 25_000); +test.each([ + "graph-verification", + "idle-verification", +] as const)("release %s failure preserves evidence and reports a safe code without acknowledgement", async (stage) => { + const f = await fixture({ releaseFault: stage }); + const started = await f.start(); + const identity = await f.acquire( + started.socket, + started.configuration.ownerGeneration, + "b" + ); + await f.clean("b"); + const root = nativeHttpsOwnerRoot(f.home); + const paths = [ + join(root, "configuration.json"), + join(root, "endpoint.json"), + join(root, "leases", `${identity.leaseId}.json`), + ]; + const before = await Promise.all(paths.map((path) => readFile(path))); + await expect(f.release(started.socket, identity)).rejects.toThrow( + `${stage}: provider_busy` + ); + expect(await Promise.all(paths.map((path) => readFile(path)))).toEqual( + before + ); + expect(await readFile(join(f.home, "events"), "utf8")).toBe("start\n"); + expect(await readFile(join(f.home, "release-verifies"), "utf8")).toBe( + "verify\n" + ); + expect( + await Bun.file(nativeHttpsLeaseReleasePath(f.home, identity)).exists() + ).toBe(false); +}); + +test("foreign release identity is refused before verification and leaves the exact lease intact", async () => { + const f = await fixture(); + const started = await f.start(); + const identity = await f.acquire( + started.socket, + started.configuration.ownerGeneration, + "b" + ); + const leasePath = join( + nativeHttpsOwnerRoot(f.home), + "leases", + `${identity.leaseId}.json` + ); + const before = await readFile(leasePath); + await expect( + f.release(started.socket, { ...identity, attempt: "c".repeat(32) }) + ).rejects.toThrow("lease-validation"); + expect(await readFile(leasePath)).toEqual(before); + expect(await Bun.file(join(f.home, "release-verifies")).exists()).toBe(false); + expect( + await Bun.file(nativeHttpsLeaseReleasePath(f.home, identity)).exists() + ).toBe(false); + expect(await readFile(join(f.home, "events"), "utf8")).toBe("start\n"); +}); + +test("extra release request data is refused without verification or changing the lease", async () => { + const f = await fixture(); + const started = await f.start(); + const identity = await f.acquire( + started.socket, + started.configuration.ownerGeneration, + "b" + ); + const path = join( + nativeHttpsOwnerRoot(f.home), + "leases", + `${identity.leaseId}.json` + ); + const before = await readFile(path); + await expect( + requestNativeHttpsOwner(started.socket, { + version: 1, + operation: "release", + identity, + extra: "secret-CANARY", + }) + ).rejects.toThrow("ownership is unavailable or unconfirmed"); + expect(await readFile(path)).toEqual(before); + expect(await Bun.file(join(f.home, "release-verifies")).exists()).toBe(false); + expect(await readFile(join(f.home, "events"), "utf8")).toBe("start\n"); +}); diff --git a/tests/native-https-release-failure.test.ts b/tests/native-https-release-failure.test.ts new file mode 100644 index 000000000..83829c46b --- /dev/null +++ b/tests/native-https-release-failure.test.ts @@ -0,0 +1,217 @@ +import { afterEach, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { createConnection, createServer, type Socket } from "node:net"; +import { requestNativeHttpsOwner } from "../src/backends/native-https-owner.ts"; +import { + decodeNativeHttpsOwnerFrame, + encodeNativeHttpsOwnerFrame, + type NativeHttpsLeaseIdentity, +} from "../src/backends/native-https-owner-protocol.ts"; +import { + nativeHttpsReleaseFailureFrame, + nativeHttpsReleaseReplyError, + parseNativeHttpsReleaseFailure, + sendNativeHttpsReleaseFailure, +} from "../src/backends/native-https-release-failure.ts"; +import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; + +const identity: NativeHttpsLeaseIdentity = { + version: 1, + ownerGeneration: "a".repeat(32), + owner: "b".repeat(32), + leaseId: "c".repeat(32), + run: "d".repeat(32), + attempt: "e".repeat(32), + namespace: "f".repeat(64), + planId: "1".repeat(64), +}; +const canary = "/private/fixture/secret-token-CANARY"; +const frame = nativeHttpsReleaseFailureFrame({ + identity, + stage: "graph-verification", + error: new NativeRuntimeRequestError({ + message: canary, + nativeCode: "provider_busy", + nativeCauseCode: canary, + }), +}); +const failure = decodeNativeHttpsOwnerFrame(frame) as Record; +const ack = { version: 1, ok: true, released: identity.leaseId }; + +test("release failure reports only an exact lease, fixed stage and reviewed native code", () => { + expect(frame.toString()).not.toContain(canary); + expect(parseNativeHttpsReleaseFailure(failure, identity).message).toBe( + "Native HTTPS release is unconfirmed (graph-verification: provider_busy); ownership evidence is retained, no request was replayed. Values omitted." + ); + expect(nativeHttpsReleaseReplyError(ack, identity)).toBeUndefined(); + expect(nativeHttpsReleaseReplyError(failure, identity)).toBeInstanceOf(Error); +}); + +test.each([ + new Error(canary), + { nativeCode: "provider_busy", message: canary }, + new NativeRuntimeRequestError({ message: canary, nativeCode: canary }), + new NativeRuntimeRequestError({ message: canary }), +])("untyped and unreviewed release diagnostics remain value-free", (error) => { + const bytes = nativeHttpsReleaseFailureFrame({ + identity, + stage: "frontend-close", + error, + }); + expect(bytes.toString()).not.toContain(canary); + const parsed = decodeNativeHttpsOwnerFrame(bytes); + expect(parsed).toEqual({ + ...failure, + stage: "frontend-close", + nativeCode: null, + }); + expect( + parseNativeHttpsReleaseFailure(parsed, identity).message + ).not.toContain("provider_busy"); +}); + +test.each([ + "ownerGeneration", + "leaseId", + "owner", + "run", + "attempt", + "namespace", + "planId", +] as const)("another %s cannot supply a release diagnostic", (key) => { + const foreign = { ...identity, [key]: "9".repeat(identity[key].length) }; + expect( + parseNativeHttpsReleaseFailure({ ...failure, identity: foreign }, identity) + .message + ).toContain("ownership is unavailable or unconfirmed"); +}); + +test.each( + [ + null, + [], + {}, + { ...failure, version: 2 }, + { ...failure, ok: true }, + { ...failure, operation: "acquire" }, + { ...failure, stage: canary }, + { ...failure, nativeCode: canary }, + { ...failure, stderr: canary }, + { ...failure, released: identity.leaseId }, + { ...failure, identity: { ...identity, extra: canary } }, + { ...ack, operation: "release" }, + { ...ack, released: "0".repeat(32) }, + { ...ack, error: failure }, + ].map((value) => [value] as const) +)("malformed and extra response data cannot acknowledge release or disclose values", (value) => { + const error = nativeHttpsReleaseReplyError(value, identity); + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain("ownership is unavailable or unconfirmed"); + expect(error?.message).not.toContain(canary); +}); + +const cleanups: (() => Promise)[] = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) { + await cleanup(); + } +}); +async function replyFixture(bytes: Buffer | undefined) { + let requests = 0; + const peers: Socket[] = []; + const server = createServer((peer) => { + peers.push(peer); + peer.once("data", () => { + requests += 1; + if (bytes) { + peer.end(bytes); + } else { + peer.destroy(); + } + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("missing fixture port"); + } + const socket = createConnection({ host: "127.0.0.1", port: address.port }); + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + cleanups.push(async () => { + socket.destroy(); + for (const peer of peers) { + peer.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + }); + return { socket, requests: () => requests }; +} + +test.each([ + ["exact diagnostic", frame], + ["transport close", undefined], + ["oversized", Buffer.alloc(8193, 65)], + ["malformed JSON", Buffer.from("{broken}\n")], + [ + "extra response field", + encodeNativeHttpsOwnerFrame({ ...failure, extra: canary }), + ], + [ + "wrong operation", + encodeNativeHttpsOwnerFrame({ ...failure, operation: "acquire" }), + ], + [ + "foreign identity", + encodeNativeHttpsOwnerFrame({ + ...failure, + identity: { ...identity, run: "8".repeat(32) }, + }), + ], + [ + "duplicate ok", + Buffer.from( + `{"ok":false,"ok":true,"version":1,"released":"${identity.leaseId}"}\n` + ), + ], + [ + "duplicate identity", + Buffer.from( + frame.toString().replace('"identity":', `"identity":{},"identity":`) + ), + ], + ["pipelined ack", Buffer.concat([frame, encodeNativeHttpsOwnerFrame(ack)])], +] as const)("real release request refuses %s exactly once", async (name, bytes) => { + const f = await replyFixture(bytes); + const result = await requestNativeHttpsOwner(f.socket, { + version: 1, + operation: "release", + identity, + }).then( + () => { + throw new Error("unexpected release acknowledgement"); + }, + (error: unknown) => error + ); + expect(result).toBeInstanceOf(Error); + const message = (result as Error).message; + expect(message).not.toContain(canary); + expect(message).toContain( + name === "exact diagnostic" + ? "graph-verification: provider_busy" + : "ownership is unavailable or unconfirmed" + ); + expect(f.requests()).toBe(1); +}); + +test("best-effort diagnostic delivery is bounded when a socket never flushes", async () => { + const peer = new EventEmitter(); + Object.assign(peer, { write: () => false }); + const start = Date.now(); + await sendNativeHttpsReleaseFailure(peer as Socket, frame); + expect(Date.now() - start).toBeLessThan(3000); + expect(peer.listenerCount("error")).toBe(0); + expect(peer.listenerCount("close")).toBe(0); +}); From 076ca262a39cb33f69abad118a68cc91379074df Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Sun, 4 Oct 2026 21:50:41 -0400 Subject: [PATCH 2/5] fix: wait for HTTPS authority startup admission --- docs/guides/native-candidate.md | 13 ++ .../provider/hostname_authority/managed.rs | 101 ++++++++++++++- .../runtime-core/src/provider/lifecycle.rs | 4 +- src/backends/native-https-startup-failure.ts | 5 + src/backends/native-project-https.ts | 53 +++++++- tests/native-project-https.test.ts | 118 ++++++++++++++++++ 6 files changed, 283 insertions(+), 11 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 5e2927637..d0503908d 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1401,3 +1401,16 @@ This record is diagnostic evidence only: it does not acknowledge retirement or permit a replacement owner. A missing record (including a helper crash before publication) leaves the cause unknown. Preserve the retained owner and finalization records for inspection; do not delete them to force another startup. + +Managed HTTPS authority startup uses the same five-second provider-lock admission +budget as ordinary pool startup. This lets a brief concurrent graph inspection +finish before admission. Pool identity, socket ownership and certificate admission +are checked only after the lock is acquired; expiry refuses with `provider_busy` +and no authority is published. Serving and cleanup are never replayed. + +An authority child that exits before readiness can supply a bounded structured +native error code without exposing its stderr. Malformed, oversized or missing +output leaves the cause unknown. The `authority-ready` diagnostic now covers only +readiness; later socket-identity, permission-port and Caddyfile failures have +separate stages. These classifications do not acknowledge cleanup or permit an +owner to be adopted or replaced. diff --git a/packages/runtime-core/src/provider/hostname_authority/managed.rs b/packages/runtime-core/src/provider/hostname_authority/managed.rs index 2741fd832..8f8ebdc26 100644 --- a/packages/runtime-core/src/provider/hostname_authority/managed.rs +++ b/packages/runtime-core/src/provider/hostname_authority/managed.rs @@ -1,7 +1,7 @@ //! Explicit foreground authority bound to the candidate pool's lifetime. -use super::super::{state, status}; +use super::super::{lifecycle, state, status}; use crate::{Candidate, CandidateError}; -use std::path::PathBuf; +use std::{path::PathBuf, time::Duration}; fn socket(owner: &state::Owner) -> PathBuf { PathBuf::from(format!( "/private/tmp/hka-{}-{}/route.sock", @@ -29,7 +29,16 @@ pub fn serve_with_certificate_limit( c: &Candidate, limit: Option, ) -> Result<(), CandidateError> { - let lock = state::Lock::acquire(&c.state_root.join("run/smolvm"))?; + serve_with_startup_wait(c, limit, lifecycle::STARTUP_LEASE_WAIT) +} +fn serve_with_startup_wait( + c: &Candidate, + limit: Option, + wait: Duration, +) -> Result<(), CandidateError> { + // Graph inspection also holds this lease. Wait before admission, then reload + // the current pool identity; neither socket publication nor serving is replayed. + let lock = lifecycle::startup_lease(&c.state_root.join("run/smolvm"), wait)?; let current = status(c)?; if current.phase != "running" || current.process_alive != Some(true) { return Err(super::error()); @@ -59,3 +68,89 @@ pub(crate) fn stop(c: &Candidate, owner: &state::Owner) -> Result<(), CandidateE super::ownership::stop(c, &path, hash)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + time::{Instant, SystemTime, UNIX_EPOCH}, + }; + + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + let root = fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!( + "hkl-authority-admission-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + state::private_directory(&root).unwrap(); + Self(root) + } + fn candidate(&self) -> Candidate { + Candidate::discover(&self.0).unwrap() + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn authority_startup_waits_for_inspection_before_reading_pool_state() { + let fixture = Fixture::new(); + let candidate = fixture.candidate(); + let root = candidate.state_root.join("run/smolvm"); + let held = state::Lock::acquire(&root).unwrap(); + let worker = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(150)); + drop(held); + }); + let start = Instant::now(); + let result = serve_with_startup_wait(&candidate, None, Duration::from_secs(2)); + worker.join().unwrap(); + assert!(start.elapsed() >= Duration::from_millis(100)); + // The exact production path gets past contention and rejects the unprepared + // pool. It must not publish an authority just because the lock became free. + assert!(matches!(result, Err(e) if e.code == "hostname_authority")); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + assert!(!root.join("owner.json").exists()); + assert!( + !candidate + .state_root + .join("run/certificate-admission") + .exists() + ); + assert!(state::Lock::acquire(&root).is_ok()); + } + + #[test] + fn authority_startup_contention_deadline_admits_no_pool_or_socket_effect() { + let fixture = Fixture::new(); + let candidate = fixture.candidate(); + let root = candidate.state_root.join("run/smolvm"); + let _held = state::Lock::acquire(&root).unwrap(); + let start = Instant::now(); + let result = serve_with_startup_wait(&candidate, None, Duration::from_millis(100)); + assert!(start.elapsed() >= Duration::from_millis(75)); + assert!( + matches!(result, Err(e) if e.code == "provider_busy" && e.message.contains("no operation was admitted")) + ); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + assert!(!root.join("owner.json").exists()); + assert!( + !candidate + .state_root + .join("run/certificate-admission") + .exists() + ); + assert!(matches!(state::Lock::acquire(&root), Err(e) if e.code == "provider_busy")); + } +} diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index 79e93d142..9dcceb193 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -273,12 +273,12 @@ fn operation_lease( /// How long ordinary startup waits for a provider lease that another operation holds, such as /// the `graph inspect` behind `hack ps`. -const STARTUP_LEASE_WAIT: Duration = Duration::from_secs(5); +pub(super) const STARTUP_LEASE_WAIT: Duration = Duration::from_secs(5); /// Startup's provider lease. Everything `start_pool` does before taking it is read-only /// validation, admission sampling and RAII guards, so waiting for a briefly held lease admits /// nothing early, and on expiry it refuses `provider_busy` with nothing admitted. -fn startup_lease(root: &Path, wait: Duration) -> Result { +pub(super) fn startup_lease(root: &Path, wait: Duration) -> Result { operation_lease(root, Some(Instant::now() + wait), || Ok(())).map(|(lock, ())| lock) } diff --git a/src/backends/native-https-startup-failure.ts b/src/backends/native-https-startup-failure.ts index 2ba3ec55e..a48edfe0c 100644 --- a/src/backends/native-https-startup-failure.ts +++ b/src/backends/native-https-startup-failure.ts @@ -19,6 +19,9 @@ const STAGES = [ "authority-observation", "authority-start", "authority-ready", + "authority-identity", + "permission-port", + "caddy-configuration", "caddy-start", "caddy-ready", "listener-verification", @@ -37,6 +40,8 @@ const NATIVE_CODES = new Set([ "recovery_required", "host_endpoint_identity", "engine_protocol", + "hostname_authority", + "authority_ownership", ]); interface Diagnostic { readonly stage: Stage; diff --git a/src/backends/native-project-https.ts b/src/backends/native-project-https.ts index 6703f7c5d..cc9dade48 100644 --- a/src/backends/native-project-https.ts +++ b/src/backends/native-project-https.ts @@ -31,7 +31,9 @@ import { checkNativeHttpsPort } from "./native-https-port.ts"; import { NativeHttpsStartupError } from "./native-https-startup-failure.ts"; import { invokeNativeRuntime, + NativeRuntimeRequestError, type NativeRuntimeSelection, + readNativeFailureCode, } from "./native-runtime-client.ts"; const PROBE_PATH_BYTES = /^[\x21-\x7e]+$/; @@ -55,6 +57,8 @@ function isValidPort(value: unknown): value is number { export interface HttpsChild { readonly pid: number; readonly exited: Promise; + /** Only structured native codes; arbitrary child output never leaves the spawn boundary. */ + readonly failure?: Promise; kill(signal: "SIGTERM" | "SIGKILL"): void; endInput(): void; } @@ -62,6 +66,7 @@ interface SpawnInput { readonly argv: readonly string[]; readonly env: Record; readonly pipe: boolean; + readonly nativeDiagnostics?: boolean; } interface Dependencies { readonly checkPort: typeof checkNativeHttpsPort; @@ -168,8 +173,15 @@ export function spawnNativeHttpsChild(input: SpawnInput): HttpsChild { env: input.env, stdin: "ignore", stdout: "ignore", - stderr: "ignore", + stderr: input.nativeDiagnostics ? "pipe" : "ignore", }); + const failure = + input.nativeDiagnostics && child.stderr + ? captureNativeHttpsChildFailure({ + exited: child.exited, + stderr: child.stderr, + }) + : undefined; // Open only after spawn: native children must never inherit the owner writer. if (directory) { writer = openSync(join(directory, "stdin"), constants.O_WRONLY); @@ -181,6 +193,7 @@ export function spawnNativeHttpsChild(input: SpawnInput): HttpsChild { return { pid: child.pid, exited: child.exited.finally(cleanup), + ...(failure ? { failure } : {}), kill: (signal) => { if (child.exitCode === null) { child.kill(signal); @@ -193,6 +206,28 @@ export function spawnNativeHttpsChild(input: SpawnInput): HttpsChild { throw error; } } +/** Drain only bounded native error codes; inherited stderr cannot extend child-exit classification. */ +export async function captureNativeHttpsChildFailure(opts: { + readonly exited: Promise; + readonly stderr: ReadableStream; +}): Promise { + const abort = new AbortController(); + const nativeCode = readNativeFailureCode(opts.stderr, abort.signal); + const code = await opts.exited; + const timer = setTimeout(() => abort.abort(), 100); + try { + const native = await nativeCode; + return code === 0 + ? undefined + : new NativeRuntimeRequestError({ + message: + "Native HTTPS authority exited before readiness; values omitted.", + nativeCode: native, + }); + } finally { + clearTimeout(timer); + } +} async function permissionPort(): Promise { const server = createServer(); return await new Promise((resolve, reject) => { @@ -1221,7 +1256,8 @@ export async function verifyNativeHttpsHostname( async function waitReady( deadline: number, dead: () => boolean, - check: () => Promise + check: () => Promise, + failure?: () => Promise | undefined ): Promise { while (Date.now() < deadline && !dead()) { try { @@ -1234,7 +1270,7 @@ async function waitReady( } await Bun.sleep(50); } - throw refused(); + throw (dead() && (await failure?.())) || refused(); } /** Owns only newly spawned children; preserves CA data and never installs host trust. */ export async function startNativeProjectHttps(opts: { @@ -1411,27 +1447,32 @@ export async function startNativeProjectHttps(opts: { ], env, pipe: true, + nativeDiagnostics: true, }); let dead = false; - void authority.exited.then(() => { + const startedAuthority = authority; + void startedAuthority.exited.then(() => { dead = true; }); const deadline = Date.now() + 10_000; - const startedAuthority = authority; startupStage = "authority-ready"; ownedAuthority = await waitReady( deadline, () => dead, - async () => authorityIdentity(await inspect(), startedAuthority.pid) + async () => authorityIdentity(await inspect(), startedAuthority.pid), + () => startedAuthority.failure ); + startupStage = "authority-identity"; const socket = ownedAuthority.socket; if (socket !== before.socket) { throw refused(); } + startupStage = "permission-port"; const permission = await deps.permissionPort(); if (permission === opts.httpsPort) { throw refused(); } + startupStage = "caddy-configuration"; const admin = join(session, "admin.sock"); const filename = join(session, "Caddyfile"); await writeFile( diff --git a/tests/native-project-https.test.ts b/tests/native-project-https.test.ts index ccfc61f91..dd5a519d9 100644 --- a/tests/native-project-https.test.ts +++ b/tests/native-project-https.test.ts @@ -15,7 +15,9 @@ import { import { createServer, type Server } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { NativeHttpsStartupError } from "../src/backends/native-https-startup-failure.ts"; import { + captureNativeHttpsChildFailure, type HttpsChild, inspectActiveNativeHttpsOwner, isNativeHttpsProbePath, @@ -886,3 +888,119 @@ test("initial authority observation retains a safe startup stage without retryin access(join(f.root, "native-https/owner.lock")) ).rejects.toThrow(); }); + +test.each([ + [ + JSON.stringify({ code: "provider_busy", message: "secret-CANARY" }), + 2, + "provider_busy", + ], + [ + JSON.stringify({ code: "authority_ownership", message: "secret-CANARY" }), + 2, + "authority_ownership", + ], + ["secret-CANARY", 2, null], + ["x".repeat(9000), 2, null], + [JSON.stringify({ code: "secret_canary" }), 2, null], + ["", 7, null], + [JSON.stringify({ code: "provider_busy" }), 0, null], +] as const)("authority child output is classified without exposing values (%#)", async (output, code, expected) => { + const child = spawnNativeHttpsChild({ + argv: [ + process.execPath, + "-e", + `process.stderr.write(${JSON.stringify(output)}); process.exit(${code});`, + ], + env: { PATH: "/usr/bin:/bin" }, + pipe: true, + nativeDiagnostics: true, + }); + try { + expect(await child.exited).toBe(code); + const failure = await child.failure; + const classified = new NativeHttpsStartupError("authority-ready", failure); + expect(classified.diagnostic.nativeCode).toBe(expected); + expect(classified.message).not.toContain("CANARY"); + if (code === 0) { + expect(failure).toBeUndefined(); + } + } finally { + child.endInput(); + child.kill("SIGKILL"); + await child.exited; + } +}); + +test("authority child classification bounds an inherited stderr stream after exit", async () => { + let canceled = false; + const stderr = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + '{"code":"provider_busy","message":"secret-CANARY"}' + ) + ); + }, + cancel() { + canceled = true; + }, + }); + const start = Date.now(); + const failure = await captureNativeHttpsChildFailure({ + exited: Promise.resolve(2), + stderr, + }); + expect(Date.now() - start).toBeLessThan(2000); + expect(canceled).toBe(true); + expect(failure?.nativeCode).toBe("provider_busy"); + expect(failure?.message).not.toContain("CANARY"); +}); + +test("authority exits carry the typed failure through readiness without starting Caddy", async () => { + const f = await fixture(); + const spawn = f.opts.dependencies.spawn!; + await expect( + startNativeProjectHttps({ + ...f.opts, + dependencies: { + ...f.opts.dependencies, + spawn: (input) => { + const child = spawn(input); + if (input.pipe) { + expect(input.nativeDiagnostics).toBe(true); + f.children[0]?.finish(2); + return { + ...child, + failure: Bun.sleep(100).then( + () => + new NativeRuntimeRequestError({ + message: "secret-CANARY", + nativeCode: "provider_busy", + }) + ), + }; + } + return child; + }, + }, + }) + ).rejects.toThrow("authority-ready: provider_busy"); + expect(f.children).toHaveLength(1); +}); + +test("permission-port failure is distinct from authority readiness", async () => { + const f = await fixture(); + await expect( + startNativeProjectHttps({ + ...f.opts, + dependencies: { + ...f.opts.dependencies, + permissionPort: async () => { + throw new Error("secret-CANARY"); + }, + }, + }) + ).rejects.toThrow("permission-port"); + expect(f.children).toHaveLength(1); +}); From e3f3d07bb83b8df83a87465d915c11fa7874dabe Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Sun, 4 Oct 2026 21:43:47 -0400 Subject: [PATCH 3/5] test: verify native timeout reaping from parent child identity --- tests/native-runtime-client.test.ts | 100 ++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 27 deletions(-) diff --git a/tests/native-runtime-client.test.ts b/tests/native-runtime-client.test.ts index 6df6a9aaa..9b1541664 100644 --- a/tests/native-runtime-client.test.ts +++ b/tests/native-runtime-client.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; import { chmod, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -129,6 +129,7 @@ test("cancellation starts at most one native request and reaps only its owned ch runtime.binary, `#!${process.execPath} import { appendFileSync, writeFileSync } from "node:fs"; +${mode === "timeout" ? "await Bun.sleep(60_000);" : ""} appendFileSync(${JSON.stringify(marker)}, "attempt\\n"); writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); await Bun.sleep(60_000); @@ -138,33 +139,78 @@ await Bun.sleep(60_000); if (mode === "before") { controller.abort(); } - const attempt = invokeNativeRuntime({ - runtime, - cwd: runtime.home, - args: ["runtime", "up", "--json"], - signal: controller.signal, - timeoutMs: mode === "timeout" ? 250 : 2000, - }); - if (mode === "running") { - const deadline = performance.now() + 1500; - while ( - !(await Bun.file(pidFile).exists()) && - performance.now() < deadline - ) { - await Bun.sleep(10); + // Pass through every real spawn; observe only this unique fixture binary. + const spawn = spyOn(Bun, "spawn"); + let child: ReturnType | undefined; + try { + const attempt = invokeNativeRuntime({ + runtime, + cwd: runtime.home, + args: ["runtime", "up", "--json"], + signal: controller.signal, + timeoutMs: mode === "timeout" ? 250 : 2000, + }); + void attempt.catch(() => undefined); + const ownCalls = () => + spawn.mock.calls.flatMap(([argv], index) => + Array.isArray(argv) && argv[0] === runtime.binary ? [index] : [] + ); + let exited = false; + if (mode !== "before") { + expect(ownCalls()).toHaveLength(1); + const result = spawn.mock.results[ownCalls()[0] ?? -1]; + if (result?.type !== "return") { + throw new Error("fixture spawn did not return its owned child"); + } + child = result.value; + void child.exited.then(() => { + exited = true; + }); + } + if (mode === "running") { + const deadline = performance.now() + 1500; + while ( + !(await Bun.file(pidFile).exists()) && + performance.now() < deadline + ) { + await Bun.sleep(10); + } + expect(await Bun.file(pidFile).exists()).toBe(true); + controller.abort(); + } + await expect(attempt).rejects.toThrow( + mode === "timeout" ? "timed out" : "canceled" + ); + expect(ownCalls()).toHaveLength(mode === "before" ? 0 : 1); + if (mode === "running") { + if (!child) { + throw new Error("running fixture lost its parent-owned child"); + } + expect(await Bun.file(marker).text()).toBe("attempt\n"); + expect(Number(await Bun.file(pidFile).text())).toBe(child.pid); + } else { + expect(await Bun.file(marker).exists()).toBe(false); + expect(await Bun.file(pidFile).exists()).toBe(false); + } + // Timeout deliberately precedes the child marker. Parent identity and exit + // completion must still prove reaping before the request rejects. + if (child) { + expect(exited).toBe(true); + expect(child.signalCode).toBe("SIGKILL"); + let probeFailure: unknown; + try { + process.kill(child.pid, 0); + } catch (error) { + probeFailure = error; + } + expect(probeFailure).toMatchObject({ code: "ESRCH" }); + } + } finally { + spawn.mockRestore(); + if (child?.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await child.exited; } - expect(await Bun.file(pidFile).exists()).toBe(true); - controller.abort(); - } - await expect(attempt).rejects.toThrow( - mode === "timeout" ? "timed out" : "canceled" - ); - if (mode === "before") { - expect(await Bun.file(marker).exists()).toBe(false); - } else { - expect(await Bun.file(marker).text()).toBe("attempt\n"); - const pid = Number(await Bun.file(pidFile).text()); - expect(() => process.kill(pid, 0)).toThrow(); } } }); From 25e5975a5756670ade6a498e3e97c6cbc26d78a3 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Sun, 4 Oct 2026 21:43:47 -0400 Subject: [PATCH 4/5] test: verify native timeout reaping from parent child identity --- tests/native-runtime-client.test.ts | 100 ++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 27 deletions(-) diff --git a/tests/native-runtime-client.test.ts b/tests/native-runtime-client.test.ts index 6df6a9aaa..9b1541664 100644 --- a/tests/native-runtime-client.test.ts +++ b/tests/native-runtime-client.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; import { chmod, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -129,6 +129,7 @@ test("cancellation starts at most one native request and reaps only its owned ch runtime.binary, `#!${process.execPath} import { appendFileSync, writeFileSync } from "node:fs"; +${mode === "timeout" ? "await Bun.sleep(60_000);" : ""} appendFileSync(${JSON.stringify(marker)}, "attempt\\n"); writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); await Bun.sleep(60_000); @@ -138,33 +139,78 @@ await Bun.sleep(60_000); if (mode === "before") { controller.abort(); } - const attempt = invokeNativeRuntime({ - runtime, - cwd: runtime.home, - args: ["runtime", "up", "--json"], - signal: controller.signal, - timeoutMs: mode === "timeout" ? 250 : 2000, - }); - if (mode === "running") { - const deadline = performance.now() + 1500; - while ( - !(await Bun.file(pidFile).exists()) && - performance.now() < deadline - ) { - await Bun.sleep(10); + // Pass through every real spawn; observe only this unique fixture binary. + const spawn = spyOn(Bun, "spawn"); + let child: ReturnType | undefined; + try { + const attempt = invokeNativeRuntime({ + runtime, + cwd: runtime.home, + args: ["runtime", "up", "--json"], + signal: controller.signal, + timeoutMs: mode === "timeout" ? 250 : 2000, + }); + void attempt.catch(() => undefined); + const ownCalls = () => + spawn.mock.calls.flatMap(([argv], index) => + Array.isArray(argv) && argv[0] === runtime.binary ? [index] : [] + ); + let exited = false; + if (mode !== "before") { + expect(ownCalls()).toHaveLength(1); + const result = spawn.mock.results[ownCalls()[0] ?? -1]; + if (result?.type !== "return") { + throw new Error("fixture spawn did not return its owned child"); + } + child = result.value; + void child.exited.then(() => { + exited = true; + }); + } + if (mode === "running") { + const deadline = performance.now() + 1500; + while ( + !(await Bun.file(pidFile).exists()) && + performance.now() < deadline + ) { + await Bun.sleep(10); + } + expect(await Bun.file(pidFile).exists()).toBe(true); + controller.abort(); + } + await expect(attempt).rejects.toThrow( + mode === "timeout" ? "timed out" : "canceled" + ); + expect(ownCalls()).toHaveLength(mode === "before" ? 0 : 1); + if (mode === "running") { + if (!child) { + throw new Error("running fixture lost its parent-owned child"); + } + expect(await Bun.file(marker).text()).toBe("attempt\n"); + expect(Number(await Bun.file(pidFile).text())).toBe(child.pid); + } else { + expect(await Bun.file(marker).exists()).toBe(false); + expect(await Bun.file(pidFile).exists()).toBe(false); + } + // Timeout deliberately precedes the child marker. Parent identity and exit + // completion must still prove reaping before the request rejects. + if (child) { + expect(exited).toBe(true); + expect(child.signalCode).toBe("SIGKILL"); + let probeFailure: unknown; + try { + process.kill(child.pid, 0); + } catch (error) { + probeFailure = error; + } + expect(probeFailure).toMatchObject({ code: "ESRCH" }); + } + } finally { + spawn.mockRestore(); + if (child?.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await child.exited; } - expect(await Bun.file(pidFile).exists()).toBe(true); - controller.abort(); - } - await expect(attempt).rejects.toThrow( - mode === "timeout" ? "timed out" : "canceled" - ); - if (mode === "before") { - expect(await Bun.file(marker).exists()).toBe(false); - } else { - expect(await Bun.file(marker).text()).toBe("attempt\n"); - const pid = Number(await Bun.file(pidFile).text()); - expect(() => process.kill(pid, 0)).toThrow(); } } }); From 15a3227c3b3616ddb274ed8331a9efdbde1d6ace Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Sun, 4 Oct 2026 22:10:32 -0400 Subject: [PATCH 5/5] docs: keep authority admission guidance before startup failure records --- docs/guides/native-candidate.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 3f3ba6472..850381b73 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1392,16 +1392,6 @@ Services without managed values use ordinary exec. The native live qualification of this fresh-delivery path remains separate from its unit and transport tests. -A detached HTTPS helper that fails during startup may retain a generation-bound -`startup-failure.json` beside its owner configuration. The CLI reports only the -reviewed startup stage and an allowlisted native error code; child output, paths -and application values are omitted. If lease cleanup also fails, the original -acquisition diagnostic remains visible alongside the unconfirmed cleanup status. -This record is diagnostic evidence only: it does not acknowledge retirement or -permit a replacement owner. A missing record (including a helper crash before -publication) leaves the cause unknown. Preserve the retained owner and finalization -records for inspection; do not delete them to force another startup. - Managed HTTPS authority startup uses the same five-second provider-lock admission budget as ordinary pool startup. This lets a brief concurrent graph inspection finish before admission. Pool identity, socket ownership and certificate admission @@ -1415,6 +1405,16 @@ readiness; later socket-identity, permission-port and Caddyfile failures have separate stages. These classifications do not acknowledge cleanup or permit an owner to be adopted or replaced. +A detached HTTPS helper that fails during startup may retain a generation-bound +`startup-failure.json` beside its owner configuration. The CLI reports only the +reviewed startup stage and an allowlisted native error code; child output, paths +and application values are omitted. If lease cleanup also fails, the original +acquisition diagnostic remains visible alongside the unconfirmed cleanup status. +This record is diagnostic evidence only: it does not acknowledge retirement or +permit a replacement owner. A missing record (including a helper crash before +publication) leaves the cause unknown. Preserve the retained owner and finalization +records for inspection; do not delete them to force another startup. + When an HTTPS owner refuses an exact lease release, it now attempts a bounded failure response containing only the release stage and a reviewed native error code. The client verifies the complete lease identity and rejects extra or