@nodetool-ai/reliability-harness 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/dist/.nodetool-build-stamp +1 -0
- package/dist/cli.d.ts +73 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +201 -0
- package/dist/cli.js.map +1 -0
- package/dist/compare.d.ts +98 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/compare.js +267 -0
- package/dist/compare.js.map +1 -0
- package/dist/core/diff.d.ts +39 -0
- package/dist/core/diff.d.ts.map +1 -0
- package/dist/core/diff.js +114 -0
- package/dist/core/diff.js.map +1 -0
- package/dist/core/golden.d.ts +50 -0
- package/dist/core/golden.d.ts.map +1 -0
- package/dist/core/golden.js +150 -0
- package/dist/core/golden.js.map +1 -0
- package/dist/core/invariants/index.d.ts +32 -0
- package/dist/core/invariants/index.d.ts.map +1 -0
- package/dist/core/invariants/index.js +44 -0
- package/dist/core/invariants/index.js.map +1 -0
- package/dist/core/invariants/leaks.d.ts +22 -0
- package/dist/core/invariants/leaks.d.ts.map +1 -0
- package/dist/core/invariants/leaks.js +55 -0
- package/dist/core/invariants/leaks.js.map +1 -0
- package/dist/core/invariants/lifecycle.d.ts +19 -0
- package/dist/core/invariants/lifecycle.d.ts.map +1 -0
- package/dist/core/invariants/lifecycle.js +95 -0
- package/dist/core/invariants/lifecycle.js.map +1 -0
- package/dist/core/invariants/protocol-validity.d.ts +4 -0
- package/dist/core/invariants/protocol-validity.d.ts.map +1 -0
- package/dist/core/invariants/protocol-validity.js +131 -0
- package/dist/core/invariants/protocol-validity.js.map +1 -0
- package/dist/core/invariants/state-machine.d.ts +29 -0
- package/dist/core/invariants/state-machine.d.ts.map +1 -0
- package/dist/core/invariants/state-machine.js +79 -0
- package/dist/core/invariants/state-machine.js.map +1 -0
- package/dist/core/invariants/status-sets.d.ts +27 -0
- package/dist/core/invariants/status-sets.d.ts.map +1 -0
- package/dist/core/invariants/status-sets.js +51 -0
- package/dist/core/invariants/status-sets.js.map +1 -0
- package/dist/core/invariants/terminal-uniqueness.d.ts +23 -0
- package/dist/core/invariants/terminal-uniqueness.d.ts.map +1 -0
- package/dist/core/invariants/terminal-uniqueness.js +99 -0
- package/dist/core/invariants/terminal-uniqueness.js.map +1 -0
- package/dist/core/invariants/types.d.ts +25 -0
- package/dist/core/invariants/types.d.ts.map +1 -0
- package/dist/core/invariants/types.js +2 -0
- package/dist/core/invariants/types.js.map +1 -0
- package/dist/core/journey.d.ts +121 -0
- package/dist/core/journey.d.ts.map +1 -0
- package/dist/core/journey.js +166 -0
- package/dist/core/journey.js.map +1 -0
- package/dist/core/normalize.d.ts +86 -0
- package/dist/core/normalize.d.ts.map +1 -0
- package/dist/core/normalize.js +182 -0
- package/dist/core/normalize.js.map +1 -0
- package/dist/core/record.d.ts +160 -0
- package/dist/core/record.d.ts.map +1 -0
- package/dist/core/record.js +74 -0
- package/dist/core/record.js.map +1 -0
- package/dist/core/stable-json.d.ts +13 -0
- package/dist/core/stable-json.d.ts.map +1 -0
- package/dist/core/stable-json.js +28 -0
- package/dist/core/stable-json.js.map +1 -0
- package/dist/drivers/anchors.d.ts +29 -0
- package/dist/drivers/anchors.d.ts.map +1 -0
- package/dist/drivers/anchors.js +48 -0
- package/dist/drivers/anchors.js.map +1 -0
- package/dist/drivers/app-headless.d.ts +39 -0
- package/dist/drivers/app-headless.d.ts.map +1 -0
- package/dist/drivers/app-headless.js +123 -0
- package/dist/drivers/app-headless.js.map +1 -0
- package/dist/drivers/browser.d.ts +34 -0
- package/dist/drivers/browser.d.ts.map +1 -0
- package/dist/drivers/browser.js +176 -0
- package/dist/drivers/browser.js.map +1 -0
- package/dist/drivers/cli.d.ts +13 -0
- package/dist/drivers/cli.d.ts.map +1 -0
- package/dist/drivers/cli.js +82 -0
- package/dist/drivers/cli.js.map +1 -0
- package/dist/drivers/fixture-nodes.d.ts +124 -0
- package/dist/drivers/fixture-nodes.d.ts.map +1 -0
- package/dist/drivers/fixture-nodes.js +189 -0
- package/dist/drivers/fixture-nodes.js.map +1 -0
- package/dist/drivers/index.d.ts +18 -0
- package/dist/drivers/index.d.ts.map +1 -0
- package/dist/drivers/index.js +12 -0
- package/dist/drivers/index.js.map +1 -0
- package/dist/drivers/kernel.d.ts +8 -0
- package/dist/drivers/kernel.d.ts.map +1 -0
- package/dist/drivers/kernel.js +247 -0
- package/dist/drivers/kernel.js.map +1 -0
- package/dist/drivers/packaged.d.ts +28 -0
- package/dist/drivers/packaged.d.ts.map +1 -0
- package/dist/drivers/packaged.js +303 -0
- package/dist/drivers/packaged.js.map +1 -0
- package/dist/drivers/python-bridge.d.ts +22 -0
- package/dist/drivers/python-bridge.d.ts.map +1 -0
- package/dist/drivers/python-bridge.js +48 -0
- package/dist/drivers/python-bridge.js.map +1 -0
- package/dist/drivers/registry.d.ts +11 -0
- package/dist/drivers/registry.d.ts.map +1 -0
- package/dist/drivers/registry.js +18 -0
- package/dist/drivers/registry.js.map +1 -0
- package/dist/drivers/relay-fields.d.ts +70 -0
- package/dist/drivers/relay-fields.d.ts.map +1 -0
- package/dist/drivers/relay-fields.js +104 -0
- package/dist/drivers/relay-fields.js.map +1 -0
- package/dist/drivers/repo-root.d.ts +9 -0
- package/dist/drivers/repo-root.d.ts.map +1 -0
- package/dist/drivers/repo-root.js +39 -0
- package/dist/drivers/repo-root.js.map +1 -0
- package/dist/drivers/spawn-cli.d.ts +8 -0
- package/dist/drivers/spawn-cli.d.ts.map +1 -0
- package/dist/drivers/spawn-cli.js +41 -0
- package/dist/drivers/spawn-cli.js.map +1 -0
- package/dist/drivers/types.d.ts +18 -0
- package/dist/drivers/types.d.ts.map +1 -0
- package/dist/drivers/types.js +2 -0
- package/dist/drivers/types.js.map +1 -0
- package/dist/drivers/ws-server.d.ts +8 -0
- package/dist/drivers/ws-server.d.ts.map +1 -0
- package/dist/drivers/ws-server.js +382 -0
- package/dist/drivers/ws-server.js.map +1 -0
- package/dist/faults/bridge-faults.d.ts +7 -0
- package/dist/faults/bridge-faults.d.ts.map +1 -0
- package/dist/faults/bridge-faults.js +90 -0
- package/dist/faults/bridge-faults.js.map +1 -0
- package/dist/faults/host-faults.d.ts +40 -0
- package/dist/faults/host-faults.d.ts.map +1 -0
- package/dist/faults/host-faults.js +70 -0
- package/dist/faults/host-faults.js.map +1 -0
- package/dist/faults/index.d.ts +21 -0
- package/dist/faults/index.d.ts.map +1 -0
- package/dist/faults/index.js +29 -0
- package/dist/faults/index.js.map +1 -0
- package/dist/faults/net-proxy.d.ts +75 -0
- package/dist/faults/net-proxy.d.ts.map +1 -0
- package/dist/faults/net-proxy.js +305 -0
- package/dist/faults/net-proxy.js.map +1 -0
- package/dist/faults/provider-faults.d.ts +12 -0
- package/dist/faults/provider-faults.d.ts.map +1 -0
- package/dist/faults/provider-faults.js +110 -0
- package/dist/faults/provider-faults.js.map +1 -0
- package/dist/faults/registry.d.ts +17 -0
- package/dist/faults/registry.d.ts.map +1 -0
- package/dist/faults/registry.js +15 -0
- package/dist/faults/registry.js.map +1 -0
- package/dist/faults/types.d.ts +42 -0
- package/dist/faults/types.d.ts.map +1 -0
- package/dist/faults/types.js +2 -0
- package/dist/faults/types.js.map +1 -0
- package/dist/faults/ws-faults.d.ts +6 -0
- package/dist/faults/ws-faults.d.ts.map +1 -0
- package/dist/faults/ws-faults.js +48 -0
- package/dist/faults/ws-faults.js.map +1 -0
- package/dist/faults/ws-proxy.d.ts +36 -0
- package/dist/faults/ws-proxy.d.ts.map +1 -0
- package/dist/faults/ws-proxy.js +40 -0
- package/dist/faults/ws-proxy.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/package.json +75 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge-seam fault modules (docs/RELIABILITY_ARCHITECTURE.md §9; task D3).
|
|
3
|
+
*
|
|
4
|
+
* Each of the five `bridge-*` fault names in `cli.ts`'s `KNOWN_FAULT_TYPES`
|
|
5
|
+
* drives E2's faithful stdio fake worker
|
|
6
|
+
* (`packages/runtime/tests/fixtures/fake-python-stdio-worker.ts`) with the
|
|
7
|
+
* `FAKE_WORKER_*` env var it already understands — the exact same knobs
|
|
8
|
+
* `packages/runtime/tests/python-stdio-bridge.test.ts` uses directly against
|
|
9
|
+
* `PythonStdioBridge`. `configure()` sets the env vars ambient (the fake
|
|
10
|
+
* interpreter shim spawns with `env: process.env`, see
|
|
11
|
+
* `python-stdio-bridge.ts`'s `_spawnCandidate`), and the teardown restores
|
|
12
|
+
* whatever was there before — same "process-global side effect, always
|
|
13
|
+
* undone" contract `provider-faults.ts` uses for the provider registry.
|
|
14
|
+
*
|
|
15
|
+
* The driver-side wiring that actually spawns the fake worker for a graph
|
|
16
|
+
* that needs one lives in `drivers/python-bridge.ts` (shared by the kernel
|
|
17
|
+
* and ws-server drivers) — these fault modules never touch a driver or a
|
|
18
|
+
* bridge instance directly, only the env vars the next spawn will read.
|
|
19
|
+
*/
|
|
20
|
+
import { registerFaultModule } from "./registry.js";
|
|
21
|
+
/** `cli.ts`'s `KNOWN_FAULT_TYPES` `bridge-*` entries, mapped to the
|
|
22
|
+
* `FAKE_WORKER_*` env vars that reproduce each fault against E2's fake
|
|
23
|
+
* (see that fixture's own doc comment for the full knob list). */
|
|
24
|
+
const FAULT_ENV_BY_NAME = {
|
|
25
|
+
// Worker exits without responding once it receives an `execute` request —
|
|
26
|
+
// `PythonStdioBridge.execute()` rejects the pending request when the
|
|
27
|
+
// process exits (`python-stdio-bridge.test.ts`: "rejects pending requests
|
|
28
|
+
// when the worker exits mid-request").
|
|
29
|
+
"bridge-exit-mid-request": { FAKE_WORKER_EXIT_ON_TYPE: "execute" },
|
|
30
|
+
// Worker replies to `execute` with an absurd length prefix instead of a
|
|
31
|
+
// real frame — `FrameDecoder` fails the whole connection
|
|
32
|
+
// (`FrameSizeError`), same path a real desynced/corrupted stream hits.
|
|
33
|
+
"bridge-framing-violation": { FAKE_WORKER_BAD_LENGTH_ON_TYPE: "execute" },
|
|
34
|
+
// Worker never emits "NODETOOL_STDIO_READY" — `connect()`'s startup timer
|
|
35
|
+
// (`FAKE_BRIDGE_STARTUP_TIMEOUT_MS` in `drivers/python-bridge.ts`) fires
|
|
36
|
+
// instead of a normal ready signal.
|
|
37
|
+
"bridge-never-ready": { FAKE_WORKER_MODE: "never-ready" },
|
|
38
|
+
// Worker's stdin read end is closed immediately (before `discover` is even
|
|
39
|
+
// sent, given the ready-signal head start), so the bridge's very first
|
|
40
|
+
// write hits a genuinely broken pipe — the same EPIPE path
|
|
41
|
+
// `python-stdio-bridge.test.ts`'s "fails connect() with EPIPE" test drives.
|
|
42
|
+
"bridge-epipe": {
|
|
43
|
+
FAKE_WORKER_CLOSE_STDIN_AFTER_MS: "0",
|
|
44
|
+
FAKE_WORKER_READY_DELAY_MS: "400"
|
|
45
|
+
},
|
|
46
|
+
// Worker reports a `protocol_version` below `MIN_BRIDGE_PROTOCOL_VERSION`
|
|
47
|
+
// (1, `@nodetool-ai/protocol/bridge-protocol`) — the bridge's hard-floor
|
|
48
|
+
// check in `python-bridge-base.ts`'s discover handler rejects the connect.
|
|
49
|
+
"bridge-version-mismatch": { FAKE_WORKER_PROTOCOL_VERSION: "0" }
|
|
50
|
+
};
|
|
51
|
+
function applyEnvOverrides(env) {
|
|
52
|
+
const previous = {};
|
|
53
|
+
for (const key of Object.keys(env)) {
|
|
54
|
+
previous[key] = process.env[key];
|
|
55
|
+
process.env[key] = env[key];
|
|
56
|
+
}
|
|
57
|
+
return () => {
|
|
58
|
+
for (const key of Object.keys(previous)) {
|
|
59
|
+
const value = previous[key];
|
|
60
|
+
if (value === undefined)
|
|
61
|
+
delete process.env[key];
|
|
62
|
+
else
|
|
63
|
+
process.env[key] = value;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function configureBridgeFault(name, _ctx) {
|
|
68
|
+
const env = FAULT_ENV_BY_NAME[name];
|
|
69
|
+
if (!env) {
|
|
70
|
+
throw new Error(`bridge-faults.ts: no env mapping for fault "${name}"`);
|
|
71
|
+
}
|
|
72
|
+
return applyEnvOverrides(env);
|
|
73
|
+
}
|
|
74
|
+
function makeBridgeFaultModule(name) {
|
|
75
|
+
return {
|
|
76
|
+
name,
|
|
77
|
+
seam: "bridge",
|
|
78
|
+
configure: (ctx) => configureBridgeFault(name, ctx)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export const BRIDGE_FAULT_MODULES = Object.keys(FAULT_ENV_BY_NAME).map(makeBridgeFaultModule);
|
|
82
|
+
/** Registers every bridge-seam fault module. Called once from
|
|
83
|
+
* `faults/index.ts`'s module-load side effect (task D1's "call once, own
|
|
84
|
+
* module state" contract) — safe to call again, last-write-wins. */
|
|
85
|
+
export function registerBridgeFaultModules() {
|
|
86
|
+
for (const module of BRIDGE_FAULT_MODULES) {
|
|
87
|
+
registerFaultModule(module);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=bridge-faults.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bridge-faults.js","sourceRoot":"","sources":["../../src/faults/bridge-faults.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAGpD;;kEAEkE;AAClE,MAAM,iBAAiB,GAA+D;IACpF,0EAA0E;IAC1E,qEAAqE;IACrE,0EAA0E;IAC1E,uCAAuC;IACvC,yBAAyB,EAAE,EAAE,wBAAwB,EAAE,SAAS,EAAE;IAClE,wEAAwE;IACxE,yDAAyD;IACzD,uEAAuE;IACvE,0BAA0B,EAAE,EAAE,8BAA8B,EAAE,SAAS,EAAE;IACzE,0EAA0E;IAC1E,yEAAyE;IACzE,oCAAoC;IACpC,oBAAoB,EAAE,EAAE,gBAAgB,EAAE,aAAa,EAAE;IACzD,2EAA2E;IAC3E,uEAAuE;IACvE,2DAA2D;IAC3D,4EAA4E;IAC5E,cAAc,EAAE;QACd,gCAAgC,EAAE,GAAG;QACrC,0BAA0B,EAAE,KAAK;KAClC;IACD,0EAA0E;IAC1E,yEAAyE;IACzE,2EAA2E;IAC3E,yBAAyB,EAAE,EAAE,4BAA4B,EAAE,GAAG,EAAE;CACjE,CAAC;AAEF,SAAS,iBAAiB,CAAC,GAAqC;IAC9D,MAAM,QAAQ,GAAuC,EAAE,CAAC;IACxD,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QACnC,QAAQ,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACjC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC;IAC9B,CAAC;IACD,OAAO,GAAG,EAAE;QACV,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;YACxC,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC;YAC5B,IAAI,KAAK,KAAK,SAAS;gBAAE,OAAO,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;;gBAC5C,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;QAChC,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED,SAAS,oBAAoB,CAAC,IAAY,EAAE,IAAkB;IAC5D,MAAM,GAAG,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAC;IACpC,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,MAAM,IAAI,KAAK,CAAC,+CAA+C,IAAI,GAAG,CAAC,CAAC;IAC1E,CAAC;IACD,OAAO,iBAAiB,CAAC,GAAG,CAAC,CAAC;AAChC,CAAC;AAED,SAAS,qBAAqB,CAAC,IAAY;IACzC,OAAO;QACL,IAAI;QACJ,IAAI,EAAE,QAAQ;QACd,SAAS,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,oBAAoB,CAAC,IAAI,EAAE,GAAG,CAAC;KACpD,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,MAAM,oBAAoB,GAA2B,MAAM,CAAC,IAAI,CACrE,iBAAiB,CAClB,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC;AAE7B;;oEAEoE;AACpE,MAAM,UAAU,0BAA0B;IACxC,KAAK,MAAM,MAAM,IAAI,oBAAoB,EAAE,CAAC;QAC1C,mBAAmB,CAAC,MAAM,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-seam fault modules (docs/RELIABILITY_ARCHITECTURE.md §9; task D3).
|
|
3
|
+
*
|
|
4
|
+
* Of the three §9 "process/host" faults, only `host-disk-full` is a
|
|
5
|
+
* `FaultModule` here:
|
|
6
|
+
*
|
|
7
|
+
* - `host-disk-full`: an injectable `StorageAdapter` whose writes fail with
|
|
8
|
+
* ENOSPC. Wired into the **kernel driver only** (`drivers/kernel.ts`
|
|
9
|
+
* consults {@link getInjectedHostStorage} when building the run's
|
|
10
|
+
* `ProcessingContext`) — the ws-server driver's asset storage comes from
|
|
11
|
+
* `websocket-client-session.ts`'s own module-level `getTempAdapter()`
|
|
12
|
+
* singleton (`lib/storage.ts`), which task A5 explicitly reserves for a
|
|
13
|
+
* later `ExecutionSession` migration ("do not refactor
|
|
14
|
+
* `websocket-client-session.ts`... pin behavior with tests around it").
|
|
15
|
+
* Journeys exercising this fault must scope it to `"surface": "kernel"`.
|
|
16
|
+
* - `host-db-locked` has no `FaultModule`: the swallowed failure it pins
|
|
17
|
+
* (`websocket-client-session.ts` ~2868) is job-table persistence, which
|
|
18
|
+
* neither driver's hermetic runs touch (both run with `persistence:
|
|
19
|
+
* "session"`/no persistence hook) — see
|
|
20
|
+
* `packages/websocket/tests/job-persistence-failure.test.ts` for the
|
|
21
|
+
* dedicated pin instead. `--faults host-db-locked` correctly reports
|
|
22
|
+
* "unknown fault" from this harness until a journey exists that actually
|
|
23
|
+
* exercises job persistence.
|
|
24
|
+
* - `host-sigkill-restart` has no `FaultModule`: killing and restarting the
|
|
25
|
+
* server process is a harness-level *test* (spawns/kills a real process),
|
|
26
|
+
* not a per-run "configure/teardown around one driver call" fault — see
|
|
27
|
+
* `tests/journeys/host-sigkill-restart.test.ts`.
|
|
28
|
+
*/
|
|
29
|
+
import { type StorageAdapter } from "@nodetool-ai/runtime";
|
|
30
|
+
import type { FaultModule } from "./types.js";
|
|
31
|
+
/** Consulted by the kernel driver when building a run's `ProcessingContext` —
|
|
32
|
+
* non-null only while `host-disk-full` is configured for the current
|
|
33
|
+
* `compareJourney` call. */
|
|
34
|
+
export declare function getInjectedHostStorage(): StorageAdapter | null;
|
|
35
|
+
export declare const HOST_FAULT_MODULES: readonly FaultModule[];
|
|
36
|
+
/** Registers every host-seam fault module this harness implements. Called
|
|
37
|
+
* once from `faults/index.ts`'s module-load side effect — safe to call
|
|
38
|
+
* again, last-write-wins. */
|
|
39
|
+
export declare function registerHostFaultModules(): void;
|
|
40
|
+
//# sourceMappingURL=host-faults.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-faults.d.ts","sourceRoot":"","sources":["../../src/faults/host-faults.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAU3D,OAAO,KAAK,EAAgB,WAAW,EAAiB,MAAM,YAAY,CAAC;AAsD3E;;4BAE4B;AAC5B,wBAAgB,sBAAsB,IAAI,cAAc,GAAG,IAAI,CAE9D;AAUD,eAAO,MAAM,kBAAkB,EAAE,SAAS,WAAW,EAMpD,CAAC;AAEF;;6BAE6B;AAC7B,wBAAgB,wBAAwB,IAAI,IAAI,CAI/C"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { InMemoryStorageAdapter } from "@nodetool-ai/storage";
|
|
2
|
+
import { registerFaultModule } from "./registry.js";
|
|
3
|
+
function enospcError(key) {
|
|
4
|
+
const err = new Error(`ENOSPC: no space left on device, write '${key}'`);
|
|
5
|
+
err.code = "ENOSPC";
|
|
6
|
+
return err;
|
|
7
|
+
}
|
|
8
|
+
/** Wraps a real adapter, failing every WRITE op with an ENOSPC-shaped error —
|
|
9
|
+
* reads still work (a full disk doesn't un-write what's already on it),
|
|
10
|
+
* matching what an actual write failure looks like mid-run. */
|
|
11
|
+
class EnospcStorageAdapter {
|
|
12
|
+
inner;
|
|
13
|
+
constructor(inner) {
|
|
14
|
+
this.inner = inner;
|
|
15
|
+
}
|
|
16
|
+
store(key) {
|
|
17
|
+
throw enospcError(key);
|
|
18
|
+
}
|
|
19
|
+
retrieve(uri) {
|
|
20
|
+
return this.inner.retrieve(uri);
|
|
21
|
+
}
|
|
22
|
+
exists(uri) {
|
|
23
|
+
return this.inner.exists(uri);
|
|
24
|
+
}
|
|
25
|
+
uriForKey(key) {
|
|
26
|
+
return this.inner.uriForKey(key);
|
|
27
|
+
}
|
|
28
|
+
list(prefix, opts) {
|
|
29
|
+
return this.inner.list(prefix, opts);
|
|
30
|
+
}
|
|
31
|
+
delete(uri) {
|
|
32
|
+
throw enospcError(uri);
|
|
33
|
+
}
|
|
34
|
+
stat(uri) {
|
|
35
|
+
return this.inner.stat(uri);
|
|
36
|
+
}
|
|
37
|
+
localPath(uri) {
|
|
38
|
+
return this.inner.localPath(uri);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
let injectedStorage = null;
|
|
42
|
+
/** Consulted by the kernel driver when building a run's `ProcessingContext` —
|
|
43
|
+
* non-null only while `host-disk-full` is configured for the current
|
|
44
|
+
* `compareJourney` call. */
|
|
45
|
+
export function getInjectedHostStorage() {
|
|
46
|
+
return injectedStorage;
|
|
47
|
+
}
|
|
48
|
+
function configureDiskFullFault(_ctx) {
|
|
49
|
+
const previous = injectedStorage;
|
|
50
|
+
injectedStorage = new EnospcStorageAdapter(new InMemoryStorageAdapter());
|
|
51
|
+
return () => {
|
|
52
|
+
injectedStorage = previous;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export const HOST_FAULT_MODULES = [
|
|
56
|
+
{
|
|
57
|
+
name: "host-disk-full",
|
|
58
|
+
seam: "host",
|
|
59
|
+
configure: configureDiskFullFault
|
|
60
|
+
}
|
|
61
|
+
];
|
|
62
|
+
/** Registers every host-seam fault module this harness implements. Called
|
|
63
|
+
* once from `faults/index.ts`'s module-load side effect — safe to call
|
|
64
|
+
* again, last-write-wins. */
|
|
65
|
+
export function registerHostFaultModules() {
|
|
66
|
+
for (const module of HOST_FAULT_MODULES) {
|
|
67
|
+
registerFaultModule(module);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=host-faults.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-faults.js","sourceRoot":"","sources":["../../src/faults/host-faults.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAUpD,SAAS,WAAW,CAAC,GAAW;IAC9B,MAAM,GAAG,GAAG,IAAI,KAAK,CACnB,2CAA2C,GAAG,GAAG,CACzB,CAAC;IAC3B,GAAG,CAAC,IAAI,GAAG,QAAQ,CAAC;IACpB,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;+DAE+D;AAC/D,MAAM,oBAAoB;IACK,KAAK;IAAlC,YAA6B,KAAqB;qBAArB,KAAK;IAAmB,CAAC;IAEtD,KAAK,CAAC,GAAW;QACf,MAAM,WAAW,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;IAED,QAAQ,CAAC,GAAW;QAClB,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;IAClC,CAAC;IAED,MAAM,CAAC,GAAW;QAChB,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAChC,CAAC;IAED,SAAS,CAAC,GAAW;QACnB,OAAO,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IACnC,CAAC;IAED,IAAI,CACF,MAAc,EACd,IAA6B;QAE7B,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IACvC,CAAC;IAED,MAAM,CAAC,GAAW;QAChB,MAAM,WAAW,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;IAED,IAAI,CAAC,GAAW;QACd,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC9B,CAAC;IAED,SAAS,CAAC,GAAW;QACnB,OAAO,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IACnC,CAAC;CACF;AAED,IAAI,eAAe,GAA0B,IAAI,CAAC;AAElD;;4BAE4B;AAC5B,MAAM,UAAU,sBAAsB;IACpC,OAAO,eAAe,CAAC;AACzB,CAAC;AAED,SAAS,sBAAsB,CAAC,IAAkB;IAChD,MAAM,QAAQ,GAAG,eAAe,CAAC;IACjC,eAAe,GAAG,IAAI,oBAAoB,CAAC,IAAI,sBAAsB,EAAE,CAAC,CAAC;IACzE,OAAO,GAAG,EAAE;QACV,eAAe,GAAG,QAAQ,CAAC;IAC7B,CAAC,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,MAAM,kBAAkB,GAA2B;IACxD;QACE,IAAI,EAAE,gBAAgB;QACtB,IAAI,EAAE,MAAM;QACZ,SAAS,EAAE,sBAAsB;KAClC;CACF,CAAC;AAEF;;6BAE6B;AAC7B,MAAM,UAAU,wBAAwB;IACtC,KAAK,MAAM,MAAM,IAAI,kBAAkB,EAAE,CAAC;QACxC,mBAAmB,CAAC,MAAM,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fault layer barrel (task D1). Importing this module registers every
|
|
3
|
+
* built-in fault module as a side effect — the same "import to register"
|
|
4
|
+
* shape `@nodetool-ai/base-nodes`' `registerBaseNodes` and this harness's own
|
|
5
|
+
* `registerFixtureNodes` use — so `compare.ts`/`cli.ts` only need to import
|
|
6
|
+
* `faults/index.js` once to have every known fault name resolvable.
|
|
7
|
+
*
|
|
8
|
+
* D2 (WS proxy) and D3 (bridge/host) add a sibling module file exporting
|
|
9
|
+
* their own `FaultModule[]` plus a `register*FaultModules()` function, call
|
|
10
|
+
* it from this barrel, and are done — `types.ts`/`registry.ts`/`compare.ts`
|
|
11
|
+
* need no changes.
|
|
12
|
+
*/
|
|
13
|
+
export * from "./types.js";
|
|
14
|
+
export * from "./registry.js";
|
|
15
|
+
export * from "./provider-faults.js";
|
|
16
|
+
export * from "./net-proxy.js";
|
|
17
|
+
export * from "./ws-proxy.js";
|
|
18
|
+
export * from "./ws-faults.js";
|
|
19
|
+
export * from "./bridge-faults.js";
|
|
20
|
+
export * from "./host-faults.js";
|
|
21
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/faults/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fault layer barrel (task D1). Importing this module registers every
|
|
3
|
+
* built-in fault module as a side effect — the same "import to register"
|
|
4
|
+
* shape `@nodetool-ai/base-nodes`' `registerBaseNodes` and this harness's own
|
|
5
|
+
* `registerFixtureNodes` use — so `compare.ts`/`cli.ts` only need to import
|
|
6
|
+
* `faults/index.js` once to have every known fault name resolvable.
|
|
7
|
+
*
|
|
8
|
+
* D2 (WS proxy) and D3 (bridge/host) add a sibling module file exporting
|
|
9
|
+
* their own `FaultModule[]` plus a `register*FaultModules()` function, call
|
|
10
|
+
* it from this barrel, and are done — `types.ts`/`registry.ts`/`compare.ts`
|
|
11
|
+
* need no changes.
|
|
12
|
+
*/
|
|
13
|
+
export * from "./types.js";
|
|
14
|
+
export * from "./registry.js";
|
|
15
|
+
export * from "./provider-faults.js";
|
|
16
|
+
export * from "./net-proxy.js";
|
|
17
|
+
export * from "./ws-proxy.js";
|
|
18
|
+
export * from "./ws-faults.js";
|
|
19
|
+
export * from "./bridge-faults.js";
|
|
20
|
+
export * from "./host-faults.js";
|
|
21
|
+
import { registerProviderFaultModules } from "./provider-faults.js";
|
|
22
|
+
import { registerWsFaultModules } from "./ws-faults.js";
|
|
23
|
+
import { registerBridgeFaultModules } from "./bridge-faults.js";
|
|
24
|
+
import { registerHostFaultModules } from "./host-faults.js";
|
|
25
|
+
registerProviderFaultModules();
|
|
26
|
+
registerWsFaultModules();
|
|
27
|
+
registerBridgeFaultModules();
|
|
28
|
+
registerHostFaultModules();
|
|
29
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/faults/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AAEjC,OAAO,EAAE,4BAA4B,EAAE,MAAM,sBAAsB,CAAC;AACpE,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,0BAA0B,EAAE,MAAM,oBAAoB,CAAC;AAChE,OAAO,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC;AAE5D,4BAA4B,EAAE,CAAC;AAC/B,sBAAsB,EAAE,CAAC;AACzB,0BAA0B,EAAE,CAAC;AAC7B,wBAAwB,EAAE,CAAC"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** The six `ws-*` fault kinds §9 names, mapped 1:1 onto `cli.ts`'s
|
|
2
|
+
* `KNOWN_FAULT_TYPES` `ws-*` entries (`ws-faults.ts` does that mapping). */
|
|
3
|
+
export type WsProxyFaultKind = "drop-no-fin" | "delay" | "reorder" | "fragment" | "stall-reads" | "abrupt-close";
|
|
4
|
+
export interface WsProxyFaultConfig {
|
|
5
|
+
kind: WsProxyFaultKind;
|
|
6
|
+
/**
|
|
7
|
+
* Frame-count anchor: the fault arms on the first frame *after* this many
|
|
8
|
+
* frames have already been forwarded in the fault's relevant direction
|
|
9
|
+
* (server->client for every kind except `abrupt-close`, which counts
|
|
10
|
+
* client->server frames — see the class doc). Default 0: arm immediately,
|
|
11
|
+
* i.e. on the very first frame.
|
|
12
|
+
*/
|
|
13
|
+
afterFrames?: number;
|
|
14
|
+
/** `delay` only: fixed added latency per chunk, ms. Default 200. */
|
|
15
|
+
delayMs?: number;
|
|
16
|
+
/** `fragment` only: TCP-write chunk size in bytes. Default 4. */
|
|
17
|
+
chunkBytes?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Incrementally counts complete WebSocket frames in a byte stream that may
|
|
21
|
+
* arrive split across many `data` events. Parses only the frame header
|
|
22
|
+
* (RFC 6455 §5.2 — 2-10 header bytes plus an optional 4-byte mask key) to
|
|
23
|
+
* find each frame's total length; payload bytes are never inspected. Good
|
|
24
|
+
* enough for frame-boundary anchoring — not a full WS parser (continuation
|
|
25
|
+
* frames are counted as their own frame, which is fine: this harness's
|
|
26
|
+
* fixture messages are always single-frame sends).
|
|
27
|
+
*/
|
|
28
|
+
export declare class WsFrameCounter {
|
|
29
|
+
private buf;
|
|
30
|
+
/** Number of complete frames seen so far. */
|
|
31
|
+
count: number;
|
|
32
|
+
feed(chunk: Buffer): void;
|
|
33
|
+
/** Returns the total byte length of the frame starting at `buf[0]`, or
|
|
34
|
+
* `null` if `buf` doesn't yet hold a complete header. */
|
|
35
|
+
static tryFrameLength(buf: Buffer): number | null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* One TCP proxy instance in front of one `(targetHost, targetPort)`. Every
|
|
39
|
+
* accepted client connection gets its own pair of pipes (client->server,
|
|
40
|
+
* server->client); `fault` (settable any time via {@link setFault}) governs
|
|
41
|
+
* both pipes of every connection accepted while it's set — matching how a
|
|
42
|
+
* real network fault (a flaky link, a saturated NAT table) affects whatever
|
|
43
|
+
* is talking over it, not just connections opened after the fault started.
|
|
44
|
+
*/
|
|
45
|
+
export declare class WsFaultProxy {
|
|
46
|
+
private readonly targetHost;
|
|
47
|
+
private readonly targetPort;
|
|
48
|
+
private fault;
|
|
49
|
+
private server;
|
|
50
|
+
private readonly connections;
|
|
51
|
+
private port;
|
|
52
|
+
constructor(targetHost: string, targetPort: number, fault?: WsProxyFaultConfig | null);
|
|
53
|
+
setFault(fault: WsProxyFaultConfig | null): void;
|
|
54
|
+
listen(): Promise<number>;
|
|
55
|
+
getPort(): number;
|
|
56
|
+
close(): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Forces every currently-open connection's pair dead — used by the
|
|
59
|
+
* client-reconnect-mid-run journey to sever a client mid-run outside the
|
|
60
|
+
* declarative fault matrix. `"reset"` destroys both sockets (an ordinary
|
|
61
|
+
* TCP RST/FIN teardown); `"half-open"` stops all forwarding and pauses
|
|
62
|
+
* both sockets' reads without closing either — the same net-level black
|
|
63
|
+
* hole `drop-no-fin` produces, just triggered on demand instead of after
|
|
64
|
+
* a frame count.
|
|
65
|
+
*/
|
|
66
|
+
killConnections(mode: "reset" | "half-open"): void;
|
|
67
|
+
private handleConnection;
|
|
68
|
+
/** Builds the `data`-event handler for one direction of one connection.
|
|
69
|
+
* Per-pipe state (the frame counter, the one-shot flags below) lives in
|
|
70
|
+
* this closure, not on the proxy or the connection entry, so concurrent
|
|
71
|
+
* connections and the two directions of the same connection never share
|
|
72
|
+
* counters. */
|
|
73
|
+
private makePipe;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=net-proxy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"net-proxy.d.ts","sourceRoot":"","sources":["../../src/faults/net-proxy.ts"],"names":[],"mappings":"AAqBA;4EAC4E;AAC5E,MAAM,MAAM,gBAAgB,GACxB,aAAa,GACb,OAAO,GACP,SAAS,GACT,UAAU,GACV,aAAa,GACb,cAAc,CAAC;AAEnB,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,gBAAgB,CAAC;IACvB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oEAAoE;IACpE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAOD;;;;;;;;GAQG;AACH,qBAAa,cAAc;IACzB,OAAO,CAAC,GAAG,CAA2B;IACtC,6CAA6C;IAC7C,KAAK,SAAK;IAEV,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAQxB;IAED;6DACyD;IACzD,MAAM,CAAC,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiBhD;CACF;AAmCD;;;;;;;GAOG;AACH,qBAAa,YAAY;IAMrB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,KAAK;IAPf,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAgC;IAC5D,OAAO,CAAC,IAAI,CAAK;IAEjB,YACmB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAC3B,KAAK,GAAE,kBAAkB,GAAG,IAAW,EAC7C;IAEJ,QAAQ,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI,GAAG,IAAI,CAE/C;IAEK,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC,CAS9B;IAED,OAAO,IAAI,MAAM,CAEhB;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAW3B;IAED;;;;;;;;OAQG;IACH,eAAe,CAAC,IAAI,EAAE,OAAO,GAAG,WAAW,GAAG,IAAI,CAUjD;IAED,OAAO,CAAC,gBAAgB;IAwBxB;;;;mBAIe;IACf,OAAO,CAAC,QAAQ;CAuHjB"}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Net-level TCP fault proxy (docs/RELIABILITY_ARCHITECTURE.md §9 "WS
|
|
3
|
+
* transport" row; task D2). Sits in front of a real TCP server (the
|
|
4
|
+
* ws-server driver's hermetic `e2e-server`) and forwards bytes verbatim in
|
|
5
|
+
* both directions until a configured {@link WsProxyFaultConfig} tells one
|
|
6
|
+
* direction's pipe to misbehave.
|
|
7
|
+
*
|
|
8
|
+
* This is a raw byte-level proxy, not a WebSocket-aware one — it never
|
|
9
|
+
* terminates the WS handshake or decodes frame payloads. The one thing it
|
|
10
|
+
* does parse is the RFC 6455 frame *header* (via {@link WsFrameCounter}), just
|
|
11
|
+
* enough to find frame boundaries in the byte stream so a fault can be
|
|
12
|
+
* anchored on "after N frames" rather than an arbitrary byte count or a
|
|
13
|
+
* wall-clock delay (§9: "event-anchored, not time-anchored", mirroring
|
|
14
|
+
* `drivers/anchors.ts`'s message-type anchors one layer down, at the wire
|
|
15
|
+
* instead of the decoded message).
|
|
16
|
+
*
|
|
17
|
+
* `ws-proxy.ts` is the seam-facing half (process-wide "is a fault active"
|
|
18
|
+
* state the `WsServerDriver` consults); this file is the mechanism.
|
|
19
|
+
*/
|
|
20
|
+
import { connect, createServer } from "node:net";
|
|
21
|
+
/**
|
|
22
|
+
* Incrementally counts complete WebSocket frames in a byte stream that may
|
|
23
|
+
* arrive split across many `data` events. Parses only the frame header
|
|
24
|
+
* (RFC 6455 §5.2 — 2-10 header bytes plus an optional 4-byte mask key) to
|
|
25
|
+
* find each frame's total length; payload bytes are never inspected. Good
|
|
26
|
+
* enough for frame-boundary anchoring — not a full WS parser (continuation
|
|
27
|
+
* frames are counted as their own frame, which is fine: this harness's
|
|
28
|
+
* fixture messages are always single-frame sends).
|
|
29
|
+
*/
|
|
30
|
+
export class WsFrameCounter {
|
|
31
|
+
buf = Buffer.alloc(0);
|
|
32
|
+
/** Number of complete frames seen so far. */
|
|
33
|
+
count = 0;
|
|
34
|
+
feed(chunk) {
|
|
35
|
+
this.buf = this.buf.length > 0 ? Buffer.concat([this.buf, chunk]) : chunk;
|
|
36
|
+
for (;;) {
|
|
37
|
+
const frameLength = WsFrameCounter.tryFrameLength(this.buf);
|
|
38
|
+
if (frameLength === null || this.buf.length < frameLength)
|
|
39
|
+
return;
|
|
40
|
+
this.count += 1;
|
|
41
|
+
this.buf = this.buf.subarray(frameLength);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Returns the total byte length of the frame starting at `buf[0]`, or
|
|
45
|
+
* `null` if `buf` doesn't yet hold a complete header. */
|
|
46
|
+
static tryFrameLength(buf) {
|
|
47
|
+
if (buf.length < 2)
|
|
48
|
+
return null;
|
|
49
|
+
const secondByte = buf[1];
|
|
50
|
+
const masked = (secondByte & 0x80) !== 0;
|
|
51
|
+
let payloadLength = secondByte & 0x7f;
|
|
52
|
+
let offset = 2;
|
|
53
|
+
if (payloadLength === 126) {
|
|
54
|
+
if (buf.length < 4)
|
|
55
|
+
return null;
|
|
56
|
+
payloadLength = buf.readUInt16BE(2);
|
|
57
|
+
offset = 4;
|
|
58
|
+
}
|
|
59
|
+
else if (payloadLength === 127) {
|
|
60
|
+
if (buf.length < 10)
|
|
61
|
+
return null;
|
|
62
|
+
payloadLength = Number(buf.readBigUInt64BE(2));
|
|
63
|
+
offset = 10;
|
|
64
|
+
}
|
|
65
|
+
if (masked)
|
|
66
|
+
offset += 4;
|
|
67
|
+
return offset + payloadLength;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Writes `chunk` to `socket` in `chunkBytes`-sized pieces. Synchronous and
|
|
72
|
+
* loop-based (not `setImmediate`-deferred) on purpose: this runs inside a
|
|
73
|
+
* `data` event handler, and a deferred, multi-tick version can still be
|
|
74
|
+
* mid-flight when the *next* `data` event fires on the same socket pair —
|
|
75
|
+
* two concurrently-running fragmenters racing to `write()` the same
|
|
76
|
+
* destination socket interleave their pieces and corrupt the byte stream.
|
|
77
|
+
* A single synchronous loop can't race anything: it either fully finishes
|
|
78
|
+
* before the next `data` event is even dispatched, or the destination socket
|
|
79
|
+
* is already destroyed (checked per piece) and the rest is silently dropped.
|
|
80
|
+
*/
|
|
81
|
+
function writeFragmented(socket, chunk, chunkBytes) {
|
|
82
|
+
const step = Math.max(1, chunkBytes);
|
|
83
|
+
for (let offset = 0; offset < chunk.length; offset += step) {
|
|
84
|
+
if (socket.destroyed)
|
|
85
|
+
return;
|
|
86
|
+
socket.write(chunk.subarray(offset, Math.min(offset + step, chunk.length)));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Which direction a given fault kind's frame-count anchor applies to.
|
|
90
|
+
* `abrupt-close` models "close hard right after the run_job command is
|
|
91
|
+
* forwarded" (§9) — every journey's first `client_to_server` frame is the
|
|
92
|
+
* `run_job` command envelope (`ws-server.ts`'s driver always sends `run`
|
|
93
|
+
* first), so counting client->server frames with the default `afterFrames:
|
|
94
|
+
* 0` closes right after that command reaches the server. Every other kind
|
|
95
|
+
* targets server->client frames — the direction a client's parser actually
|
|
96
|
+
* has to tolerate misbehaving. */
|
|
97
|
+
function relevantDirection(kind) {
|
|
98
|
+
return kind === "abrupt-close" ? "client-to-server" : "server-to-client";
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* One TCP proxy instance in front of one `(targetHost, targetPort)`. Every
|
|
102
|
+
* accepted client connection gets its own pair of pipes (client->server,
|
|
103
|
+
* server->client); `fault` (settable any time via {@link setFault}) governs
|
|
104
|
+
* both pipes of every connection accepted while it's set — matching how a
|
|
105
|
+
* real network fault (a flaky link, a saturated NAT table) affects whatever
|
|
106
|
+
* is talking over it, not just connections opened after the fault started.
|
|
107
|
+
*/
|
|
108
|
+
export class WsFaultProxy {
|
|
109
|
+
targetHost;
|
|
110
|
+
targetPort;
|
|
111
|
+
fault;
|
|
112
|
+
server = null;
|
|
113
|
+
connections = new Set();
|
|
114
|
+
port = 0;
|
|
115
|
+
constructor(targetHost, targetPort, fault = null) {
|
|
116
|
+
this.targetHost = targetHost;
|
|
117
|
+
this.targetPort = targetPort;
|
|
118
|
+
this.fault = fault;
|
|
119
|
+
}
|
|
120
|
+
setFault(fault) {
|
|
121
|
+
this.fault = fault;
|
|
122
|
+
}
|
|
123
|
+
async listen() {
|
|
124
|
+
this.server = createServer((clientSocket) => this.handleConnection(clientSocket));
|
|
125
|
+
await new Promise((resolve, reject) => {
|
|
126
|
+
this.server.once("error", reject);
|
|
127
|
+
this.server.listen(0, "127.0.0.1", () => resolve());
|
|
128
|
+
});
|
|
129
|
+
const address = this.server.address();
|
|
130
|
+
this.port = typeof address === "object" && address !== null ? address.port : 0;
|
|
131
|
+
return this.port;
|
|
132
|
+
}
|
|
133
|
+
getPort() {
|
|
134
|
+
return this.port;
|
|
135
|
+
}
|
|
136
|
+
async close() {
|
|
137
|
+
for (const conn of this.connections) {
|
|
138
|
+
conn.clientSocket.destroy();
|
|
139
|
+
conn.serverSocket.destroy();
|
|
140
|
+
}
|
|
141
|
+
this.connections.clear();
|
|
142
|
+
const server = this.server;
|
|
143
|
+
this.server = null;
|
|
144
|
+
if (server) {
|
|
145
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Forces every currently-open connection's pair dead — used by the
|
|
150
|
+
* client-reconnect-mid-run journey to sever a client mid-run outside the
|
|
151
|
+
* declarative fault matrix. `"reset"` destroys both sockets (an ordinary
|
|
152
|
+
* TCP RST/FIN teardown); `"half-open"` stops all forwarding and pauses
|
|
153
|
+
* both sockets' reads without closing either — the same net-level black
|
|
154
|
+
* hole `drop-no-fin` produces, just triggered on demand instead of after
|
|
155
|
+
* a frame count.
|
|
156
|
+
*/
|
|
157
|
+
killConnections(mode) {
|
|
158
|
+
for (const conn of this.connections) {
|
|
159
|
+
if (mode === "reset") {
|
|
160
|
+
conn.clientSocket.destroy();
|
|
161
|
+
conn.serverSocket.destroy();
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
conn.clientSocket.pause();
|
|
165
|
+
conn.serverSocket.pause();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
handleConnection(clientSocket) {
|
|
170
|
+
const serverSocket = connect(this.targetPort, this.targetHost);
|
|
171
|
+
const entry = { clientSocket, serverSocket };
|
|
172
|
+
this.connections.add(entry);
|
|
173
|
+
clientSocket.setNoDelay(true);
|
|
174
|
+
serverSocket.setNoDelay(true);
|
|
175
|
+
let cleaned = false;
|
|
176
|
+
const cleanup = () => {
|
|
177
|
+
if (cleaned)
|
|
178
|
+
return;
|
|
179
|
+
cleaned = true;
|
|
180
|
+
this.connections.delete(entry);
|
|
181
|
+
clientSocket.destroy();
|
|
182
|
+
serverSocket.destroy();
|
|
183
|
+
};
|
|
184
|
+
clientSocket.on("error", cleanup);
|
|
185
|
+
serverSocket.on("error", cleanup);
|
|
186
|
+
clientSocket.on("close", cleanup);
|
|
187
|
+
serverSocket.on("close", cleanup);
|
|
188
|
+
clientSocket.on("data", this.makePipe(clientSocket, serverSocket, "client-to-server"));
|
|
189
|
+
serverSocket.on("data", this.makePipe(serverSocket, clientSocket, "server-to-client"));
|
|
190
|
+
}
|
|
191
|
+
/** Builds the `data`-event handler for one direction of one connection.
|
|
192
|
+
* Per-pipe state (the frame counter, the one-shot flags below) lives in
|
|
193
|
+
* this closure, not on the proxy or the connection entry, so concurrent
|
|
194
|
+
* connections and the two directions of the same connection never share
|
|
195
|
+
* counters. */
|
|
196
|
+
makePipe(from, to, direction) {
|
|
197
|
+
const counter = new WsFrameCounter();
|
|
198
|
+
let blackHoled = false;
|
|
199
|
+
let reorderPending = null;
|
|
200
|
+
// The very first bytes in either direction are the plain-text HTTP
|
|
201
|
+
// Upgrade request/response, not a WS frame at all — `WsFrameCounter`'s
|
|
202
|
+
// frame-header parser applied to arbitrary HTTP header bytes produces a
|
|
203
|
+
// garbage "frame length" that usually resolves within the header block
|
|
204
|
+
// itself, silently arming a fault (and, for `drop-no-fin`/`stall-reads`,
|
|
205
|
+
// swallowing the 101 response before the client ever sees it) during
|
|
206
|
+
// the handshake instead of the application traffic the fault is meant to
|
|
207
|
+
// target. Every fault kind is a no-op (plain pass-through, no counting)
|
|
208
|
+
// until this direction's own handshake terminator (`\r\n\r\n`) has been
|
|
209
|
+
// seen; a connection whose handshake never terminates within 16 KiB
|
|
210
|
+
// (never happens for this harness's own client/server) falls back to
|
|
211
|
+
// fault-active rather than hanging the scan forever.
|
|
212
|
+
let handshakeDone = false;
|
|
213
|
+
let handshakeScan = Buffer.alloc(0);
|
|
214
|
+
return (chunk) => {
|
|
215
|
+
if (blackHoled)
|
|
216
|
+
return;
|
|
217
|
+
if (!handshakeDone) {
|
|
218
|
+
to.write(chunk);
|
|
219
|
+
handshakeScan =
|
|
220
|
+
handshakeScan.length > 0 ? Buffer.concat([handshakeScan, chunk]) : chunk;
|
|
221
|
+
if (handshakeScan.includes("\r\n\r\n") || handshakeScan.length > 16384) {
|
|
222
|
+
handshakeDone = true;
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const fault = this.fault;
|
|
227
|
+
if (!fault || direction !== relevantDirection(fault.kind)) {
|
|
228
|
+
to.write(chunk);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
counter.feed(chunk);
|
|
232
|
+
const armed = counter.count > (fault.afterFrames ?? 0);
|
|
233
|
+
switch (fault.kind) {
|
|
234
|
+
case "drop-no-fin":
|
|
235
|
+
// Half-open: once armed, never forward and never close either
|
|
236
|
+
// socket — a live-looking connection nothing moves on.
|
|
237
|
+
if (armed) {
|
|
238
|
+
blackHoled = true;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
to.write(chunk);
|
|
242
|
+
return;
|
|
243
|
+
case "abrupt-close":
|
|
244
|
+
// Forward the frame that arms the fault (the run_job command
|
|
245
|
+
// itself must actually reach the server), then hang up hard —
|
|
246
|
+
// no FIN, no close frame — right after.
|
|
247
|
+
to.write(chunk);
|
|
248
|
+
if (armed) {
|
|
249
|
+
blackHoled = true;
|
|
250
|
+
from.destroy();
|
|
251
|
+
to.destroy();
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
case "stall-reads":
|
|
255
|
+
// Stop reading from `from` (the server side) once armed — its
|
|
256
|
+
// socket buffer, then its own send buffer, backs up because
|
|
257
|
+
// nothing drains it, which is the drain-timeout path's trigger.
|
|
258
|
+
// The client-facing socket is untouched: any already-forwarded
|
|
259
|
+
// bytes remain delivered.
|
|
260
|
+
if (armed) {
|
|
261
|
+
blackHoled = true;
|
|
262
|
+
from.pause();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
to.write(chunk);
|
|
266
|
+
return;
|
|
267
|
+
case "delay":
|
|
268
|
+
setTimeout(() => {
|
|
269
|
+
if (!to.destroyed)
|
|
270
|
+
to.write(chunk);
|
|
271
|
+
}, fault.delayMs ?? 200);
|
|
272
|
+
return;
|
|
273
|
+
case "fragment":
|
|
274
|
+
writeFragmented(to, chunk, fault.chunkBytes ?? 4);
|
|
275
|
+
return;
|
|
276
|
+
case "reorder":
|
|
277
|
+
// Swap exactly one adjacent pair once armed, then resume in-order
|
|
278
|
+
// forwarding — enough to exercise a client's/server's tolerance
|
|
279
|
+
// for a single out-of-order delivery without permanently
|
|
280
|
+
// reordering the whole stream.
|
|
281
|
+
if (!armed) {
|
|
282
|
+
to.write(chunk);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (reorderPending === null) {
|
|
286
|
+
reorderPending = chunk;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// Synchronous double-write, not `setImmediate`-deferred — same
|
|
290
|
+
// interleaving hazard `writeFragmented` has: a deferred write can
|
|
291
|
+
// still be pending when the next `data` event's own writes land,
|
|
292
|
+
// putting bytes on `to` out of the order this fault itself
|
|
293
|
+
// intended.
|
|
294
|
+
{
|
|
295
|
+
const previous = reorderPending;
|
|
296
|
+
reorderPending = null;
|
|
297
|
+
to.write(chunk);
|
|
298
|
+
to.write(previous);
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
//# sourceMappingURL=net-proxy.js.map
|