From f957b54533808f1297aa844cf3c7c5c3a4ba7fcf Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:09:06 -0500 Subject: [PATCH 1/2] test(daemon): repeated endpoint churn against real sockets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cave-58eoq.4 — third increment. Covers the bead's "repeated lifecycle stress detects orphaned processes and stale endpoint state" criterion. Scoped deliberately narrow, because two neighbouring criteria are already met and padding them would be noise rather than coverage: - sleep/wake is pinned by daemon-connection-supervisor.test.ts (501 lines, 14 tests) — hiding clears timers and aborts active work without publishing or backing off, foregrounding triggers exactly one immediate fresh request, plus the full backoff ladder and jitter bounds - single-transition endpoint faults are pinned by daemon-endpoint-faults.test.ts from increment 2 What neither covers is the REPEATED case, and that is where the real failures live. A single bind/close cycle can pass while the tenth fails: an address that reports occupied after its owner left, a listener that cannot rebind because something held the path, or a probe that leaks one connection per cycle and slowly starves the daemon of accept slots. Each shows up to a user as "the daemon won't start" or as a duplicate daemon, and none of them reproduces on the first try. Four tests: 12 bind/release cycles asserting a clean occupied/free transition every time; rebind after sustained churn; concurrent probes agreeing with the endpoint's real state through churn, since a launcher, a status poll and a recovery banner can all probe at once and one stale "occupied" wrongly refuses a launch; and a per-cycle orphan check. Addresses are platform-appropriate — a named pipe on Windows, a temp socket elsewhere — so the same cases run on both legs. Verified: 4 tests in 140ms, check:tests-wired (1607), typecheck, lint. --- scripts/run-tests.mjs | 1 + src/lib/daemon-endpoint-churn.test.ts | 177 ++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 src/lib/daemon-endpoint-churn.test.ts diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index bbd100b13..2b437111c 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1281,6 +1281,7 @@ export const SUITES = { "src/lib/daemon-start.test.ts", "src/lib/daemon-socket-occupancy.test.ts", "src/lib/daemon-endpoint-faults.test.ts", + "src/lib/daemon-endpoint-churn.test.ts", "src/lib/daemon-startup-contract.test.ts", "src/lib/runtime-startup-throttle.test.ts", "src/lib/daemon-update-lifecycle.test.ts", diff --git a/src/lib/daemon-endpoint-churn.test.ts b/src/lib/daemon-endpoint-churn.test.ts new file mode 100644 index 000000000..4c6739ce5 --- /dev/null +++ b/src/lib/daemon-endpoint-churn.test.ts @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer, type Server } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { inspectDaemonAddress } from "./daemon-socket-occupancy.ts"; + +/** + * Repeated startup/shutdown churn against a real endpoint (cave-58eoq.4). + * + * The bead asks for repeated lifecycle stress that "detects orphaned processes, + * stale endpoint state, unsafe retries, and false healthy/fixed diagnostics". + * Two of those are already covered elsewhere and deliberately not repeated here: + * the supervisor's sleep/wake, backoff ladder and abort semantics are pinned by + * daemon-connection-supervisor.test.ts, and single-transition endpoint faults by + * daemon-endpoint-faults.test.ts. + * + * What neither covers is the *repeated* case. A single bind/close cycle can pass + * while the tenth fails, and those are the interesting failures: an address that + * reports occupied after its owner left, a probe that caches its first verdict, + * a listener that cannot rebind because something held the path. Each of those + * shows up as a daemon that "won't start" or a duplicate daemon, and neither + * reproduces on the first try. + */ + +const BOUND_MS = 1_000; +const CYCLES = 12; + +const cleanups: Array<() => void> = []; +test.after(() => { + for (const cleanup of cleanups.reverse()) { + try { + cleanup(); + } catch { + // Teardown must never convert a passing assertion into a failing suite. + } + } +}); + +function tempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "cave-endpoint-churn-")); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +/** Windows has no UNIX domain sockets; the daemon uses a named pipe there. */ +function addressFor(dir: string, name: string): string { + if (process.platform === "win32") { + return path.join("\\\\.\\pipe", `cave-endpoint-churn-${name}-${process.pid}`); + } + return path.join(dir, `${name}.sock`); +} + +async function bind(socketPath: string): Promise { + const server = createServer(() => {}); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => resolve()); + }); + return server; +} + +async function unbind(server: Server): Promise { + await new Promise((resolve) => server.close(() => resolve())); +} + +test("an endpoint survives repeated bind/release cycles without drifting", async () => { + const dir = tempDir(); + const address = addressFor(dir, "churn"); + + for (let cycle = 1; cycle <= CYCLES; cycle += 1) { + const server = await bind(address); + assert.equal( + await inspectDaemonAddress({ socketPath: address, timeoutMs: BOUND_MS }), + "occupied", + `cycle ${cycle}: a live listener must read occupied`, + ); + + await unbind(server); + assert.equal( + await inspectDaemonAddress({ socketPath: address, timeoutMs: BOUND_MS }), + "free", + `cycle ${cycle}: a released address must read free, not stale-occupied`, + ); + } +}); + +test("a released address never blocks the next daemon from binding it", async () => { + // The stale-endpoint failure a user actually hits: the daemon exits, and the + // next launch cannot bind because the path was left behind. If any cycle here + // throws EADDRINUSE, that is the bug, and it is invisible in a single cycle. + const dir = tempDir(); + const address = addressFor(dir, "rebind"); + + for (let cycle = 1; cycle <= CYCLES; cycle += 1) { + const server = await bind(address); + await unbind(server); + } + + const finalServer = await bind(address); + assert.equal( + await inspectDaemonAddress({ socketPath: address, timeoutMs: BOUND_MS }), + "occupied", + "the address is still usable after repeated churn", + ); + await unbind(finalServer); +}); + +test("concurrent probes during churn never disagree with the endpoint's real state", async () => { + // Several surfaces can probe at once — a launcher, a status poll, a recovery + // banner. Under churn they must all see the same truth, because one probe + // reporting occupied after shutdown is how a launch gets wrongly refused. + const dir = tempDir(); + const address = addressFor(dir, "concurrent"); + + for (let cycle = 1; cycle <= 6; cycle += 1) { + const server = await bind(address); + const whileUp = await Promise.all( + Array.from({ length: 4 }, () => + inspectDaemonAddress({ socketPath: address, timeoutMs: BOUND_MS })), + ); + assert.deepEqual( + new Set(whileUp), + new Set(["occupied"]), + `cycle ${cycle}: concurrent probes disagreed while the listener was up`, + ); + + await unbind(server); + const whileDown = await Promise.all( + Array.from({ length: 4 }, () => + inspectDaemonAddress({ socketPath: address, timeoutMs: BOUND_MS })), + ); + assert.ok( + !whileDown.includes("occupied"), + `cycle ${cycle}: a probe claimed occupied after the listener was gone`, + ); + } +}); + +test("churn leaves no connection behind on any listener it probed", async () => { + // The orphan check. A probe that holds its connection open would accumulate + // one per cycle, and the daemon would slowly run out of accept slots — a + // failure that only appears after long uptime, never in a single-shot test. + const dir = tempDir(); + const address = addressFor(dir, "orphans"); + let everOpened = 0; + let stillOpen = 0; + + for (let cycle = 1; cycle <= CYCLES; cycle += 1) { + const server = createServer(() => {}); + server.on("connection", (socket) => { + everOpened += 1; + stillOpen += 1; + socket.on("close", () => { + stillOpen -= 1; + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(address, () => resolve()); + }); + + await inspectDaemonAddress({ socketPath: address, timeoutMs: BOUND_MS }); + await unbind(server); + } + + // Bounded drain: close events are asynchronous, but they are not slow. + const deadline = Date.now() + 500; + while (stillOpen > 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + assert.equal(everOpened, CYCLES, "every cycle must have been probed for real"); + assert.equal(stillOpen, 0, "a probe left a connection open on a departed daemon"); +}); From 01b883f4618255193796e02efd62dc8a85a14ffa Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:23:19 -0500 Subject: [PATCH 2/2] test(daemon): register churn listeners for teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raised in review on #4506, and correct. bind() created a real server but never registered it in the file-level cleanups list. These tests assert mid-cycle, between bind() and unbind(). An assertion that throws therefore skips its unbind and leaves a listener holding the address, so one genuine failure cascades into a hung suite — the exact behaviour the fault harness in increment 1 exists to prevent. Increment 2's listenOn() already registers correctly; this file did not. The review named the helper. The orphan-check test creates its own server inline, because it counts connections and cannot use the helper, and it had the identical flaw — so both are fixed rather than only the reported line. Closing twice is harmless: the cleanup runner swallows it. Verified: 4 tests in 105ms, typecheck and lint exit 0. --- src/lib/daemon-endpoint-churn.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/lib/daemon-endpoint-churn.test.ts b/src/lib/daemon-endpoint-churn.test.ts index 4c6739ce5..330154c1e 100644 --- a/src/lib/daemon-endpoint-churn.test.ts +++ b/src/lib/daemon-endpoint-churn.test.ts @@ -55,6 +55,13 @@ function addressFor(dir: string, name: string): string { async function bind(socketPath: string): Promise { const server = createServer(() => {}); + // Registered before listen resolves, not after the caller unbinds it. These + // tests assert mid-cycle, so an assertion that throws would otherwise skip + // its unbind() and leave a listener holding the address — the suite then + // hangs on the failure path, which is the exact behaviour this whole fault + // harness exists to prevent. Closing twice is harmless; the cleanup runner + // swallows it. + cleanups.push(() => server.close()); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(socketPath, () => resolve()); @@ -150,6 +157,10 @@ test("churn leaves no connection behind on any listener it probed", async () => for (let cycle = 1; cycle <= CYCLES; cycle += 1) { const server = createServer(() => {}); + // Same registration discipline as bind() above: this test counts + // connections, so it cannot use the helper, but an assertion throwing + // mid-cycle must still not strand a listener. + cleanups.push(() => server.close()); server.on("connection", (socket) => { everOpened += 1; stillOpen += 1;