From ab60e2120c4204fef028fa1b9d79e08852826c40 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 13 Sep 2026 14:27:07 +0200 Subject: [PATCH 1/4] =?UTF-8?q?test(f-harness):=20the=20controllable=20tes?= =?UTF-8?q?t=20clock=20=E2=80=94=20FakeClock=20+=20Lease=20(#1049)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC2: deterministic advance with deadline-ordered firing (same-deadline in registration order), chained timers due inside the same advance, negative advance refused, pending() next-due probe. Lease expires exactly at issue+ttl on the injected clock, renews from the renewal instant, onExpiry fires through the clock — zero wall-clock waiting (spec-20260913-114814 §8). --- .../fleet_fault_harness/test_clock.ts | 133 +++++++++++++++ .../extension/test/fleet_fault_clock.test.ts | 154 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 packages/extension/test/fixtures/fleet_fault_harness/test_clock.ts create mode 100644 packages/extension/test/fleet_fault_clock.test.ts diff --git a/packages/extension/test/fixtures/fleet_fault_harness/test_clock.ts b/packages/extension/test/fixtures/fleet_fault_harness/test_clock.ts new file mode 100644 index 000000000..45a94ae3d --- /dev/null +++ b/packages/extension/test/fixtures/fleet_fault_harness/test_clock.ts @@ -0,0 +1,133 @@ +// ============================================================================ +// #1049 F-harness — the controllable test clock (spec-20260913-114814 §8). +// +// The harness's injectable time source: `now()` is a fake monotone instant, +// `advance(ms)` fires due scheduled callbacks in deadline order (same-deadline +// in registration order) — never a real timer, never a real wait. Time only +// moves through `advance`; a callback scheduled during an advance fires inside +// the same advance if its deadline is still due. Negative advance is refused: +// time never runs backward. +// +// `Lease` is the representative lease/scheduling consumer (the F5 checkout-race +// fixtures use it): held → expired at exactly `issue + ttl` on the injected +// clock, renew re-holds from the renewal instant, `onExpiry` fires through the +// clock at the exact expiry instant — zero wall-clock waiting anywhere. +// ============================================================================ + +export type ClockTimerHandle = { readonly id: number }; + +export type ClockTimerCallback = () => void; + +export class FakeClock { + private t: number; + private nextId = 0; + // Deadline-ordered on demand; insertion order preserved for same-deadline ties. + private timers: { id: number; deadline: number; cb: ClockTimerCallback }[] = []; + + constructor(epoch = 0) { + this.t = epoch; + } + + now(): number { + return this.t; + } + + setTimeout(cb: ClockTimerCallback, delayMs: number): ClockTimerHandle { + if (!Number.isFinite(delayMs) || delayMs < 0) { + throw new Error(`FakeClock.setTimeout: delayMs must be a non-negative finite number, got ${delayMs}`); + } + const id = this.nextId++; + this.timers.push({ id, deadline: this.t + delayMs, cb }); + return { id }; + } + + clearTimeout(handle: ClockTimerHandle): void { + this.timers = this.timers.filter((tm) => tm.id !== handle.id); + } + + /** The earliest pending deadline (absolute clock time), or null if idle. */ + pending(): number | null { + if (this.timers.length === 0) return null; + return this.timers.reduce((min, tm) => (tm.deadline < min ? tm.deadline : min), this.timers[0].deadline); + } + + /** + * Move the clock forward by `ms`, firing every timer whose deadline falls + * within (now, now+ms] in deadline order; ties fire in registration order. + * A callback scheduled mid-advance participates in the same advance if its + * deadline is still due. Each callback observes now() == its deadline. + */ + advance(ms: number): void { + if (!Number.isFinite(ms) || ms < 0) { + throw new Error(`FakeClock.advance: time never runs backward (got ${ms})`); + } + const target = this.t + ms; + for (;;) { + let due: (typeof this.timers)[number] | undefined; + for (const tm of this.timers) { + if (tm.deadline <= target && (due === undefined || tm.deadline < due.deadline)) { + due = tm; + } + } + if (due === undefined) break; + this.t = due.deadline; + // Fire ALL timers at this exact deadline, in registration order. + const batch = this.timers.filter((tm) => tm.deadline === due!.deadline); + this.timers = this.timers.filter((tm) => tm.deadline !== due!.deadline); + for (const tm of batch) tm.cb(); + } + this.t = target; + } +} + +export type LeaseState = "held" | "expired"; + +export type LeaseOptions = { + clock: FakeClock; + holder: string; + ttlMs: number; +}; + +/** + * A lease driven entirely by the injected clock: expiry is exact (now >= + * issuedAt + ttl), renewal extends from the renewal instant, and `onExpiry` + * callbacks fire through the clock at the exact expiry instant. + */ +export class Lease { + readonly holder: string; + private readonly clock: FakeClock; + private readonly ttlMs: number; + private expiryAt: number; + private expiryCallbacks: ClockTimerCallback[] = []; + + constructor(opts: LeaseOptions) { + this.clock = opts.clock; + this.holder = opts.holder; + this.ttlMs = opts.ttlMs; + this.expiryAt = this.clock.now() + this.ttlMs; + } + + state(): LeaseState { + return this.clock.now() >= this.expiryAt ? "expired" : "held"; + } + + remainingMs(): number { + return Math.max(0, this.expiryAt - this.clock.now()); + } + + /** Re-hold: a fresh TTL counted from the renewal instant (works from expired too). */ + renew(): void { + this.expiryAt = this.clock.now() + this.ttlMs; + } + + /** Fire `cb` through the clock at the exact expiry instant. */ + onExpiry(cb: ClockTimerCallback): void { + this.expiryCallbacks.push(cb); + const delay = this.expiryAt - this.clock.now(); + if (delay <= 0) { + cb(); + return; + } + this.clock.setTimeout(() => cb(), delay); + } +} diff --git a/packages/extension/test/fleet_fault_clock.test.ts b/packages/extension/test/fleet_fault_clock.test.ts new file mode 100644 index 000000000..e0076113e --- /dev/null +++ b/packages/extension/test/fleet_fault_clock.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from "vitest"; +import { FakeClock, Lease } from "./fixtures/fleet_fault_harness/test_clock"; + +// ============================================================================ +// #1049 F-harness — the controllable test clock (spec-20260913-114814 §8, +// F-harness deliverable; AC2: "The test clock advances time deterministically; +// a lease-expiry scenario can be driven to expiry and back with zero +// wall-clock waiting"). +// +// The clock is the harness's injectable time source: `now()` is a fake +// monotone instant, `advance(ms)` fires due scheduled callbacks in +// deadline order (same-deadline in registration order) — never a real +// timer, never a real wait. The Lease is the representative lease/scheduling +// consumer (the F5 checkout-race fixtures consume it) driven entirely by the +// injected clock. +// ============================================================================ + +describe("FakeClock — deterministic time source", () => { + it("starts at 0 (or a given epoch) and now() only moves by advance()", () => { + const c = new FakeClock(); + expect(c.now()).toBe(0); + c.advance(5000); + expect(c.now()).toBe(5000); + const epoch = new FakeClock(10_000); + expect(epoch.now()).toBe(10_000); + }); + + it("fires a scheduled timer when advanced past its deadline", () => { + const c = new FakeClock(); + const fired: number[] = []; + c.setTimeout(() => fired.push(c.now()), 100); + c.advance(99); + expect(fired).toEqual([]); + c.advance(1); + expect(fired).toEqual([100]); // callback sees now() == its deadline + }); + + it("fires due timers in DEADLINE order regardless of registration order", () => { + const c = new FakeClock(); + const order: string[] = []; + c.setTimeout(() => order.push("late-registered-early-deadline"), 10); + c.setTimeout(() => order.push("early-registered-late-deadline"), 20); + c.advance(100); + expect(order).toEqual(["late-registered-early-deadline", "early-registered-late-deadline"]); + }); + + it("fires same-deadline timers in REGISTRATION order", () => { + const c = new FakeClock(); + const order: string[] = []; + c.setTimeout(() => order.push("a"), 50); + c.setTimeout(() => order.push("b"), 50); + c.setTimeout(() => order.push("c"), 50); + c.advance(50); + expect(order).toEqual(["a", "b", "c"]); + }); + + it("a timer scheduled DURING advance fires inside the same advance if still due", () => { + const c = new FakeClock(); + const order: string[] = []; + c.setTimeout(() => { + order.push("one"); + c.setTimeout(() => order.push("chained@+5"), 5); + }, 10); + c.setTimeout(() => order.push("two@20"), 20); + c.advance(30); + // chained (deadline 15) fires before the 20-deadline timer, inside one advance + expect(order).toEqual(["one", "chained@+5", "two@20"]); + }); + + it("clearTimeout prevents the callback from ever firing", () => { + const c = new FakeClock(); + let fired = 0; + const h = c.setTimeout(() => fired++, 10); + c.clearTimeout(h); + c.advance(1000); + expect(fired).toBe(0); + }); + + it("advance(0) fires exactly-due timers and nothing else", () => { + const c = new FakeClock(); + const fired: number[] = []; + c.setTimeout(() => fired.push(10), 10); + c.advance(10); + c.advance(0); + c.setTimeout(() => fired.push(15), 5); + c.advance(0); + expect(fired).toEqual([10]); + }); + + it("negative advance is refused loudly (determinism: time never runs backward)", () => { + const c = new FakeClock(); + expect(() => c.advance(-1)).toThrow(); + }); + + it("pending(deadline) reports the next due time (for probe timeouts)", () => { + const c = new FakeClock(); + c.setTimeout(() => {}, 100); + c.setTimeout(() => {}, 40); + expect(c.pending()).toBe(40); + }); +}); + +describe("Lease — clock-driven expiry, zero wall-clock waiting (AC2)", () => { + it("drives a lease to expiry and back with zero wall-clock waiting", () => { + const realStart = Date.now(); // proof-of-no-wait, not synchronization + const c = new FakeClock(); + const lease = new Lease({ clock: c, holder: "macbook", ttlMs: 30_000 }); + + expect(lease.state()).toBe("held"); + expect(lease.remainingMs()).toBe(30_000); + + c.advance(29_999); + expect(lease.state()).toBe("held"); // one ms before expiry — still held + + c.advance(1); + expect(lease.state()).toBe("expired"); // exactly at TTL — expired + expect(lease.remainingMs()).toBe(0); + + // "and back": renew from an expired lease re-holds it for a fresh TTL + c.advance(120_000); + lease.renew(); + expect(lease.state()).toBe("held"); + expect(lease.remainingMs()).toBe(30_000); + + c.advance(30_000); + expect(lease.state()).toBe("expired"); + + // a renewal mid-life extends from the RENEWAL instant, not the issue instant + const second = new Lease({ clock: c, holder: "mini", ttlMs: 10_000 }); + c.advance(5000); + second.renew(); + expect(second.remainingMs()).toBe(10_000); + c.advance(9999); + expect(second.state()).toBe("held"); + c.advance(1); + expect(second.state()).toBe("expired"); + + // AC2's "zero wall-clock waiting": the whole lifecycle consumed no real time + expect(Date.now() - realStart).toBeLessThan(1000); + }); + + it("expires via a clock SCHEDULED callback (the unattended-loop shape: no polling)", () => { + const c = new FakeClock(); + const events: string[] = []; + const lease = new Lease({ clock: c, holder: "erlich", ttlMs: 1000 }); + lease.onExpiry(() => events.push(`expired@${c.now()}`)); + + c.advance(999); + expect(events).toEqual([]); + c.advance(1); + expect(events).toEqual(["expired@1000"]); // fired at the exact expiry instant + expect(lease.state()).toBe("expired"); + }); +}); From 8625b65f08daa9f0dc0db09cfd0f319713dff796 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 13 Sep 2026 14:29:55 +0200 Subject: [PATCH 2/4] =?UTF-8?q?test(f-harness):=20the=20scriptable=20tunne?= =?UTF-8?q?l-fault=20proxy=20=E2=80=94=205=20distinct=20fault=20modes=20(#?= =?UTF-8?q?1049)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC1+AC4: DROP black-holes (client timeout, zero handshake bytes), HALF-OPEN delivers the accepted greeting then silence (distinguishable from DROP by those bytes alone), REFUSE closes the listener (true ECONNREFUSED, same port rebound on exit), CLEAN-CLOSE is an orderly zero-payload FIN, PASS proxies. Schedules are {mode, probes} plans run with every transition awaited — sustained no-response windows and healthy-then-dead sequences reproduce from the plan; a signature-uniqueness test guards mode collisions (spec §8). node:net/node:timers only; teardown waits for every socket (H4). --- .../fleet_fault_harness/fault_proxy.ts | 288 ++++++++++++++++++ .../extension/test/fleet_fault_proxy.test.ts | 197 ++++++++++++ 2 files changed, 485 insertions(+) create mode 100644 packages/extension/test/fixtures/fleet_fault_harness/fault_proxy.ts create mode 100644 packages/extension/test/fleet_fault_proxy.test.ts diff --git a/packages/extension/test/fixtures/fleet_fault_harness/fault_proxy.ts b/packages/extension/test/fixtures/fleet_fault_harness/fault_proxy.ts new file mode 100644 index 000000000..cb964f8af --- /dev/null +++ b/packages/extension/test/fixtures/fleet_fault_harness/fault_proxy.ts @@ -0,0 +1,288 @@ +// ============================================================================ +// #1049 F-harness — the scriptable tunnel-fault proxy (spec-20260913-114814 §8). +// +// A loopback-only TCP proxy between a test client and a real backend, injecting +// one of five OBSERVABLY DISTINCT fault modes: +// +// PASS → normal bidirectional proxying +// DROP → accepted socket black-holed: no bytes ever flow, no EOF, +// no error — the client's only outcome is a timeout +// HALF-OPEN → the tunnel handshake is ACCEPTED (a greeting banner is +// delivered) and then silence — distinguishable from DROP by +// those handshake bytes alone +// CLEAN-CLOSE → an orderly FIN: prompt EOF, zero payload, no error +// REFUSE → the listener itself is closed for the window: the client +// gets ECONNREFUSED (active reject, not a reset) +// +// Scheduling is a plan of {mode, probes} steps executed by runSchedule with +// every transition awaited — the same plan reproduces the same outcome +// sequence, there are no sleeps used as synchronization, and teardown waits +// for every socket to be gone (the H4 lesson). node:net / node:timers only. +// ============================================================================ + +import * as net from "node:net"; +import { once } from "node:events"; + +export type FaultMode = "pass" | "drop" | "half-open" | "clean-close" | "refuse"; + +/** The banner HALF-OPEN delivers before going silent — the accepted handshake. */ +export const HALF_OPEN_GREETING = "AMICO-TUNNEL-READY\n"; + +export const PROBE_REQUEST = "PING"; + +export type Outcome = + | { kind: "response"; body: string } + | { kind: "timeout"; handshakeBytes: number } + | { kind: "refused"; code: string } + | { kind: "reset"; code: string } + | { kind: "clean-close" }; + +export type ScheduleStep = { mode: FaultMode; probes: number }; + +// --------------------------------------------------------------------------- +// The backend the proxy fronts: a trivial echo server (loopback, in-process). +// --------------------------------------------------------------------------- + +export type EchoBackend = { + port: number; + close: () => Promise; +}; + +export function startEchoBackend(): Promise { + return new Promise((resolve, reject) => { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + socket.on("data", (chunk: Buffer) => { + socket.write("ECHO:" + chunk.toString("utf8")); + }); + }); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as net.AddressInfo).port; + resolve({ + port, + close: async () => { + for (const s of sockets) s.destroy(); + sockets.clear(); + await closeServerFully(server); + }, + }); + }); + }); +} + +// --------------------------------------------------------------------------- +// The proxy. +// --------------------------------------------------------------------------- + +export class FaultProxy { + private server: net.Server | null = null; + private targetPort: number; + private listening = false; + private mode: FaultMode = "pass"; + private portValue = 0; + private readonly sockets = new Set(); + + readonly stats = { + acceptedConnections: 0, + forwardedBytes: 0, + deliveredBytes: 0, + }; + + constructor(opts: { targetPort: number }) { + this.targetPort = opts.targetPort; + } + + get port(): number { + return this.portValue; + } + + async listen(): Promise { + this.server = net.createServer((socket) => this.onConnection(socket)); + await new Promise((resolve, reject) => { + this.server!.once("error", reject); + this.server!.listen(0, "127.0.0.1", resolve); + }); + this.portValue = (this.server.address() as net.AddressInfo).port; + this.listening = true; + } + + /** + * Switch the injected fault mode. Transitions happen between probes (the + * schedule runner awaits every probe); entering REFUSE closes the listener — + * that is WHAT ECONNREFUSED is — and leaving it rebinds the SAME port. + */ + async setMode(mode: FaultMode): Promise { + if (mode === this.mode) return; + if (mode === "refuse") { + if (this.listening) { + this.destroyTracked(); + const server = this.server!; + server.close(); + await once(server, "close"); + this.listening = false; + } + this.mode = "refuse"; + return; + } + if (this.mode === "refuse") { + const server = this.server!; + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(this.portValue, "127.0.0.1", resolve); + }); + this.listening = true; + } + this.mode = mode; + } + + get currentMode(): FaultMode { + return this.mode; + } + + /** Teardown waits for every socket and the listener to be gone (H4). */ + async close(): Promise { + if (!this.server) return; + this.destroyTracked(); + const server = this.server; + this.server = null; + this.listening = false; + await closeServerFully(server); + } + + private destroyTracked(): void { + for (const s of this.sockets) s.destroy(); + this.sockets.clear(); + } + + private track(socket: net.Socket): void { + this.sockets.add(socket); + socket.on("close", () => this.sockets.delete(socket)); + } + + private onConnection(client: net.Socket): void { + this.stats.acceptedConnections += 1; + this.track(client); + switch (this.mode) { + case "pass": + return this.proxyThrough(client); + case "drop": + // Accept and black-hole: never read, never write, never close. + client.pause(); + return; + case "half-open": + // Deliver the accepted handshake, then silence forever. + client.write(HALF_OPEN_GREETING); + client.pause(); + return; + case "clean-close": + client.end(); + return; + case "refuse": + // Unreachable through the listener (it is closed); destroy defensively. + client.destroy(); + return; + } + } + + private proxyThrough(client: net.Socket): void { + const upstream = net.connect(this.targetPort, "127.0.0.1"); + this.track(upstream); + client.on("data", (chunk: Buffer) => { + this.stats.forwardedBytes += chunk.length; + upstream.write(chunk); + }); + upstream.on("data", (chunk: Buffer) => { + this.stats.deliveredBytes += chunk.length; + client.write(chunk); + }); + const tear = () => { + client.destroy(); + upstream.destroy(); + }; + client.on("error", tear); + upstream.on("error", tear); + client.on("close", () => upstream.destroy()); + upstream.on("close", () => client.end()); + } +} + +function closeServerFully(server: net.Server): Promise { + return new Promise((resolve, reject) => { + if (!server.listening) { + resolve(); + return; + } + server.close((err) => (err && err.code !== "ERR_SERVER_NOT_RUNNING" ? reject(err) : resolve())); + }); +} + +// --------------------------------------------------------------------------- +// The harness client: one probe through the proxy, classified by outcome. +// --------------------------------------------------------------------------- + +/** + * Connect to the proxy, send PROBE_REQUEST, and classify what happens within + * `deadlineMs`. The deadline is the timeout MECHANISM under test (DROP and + * HALF-OPEN are defined by never answering) — every other mode resolves as + * soon as its event fires, well inside the deadline. + */ +export function probeOnce(port: number, deadlineMs = 150): Promise { + return new Promise((resolve) => { + let settled = false; + let received = ""; + let hadHandshake = 0; + + const socket = net.connect(port, "127.0.0.1"); + const timer = setTimeout(() => settle({ kind: "timeout", handshakeBytes: hadHandshake }), deadlineMs); + + function settle(out: Outcome): void { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.removeAllListeners(); + socket.destroy(); + resolve(out); + } + + socket.on("connect", () => { + socket.write(PROBE_REQUEST); + }); + socket.on("data", (chunk: Buffer) => { + received += chunk.toString("utf8"); + if (received.includes("ECHO:")) { + settle({ kind: "response", body: received }); + return; + } + hadHandshake = received.length; + }); + socket.on("end", () => settle({ kind: "clean-close" })); + socket.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "ECONNREFUSED") settle({ kind: "refused", code: "ECONNREFUSED" }); + else settle({ kind: "reset", code: err.code ?? "UNKNOWN" }); + }); + }); +} + +/** + * Execute a fault plan: for each step, flip the mode (awaited — including the + * listener close/reopen that REFUSE is) and run that many consumer probes. + * Consecutive same-mode steps ARE the sustained window; the returned outcome + * log is what the consumer observed, in order, reproducibly from the plan. + */ +export async function runSchedule( + proxy: FaultProxy, + plan: ScheduleStep[], + deadlineMs = 150, +): Promise { + const outcomes: Outcome[] = []; + for (const step of plan) { + await proxy.setMode(step.mode); + for (let i = 0; i < step.probes; i++) { + outcomes.push(await probeOnce(proxy.port, deadlineMs)); + } + } + return outcomes; +} diff --git a/packages/extension/test/fleet_fault_proxy.test.ts b/packages/extension/test/fleet_fault_proxy.test.ts new file mode 100644 index 000000000..76520a122 --- /dev/null +++ b/packages/extension/test/fleet_fault_proxy.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { + FaultProxy, + probeOnce, + runSchedule, + startEchoBackend, + HALF_OPEN_GREETING, + type Outcome, +} from "./fixtures/fleet_fault_harness/fault_proxy"; + +// ============================================================================ +// #1049 F-harness — the scriptable tunnel-fault proxy (spec-20260913-114814 §8, +// AC1 + AC4). Each fault mode must manifest as the CORRECT transport outcome — +// timeout vs reset vs refused vs orderly close vs response — not "an error": +// +// DROP → client timeout: accepted socket black-holed, zero bytes +// ever written, no EOF, no error (handshakeBytes === 0) +// HALF-OPEN → accepted handshake then silence: the greeting banner arrives, +// then the response never does and the socket never closes +// (handshakeBytes > 0 — THE bit that distinguishes it from DROP) +// REFUSE → ECONNREFUSED: active reject at the listen level +// CLEAN-CLOSE → orderly FIN: prompt EOF, zero payload, no error +// PASS → normal proxying to the backend echo +// +// The schedule is a plan of {mode, probes} steps executed with every +// transition awaited — no sleeps as synchronization, no racing processes; +// the same plan reproduces the same outcome sequence. Sustained no-response +// windows (AC4) are consecutive same-mode probes observed by the consumer. +// ============================================================================ + +const PROBE_DEADLINE_MS = 150; + +const disposers: (() => Promise | void)[] = []; +afterEach(async () => { + while (disposers.length) { + const d = disposers.pop(); + await d?.(); + } +}); + +async function startProxy(mode = "pass") { + const backend = await startEchoBackend(); + const proxy = new FaultProxy({ targetPort: backend.port }); + await proxy.listen(); + await proxy.setMode(mode as never); + disposers.push(async () => { + await proxy.close(); + await backend.close(); + }); + return { backend, proxy }; +} + +describe("FaultProxy — PASS mode proxies to the real backend", () => { + it("delivers the echo response end to end (the PASS signature)", async () => { + const { proxy } = await startProxy("pass"); + const out = await probeOnce(proxy.port, PROBE_DEADLINE_MS); + expect(out).toEqual({ kind: "response", body: "ECHO:PING" }); + expect(proxy.stats.forwardedBytes).toBeGreaterThan(0); + expect(proxy.stats.deliveredBytes).toBeGreaterThan(0); + }); +}); + +describe("FaultProxy — REFUSE is an active reject (ECONNREFUSED), not an error-ish reset", () => { + it("client gets ECONNREFUSED while the refuse window is open", async () => { + const { proxy } = await startProxy("pass"); + await proxy.setMode("refuse"); + const out = await probeOnce(proxy.port, PROBE_DEADLINE_MS); + expect(out).toEqual({ kind: "refused", code: "ECONNREFUSED" }); + }); + + it("exiting the refuse window reopens the SAME port and proxying resumes", async () => { + const { proxy } = await startProxy("refuse"); + const portBefore = proxy.port; + const refused = await probeOnce(proxy.port, PROBE_DEADLINE_MS); + expect(refused).toEqual({ kind: "refused", code: "ECONNREFUSED" }); + await proxy.setMode("pass"); + expect(proxy.port).toBe(portBefore); + const out = await probeOnce(proxy.port, PROBE_DEADLINE_MS); + expect(out.kind).toBe("response"); + }); +}); + +describe("FaultProxy — DROP black-holes the socket (client timeout, no handshake bytes)", () => { + it("response never arrives, socket never closes, no error fires", async () => { + const { proxy } = await startProxy("drop"); + const out = await probeOnce(proxy.port, PROBE_DEADLINE_MS); + expect(out).toEqual({ kind: "timeout", handshakeBytes: 0 }); + // the deterministic core: the proxy accepted but forwarded NOTHING + expect(proxy.stats.acceptedConnections).toBe(1); + expect(proxy.stats.forwardedBytes).toBe(0); + expect(proxy.stats.deliveredBytes).toBe(0); + }); +}); + +describe("FaultProxy — HALF-OPEN accepts the handshake then goes silent", () => { + it("greeting arrives, then the response never does (hang, not EOF)", async () => { + const { proxy } = await startProxy("half-open"); + const out = await probeOnce(proxy.port, PROBE_DEADLINE_MS); + expect(out).toEqual({ kind: "timeout", handshakeBytes: HALF_OPEN_GREETING.length }); + expect(proxy.stats.acceptedConnections).toBe(1); + expect(proxy.stats.forwardedBytes).toBe(0); // request consumed by the fault, never proxied + }); + + it("is distinguishable from DROP by the accepted handshake alone", async () => { + const { proxy } = await startProxy("drop"); + const dropped = await probeOnce(proxy.port, PROBE_DEADLINE_MS); + await proxy.setMode("half-open"); + const half = await probeOnce(proxy.port, PROBE_DEADLINE_MS); + expect(dropped.kind).toBe("timeout"); + expect(half.kind).toBe("timeout"); + expect(dropped.handshakeBytes).toBe(0); + expect(half.handshakeBytes).toBeGreaterThan(0); + }); +}); + +describe("FaultProxy — CLEAN-CLOSE is an orderly EOF with zero payload", () => { + it("prompt EOF, no data, no error (distinct from DROP's hang and REFUSE's error)", async () => { + const { proxy } = await startProxy("clean-close"); + const out = await probeOnce(proxy.port, PROBE_DEADLINE_MS); + expect(out).toEqual({ kind: "clean-close" }); + }); +}); + +describe("FaultProxy — the five fault modes are pairwise distinguishable", () => { + it("no two modes share the same observable signature (a collision is a harness bug)", async () => { + const { proxy } = await startProxy("pass"); + const modes = ["pass", "drop", "half-open", "clean-close", "refuse"] as const; + const signatures: string[] = []; + for (const mode of modes) { + await proxy.setMode(mode); + const out = await probeOnce(proxy.port, PROBE_DEADLINE_MS); + signatures.push(signatureOf(out)); + } + expect(new Set(signatures).size).toBe(modes.length); + }); +}); + +describe("FaultProxy — scripted schedules (deterministic, consumer-observed)", () => { + it("AC4: a sustained no-response window of N consecutive outcomes ends when the mode flips", async () => { + const { proxy } = await startProxy("pass"); + const outcomes = await runSchedule(proxy, [ + { mode: "drop", probes: 3 }, + { mode: "pass", probes: 1 }, + ]); + expect(outcomes).toHaveLength(4); + expect(outcomes.slice(0, 3).every((o) => o.kind === "timeout")).toBe(true); + expect(outcomes[3].kind).toBe("response"); + }); + + it("healthy-then-dead-then-healthy tunnel sequence reproduces from the plan (F4 shape)", async () => { + const { proxy } = await startProxy("pass"); + const port = proxy.port; + const outcomes = await runSchedule(proxy, [ + { mode: "pass", probes: 2 }, + { mode: "drop", probes: 2 }, + { mode: "pass", probes: 1 }, + ]); + expect(outcomes.map((o) => o.kind)).toEqual([ + "response", + "response", + "timeout", + "timeout", + "response", + ]); + expect(proxy.port).toBe(port); // the tunnel endpoint never moved + }); + + it("a plan mixing refuse and half-open windows is observed exactly as scripted", async () => { + const { proxy } = await startProxy("refuse"); + const outcomes = await runSchedule(proxy, [ + { mode: "refuse", probes: 2 }, + { mode: "half-open", probes: 1 }, + { mode: "clean-close", probes: 1 }, + ]); + expect(outcomes.map((o) => o.kind)).toEqual([ + "refused", + "refused", + "timeout", + "clean-close", + ]); + expect(outcomes[2].handshakeBytes).toBeGreaterThan(0); + }); +}); + +function signatureOf(out: Outcome): string { + switch (out.kind) { + case "response": + return `response:${out.body}`; + case "refused": + case "reset": + return `${out.kind}:${out.code}`; + case "timeout": + return `timeout:handshake=${out.handshakeBytes}`; + case "clean-close": + return "clean-close"; + } +} From c4388fd9a179bcaac7428faca410b035d3bebc71 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 13 Sep 2026 14:30:27 +0200 Subject: [PATCH 3/4] =?UTF-8?q?test(f-harness):=20the=20named-point=20kill?= =?UTF-8?q?=20hook=20=E2=80=94=20pre/mid-commit=20without=20signal=20races?= =?UTF-8?q?=20(#1049)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC3 + F2 shape: KillHookRegistry arms stop/throw/crash at NAMED state-machine boundaries; JournalingCommitter (the representative consumer) invokes them at pre-commit (after verify, pre-journal) and mid-commit (post-journal, pre-done). Tests prove the target stops AT the named point via state, journal and trace; mid-commit stop/crash leaves it journaled and resume() replays to committed — restored-or-journaled, never torn; named-point specificity (an armed pre-commit never fires mid-commit); the scenario reproduces from config. --- .../fixtures/fleet_fault_harness/kill_hook.ts | 165 ++++++++++++++++++ .../test/fleet_fault_kill_hook.test.ts | 139 +++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 packages/extension/test/fixtures/fleet_fault_harness/kill_hook.ts create mode 100644 packages/extension/test/fleet_fault_kill_hook.test.ts diff --git a/packages/extension/test/fixtures/fleet_fault_harness/kill_hook.ts b/packages/extension/test/fixtures/fleet_fault_harness/kill_hook.ts new file mode 100644 index 000000000..e7d8c96a6 --- /dev/null +++ b/packages/extension/test/fixtures/fleet_fault_harness/kill_hook.ts @@ -0,0 +1,165 @@ +// ============================================================================ +// #1049 F-harness — the deterministic mid-commit kill hook (spec-20260913 +// -114814 §8, fixture F2). The kill is a NAMED STATE-MACHINE BOUNDARY, never a +// signal race: the component under test invokes the registry at its named +// boundaries ("pre-commit", "mid-commit", ...) and the scenario arms one fault +// at exactly one of them — stop (halt at the point), throw (propagate an +// error), or crash (simulate process death, escaping the machine entirely). +// +// JournalingCommitter is the representative consumer (as Lease is for the +// clock): stage → verify → commit { write journal → mark committed }, hooks at +// "pre-commit" (after verify, before the journal) and "mid-commit" (after the +// journal, before the commit is marked done). Its resume() replays the +// journal — F2's restored-or-journaled, never torn. +// ============================================================================ + +export type KillFaultKind = "stop" | "throw" | "crash"; + +export type KillFault = { kind: KillFaultKind; error?: Error }; + +export type KillEvent = { point: string; kind: KillFaultKind }; + +export class SimulatedCrash extends Error { + readonly point: string; + constructor(point: string) { + super(`simulated crash at "${point}"`); + this.name = "SimulatedCrash"; + this.point = point; + } +} + +export class KillHookRegistry { + private readonly armed = new Map(); + /** Every fault that actually FIRED, in firing order — the assertion surface. */ + readonly fired: KillEvent[] = []; + + arm(point: string, fault: KillFault): void { + this.armed.set(point, fault); + } + + disarm(point: string): void { + this.armed.delete(point); + } + + /** + * Fire whatever is armed at the named point. Unarmed → undefined (no-op). + * "stop" → returns the event for the machine to obey by halting there; + * "throw" → throws the armed error; "crash" → throws a SimulatedCrash. + */ + invoke(point: string): KillEvent | undefined { + const fault = this.armed.get(point); + if (!fault) return undefined; + const event: KillEvent = { point, kind: fault.kind }; + this.fired.push(event); + if (fault.kind === "throw") throw fault.error ?? new Error(`kill hook fired at "${point}"`); + if (fault.kind === "crash") throw new SimulatedCrash(point); + return event; + } +} + +export type CommitStage = "idle" | "staged" | "verified" | "committing" | "committed"; + +export type CommitJournal = { marker: "commit-pending" }; + +/** + * The representative commit state machine the kill hook is proved against: + * a stop at "pre-commit" leaves it AT verified (no journal, nothing written), + * a stop/crash at "mid-commit" leaves it journaled-but-uncommitted, and + * resume() replays the journal to committed — never torn. + */ +export class JournalingCommitter { + private state: CommitStage = "idle"; + private halted: string | null = null; + private journalValue: CommitJournal | null = null; + private committedFlag = false; + private recovered = false; + private lastErrorValue: Error | null = null; + readonly trace: string[] = []; + + constructor(private readonly hooks: KillHookRegistry) {} + + stage_(): CommitStage { + return this.state; + } + + get haltedAt(): string | null { + return this.halted; + } + + get journal(): CommitJournal | null { + return this.journalValue; + } + + get committed(): boolean { + return this.committedFlag; + } + + get recoveredFromJournal(): boolean { + return this.recovered; + } + + get lastError(): Error | null { + return this.lastErrorValue; + } + + stage(): void { + if (this.state !== "idle") throw new Error(`stage() from state "${this.state}"`); + this.state = "staged"; + this.trace.push("stage"); + } + + verify(): void { + if (this.state !== "staged") throw new Error(`verify() from state "${this.state}"`); + this.state = "verified"; + this.trace.push("verify"); + } + + commit(): void { + if (this.state !== "verified") throw new Error(`commit() from state "${this.state}"`); + // named boundary: pre-commit — after verify, before anything is written + const pre = this.fire("pre-commit"); + if (pre) { + this.halted = "pre-commit"; + this.trace.push("halt@pre-commit"); + return; + } + this.journalValue = { marker: "commit-pending" }; + this.state = "committing"; + this.trace.push("commit:journal"); + // named boundary: mid-commit — journal written, commit not yet marked done + const mid = this.fire("mid-commit"); + if (mid) { + this.halted = "mid-commit"; + this.trace.push("halt@mid-commit"); + return; + } + this.committedFlag = true; + this.state = "committed"; + this.trace.push("commit:done"); + } + + /** Recovery: replay the journal (restored-or-journaled), or re-run an aborted pre-commit halt. */ + resume(): void { + if (this.journalValue && !this.committedFlag) { + this.recovered = true; + this.committedFlag = true; + this.state = "committed"; + this.trace.push("resume:replay", "commit:done"); + return; + } + if (this.halted === "pre-commit" && this.state === "verified") { + this.halted = null; + this.trace.push("resume:abort-retry"); + this.commit(); + } + } + + private fire(point: string): KillEvent | undefined { + try { + return this.hooks.invoke(point); + } catch (err) { + this.lastErrorValue = err as Error; + throw err; + } + } +} diff --git a/packages/extension/test/fleet_fault_kill_hook.test.ts b/packages/extension/test/fleet_fault_kill_hook.test.ts new file mode 100644 index 000000000..c08f78afe --- /dev/null +++ b/packages/extension/test/fleet_fault_kill_hook.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "vitest"; +import { + KillHookRegistry, + SimulatedCrash, + JournalingCommitter, +} from "./fixtures/fleet_fault_harness/kill_hook"; + +// ============================================================================ +// #1049 F-harness — the deterministic mid-commit kill hook (spec-20260913 +// -114814 §8, AC3 + fixture F2). The kill is a NAMED STATE-MACHINE BOUNDARY, +// never a signal race: the component under test invokes the registry at its +// named boundaries ("pre-commit", "mid-commit") and the scenario arms a fault +// at exactly one of them. The test proves the target stops AT the named point +// — not nearby — via the machine's own observable state, journal, and trace. +// +// JournalingCommitter is the representative consumer (like Lease is for the +// clock): stage → verify → commit { write journal → mark committed }, with +// hooks at "pre-commit" (after verify, before the journal) and "mid-commit" +// (after the journal, before the commit is marked done). F2's invariant — +// restored-or-journaled, never torn — is what resume() replays from. +// ============================================================================ + +describe("KillHookRegistry — named-point fault injection", () => { + it("is a no-op at a point nothing armed (the machine runs straight through)", () => { + const hooks = new KillHookRegistry(); + const machine = new JournalingCommitter(hooks); + machine.stage(); + machine.verify(); + machine.commit(); + expect(machine.stage_()).toBe("committed"); + expect(hooks.fired).toEqual([]); + }); + + it("arming pre-commit fires ONLY pre-commit (named-point specificity)", () => { + const hooks = new KillHookRegistry(); + hooks.arm("pre-commit", { kind: "stop" }); + const machine = new JournalingCommitter(hooks); + machine.stage(); + machine.verify(); + try { + machine.commit(); + } catch { + // a stop is reported, not thrown — reaching here would be a bug + throw new Error("stop faults must not throw"); + } + expect(hooks.fired).toEqual([{ point: "pre-commit", kind: "stop" }]); + }); + + it("throw faults propagate the given error into the machine's caller", () => { + const hooks = new KillHookRegistry(); + const boom = new Error("verify infra flaked"); + hooks.arm("pre-commit", { kind: "throw", error: boom }); + const machine = new JournalingCommitter(hooks); + machine.stage(); + machine.verify(); + expect(() => machine.commit()).toThrow(boom); + expect(hooks.fired).toEqual([{ point: "pre-commit", kind: "throw" }]); + expect(machine.lastError).toBe(boom); + }); +}); + +describe("KillHook — stop at PRE-COMMIT: the target stops AT the point, not nearby", () => { + it("verify already ran, the journal was never written, nothing was committed", () => { + const hooks = new KillHookRegistry(); + hooks.arm("pre-commit", { kind: "stop" }); + const machine = new JournalingCommitter(hooks); + machine.stage(); + machine.verify(); + machine.commit(); + + expect(machine.haltedAt).toBe("pre-commit"); // stopped at THE point + expect(machine.stage_()).toBe("verified"); // the stage the halt left + expect(machine.journal).toBeNull(); // pre-journal: the write never began + expect(machine.committed).toBe(false); + expect(machine.trace).toEqual(["stage", "verify", "halt@pre-commit"]); // not one step further, not one step earlier + }); +}); + +describe("KillHook — stop at MID-COMMIT: journaled, then restored — never torn", () => { + it("the journal exists at the halt and resume() replays it to committed", () => { + const hooks = new KillHookRegistry(); + hooks.arm("mid-commit", { kind: "stop" }); + const machine = new JournalingCommitter(hooks); + machine.stage(); + machine.verify(); + machine.commit(); + + expect(machine.haltedAt).toBe("mid-commit"); + expect(machine.stage_()).toBe("committing"); // mid-flight, exactly + expect(machine.journal).not.toBeNull(); // F2: journaled + expect(machine.committed).toBe(false); + + machine.resume(); + expect(machine.stage_()).toBe("committed"); // restored-or-journaled + expect(machine.committed).toBe(true); + expect(machine.recoveredFromJournal).toBe(true); + }); + + it("a CRASH fault at mid-commit simulates process death and recovery still replays", () => { + const hooks = new KillHookRegistry(); + hooks.arm("mid-commit", { kind: "crash" }); + const machine = new JournalingCommitter(hooks); + machine.stage(); + machine.verify(); + + // the crash escapes the machine entirely — the process-death analog + expect(() => machine.commit()).toThrow(SimulatedCrash); + expect(hooks.fired).toEqual([{ point: "mid-commit", kind: "crash" }]); + expect(machine.journal).not.toBeNull(); + + machine.resume(); // next process replays the journal + expect(machine.stage_()).toBe("committed"); + expect(machine.recoveredFromJournal).toBe(true); + }); +}); + +describe("KillHook — determinism: the scenario reproduces from config", () => { + it("the same fault scenario twice yields the identical trace", () => { + const run = () => { + const hooks = new KillHookRegistry(); + hooks.arm("mid-commit", { kind: "stop" }); + const machine = new JournalingCommitter(hooks); + machine.stage(); + machine.verify(); + try { + machine.commit(); + } catch { + // crash faults throw; stop faults don't — both recorded in the trace + } + machine.resume(); + return { trace: machine.trace, fired: hooks.fired }; + }; + const a = run(); + const b = run(); + expect(a.trace).toEqual(b.trace); + expect(a.fired).toEqual(b.fired); + expect(a.trace).toEqual(["stage", "verify", "commit:journal", "halt@mid-commit", "resume:replay", "commit:done"]); + }); +}); From 62b4f6285017a210b015fc6283c9e8f496f2dff7 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 13 Sep 2026 14:33:12 +0200 Subject: [PATCH 4/4] test(f-harness): dependency + scope guard for the harness modules (#1049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC5/AC6 guard: every fleet_fault_harness module may import node: builtins and vitest only — no third-party dependency, no reach into src/** (the harness stays plain test-tree fixture code, usable from the extension's vitest setup without spawning outside the test process tree). --- .../test/fleet_fault_harness_guard.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 packages/extension/test/fleet_fault_harness_guard.test.ts diff --git a/packages/extension/test/fleet_fault_harness_guard.test.ts b/packages/extension/test/fleet_fault_harness_guard.test.ts new file mode 100644 index 000000000..50bb3d067 --- /dev/null +++ b/packages/extension/test/fleet_fault_harness_guard.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +// ============================================================================ +// #1049 F-harness — AC5/AC6 guard: the harness is plain test-tree fixture +// code. Its modules may import node: builtins and vitest ONLY — no third-party +// dependency, no runtime (src/**) import (constraint: test-tree only, node:net +// / node:timers / node:events first). If a future harness module reaches for a +// package or reaches into the extension runtime, this fails before review. +// ============================================================================ + +const HARNESS_DIR = join(__dirname, "fixtures/fleet_fault_harness"); + +const ALLOWED_PREFIXES = ["node:", "vitest"]; + +describe("fleet_fault_harness — dependency + scope guard (AC6)", () => { + const modules = readdirSync(HARNESS_DIR).filter((f) => f.endsWith(".ts")); + + it("the harness directory contains the F-harness modules", () => { + expect(modules).toEqual(expect.arrayContaining(["test_clock.ts", "fault_proxy.ts", "kill_hook.ts"])); + }); + + it.each(modules)("imports nothing outside node: builtins and vitest — %s", (file) => { + const source = readFileSync(join(HARNESS_DIR, file), "utf8"); + const imports = [...source.matchAll(/from\s+["']([^"']+)["']/g)].map((m) => m[1]); + for (const spec of imports) { + expect(ALLOWED_PREFIXES.some((p) => spec.startsWith(p))).toBe(true); + } + // no runtime reach-in: the harness is test-tree only + expect(source).not.toMatch(/from\s+["']\.\.\/\.\.\/\.\.\/src\//); + }); +});