@openparachute/hub 0.7.6-rc.1 → 0.7.6-rc.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.6-rc.1",
3
+ "version": "0.7.6-rc.3",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -753,3 +753,134 @@ describe("doctor --fix — canonical-port repair (confirm-gated, idempotent, non
753
753
  }
754
754
  });
755
755
  });
756
+
757
+ describe("doctor — loopback-hijack check (hub#737)", () => {
758
+ test("no hub-instance.json → PASS (benign; the Hub check owns 'down') — #717", async () => {
759
+ const h = makeHarness();
760
+ try {
761
+ seedCurrentManifest(h.manifestPath);
762
+ seedOperatorToken(h.configDir);
763
+ // No instance file seeded, no seams overridden — defaults read the empty
764
+ // sandbox and short-circuit before any real network/lsof.
765
+ const { code, checks } = await runDoctor(h, healthyDeps());
766
+ expect(byName(checks, "loopback-hijack")?.status).toBe("pass");
767
+ expect(code).toBe(0);
768
+ } finally {
769
+ h.cleanup();
770
+ }
771
+ });
772
+
773
+ test("loopback nonce matches ours + single listener → PASS", async () => {
774
+ const h = makeHarness();
775
+ try {
776
+ seedCurrentManifest(h.manifestPath);
777
+ seedOperatorToken(h.configDir);
778
+ const { code, checks } = await runDoctor(
779
+ h,
780
+ healthyDeps({
781
+ readInstanceRecord: () => ({ instance: "n1", pid: 1, port: 1939, startedAt: "" }),
782
+ probeLoopbackInstance: async () => ({ reachable: true, status: 200, instance: "n1" }),
783
+ countHubListeners: () => 1,
784
+ }),
785
+ );
786
+ const c = byName(checks, "loopback-hijack");
787
+ expect(c?.status).toBe("pass");
788
+ expect(c?.detail).toContain("instance nonce");
789
+ expect(code).toBe(0);
790
+ } finally {
791
+ h.cleanup();
792
+ }
793
+ });
794
+
795
+ test("loopback nonce MISMATCH → FAIL with lsof/orb remediation + incident ref", async () => {
796
+ const h = makeHarness();
797
+ try {
798
+ seedCurrentManifest(h.manifestPath);
799
+ seedOperatorToken(h.configDir);
800
+ const { code, checks } = await runDoctor(
801
+ h,
802
+ healthyDeps({
803
+ readInstanceRecord: () => ({ instance: "ours", pid: 1, port: 1939, startedAt: "" }),
804
+ probeLoopbackInstance: async () => ({
805
+ reachable: true,
806
+ status: 200,
807
+ instance: "rogue-hub",
808
+ }),
809
+ countHubListeners: () => 2,
810
+ }),
811
+ );
812
+ const c = byName(checks, "loopback-hijack");
813
+ expect(c?.status).toBe("fail");
814
+ expect(c?.detail).toContain("rogue-hub");
815
+ expect(c?.detail).toContain("2 listeners");
816
+ expect(c?.detail).toContain("hub#737");
817
+ expect(c?.fix).toContain("lsof -nP -iTCP:1939 -sTCP:LISTEN");
818
+ expect(c?.fix).toContain("orb list");
819
+ expect(code).toBe(1);
820
+ } finally {
821
+ h.cleanup();
822
+ }
823
+ });
824
+
825
+ test("foreign process answering with NO nonce → FAIL (the OrbStack container-hub shape)", async () => {
826
+ const h = makeHarness();
827
+ try {
828
+ seedCurrentManifest(h.manifestPath);
829
+ seedOperatorToken(h.configDir);
830
+ const { checks } = await runDoctor(
831
+ h,
832
+ healthyDeps({
833
+ readInstanceRecord: () => ({ instance: "ours", pid: 1, port: 1939, startedAt: "" }),
834
+ probeLoopbackInstance: async () => ({ reachable: true, status: 200 }),
835
+ countHubListeners: () => undefined, // lsof indeterminate — still FAILs on the nonce alone
836
+ }),
837
+ );
838
+ const c = byName(checks, "loopback-hijack");
839
+ expect(c?.status).toBe("fail");
840
+ expect(c?.detail).toContain("foreign process");
841
+ } finally {
842
+ h.cleanup();
843
+ }
844
+ });
845
+
846
+ test("nonce matches but a SECOND listener exists → WARN (latent shadow, not FAIL)", async () => {
847
+ const h = makeHarness();
848
+ try {
849
+ seedCurrentManifest(h.manifestPath);
850
+ seedOperatorToken(h.configDir);
851
+ const { code, checks } = await runDoctor(
852
+ h,
853
+ healthyDeps({
854
+ readInstanceRecord: () => ({ instance: "n1", pid: 1, port: 1939, startedAt: "" }),
855
+ probeLoopbackInstance: async () => ({ reachable: true, status: 200, instance: "n1" }),
856
+ countHubListeners: () => 2,
857
+ }),
858
+ );
859
+ const c = byName(checks, "loopback-hijack");
860
+ expect(c?.status).toBe("warn");
861
+ expect(c?.detail).toContain("2 listeners");
862
+ // WARN never fails the exit code.
863
+ expect(code).toBe(0);
864
+ } finally {
865
+ h.cleanup();
866
+ }
867
+ });
868
+
869
+ test("record present but loopback unreachable → PASS (defers to the Hub check)", async () => {
870
+ const h = makeHarness();
871
+ try {
872
+ seedCurrentManifest(h.manifestPath);
873
+ seedOperatorToken(h.configDir);
874
+ const { checks } = await runDoctor(
875
+ h,
876
+ healthyDeps({
877
+ readInstanceRecord: () => ({ instance: "n1", pid: 1, port: 1939, startedAt: "" }),
878
+ probeLoopbackInstance: async () => ({ reachable: false }),
879
+ }),
880
+ );
881
+ expect(byName(checks, "loopback-hijack")?.status).toBe("pass");
882
+ } finally {
883
+ h.cleanup();
884
+ }
885
+ });
886
+ });
@@ -0,0 +1,297 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ type HubInstanceRecord,
7
+ type LoopbackProbe,
8
+ type SelfProbeState,
9
+ armHubSelfProbe,
10
+ classifyLoopback,
11
+ clearHubInstanceFile,
12
+ generateInstanceNonce,
13
+ hijackAlertMessage,
14
+ hubInstancePath,
15
+ probeLoopbackInstance,
16
+ readHubInstanceFile,
17
+ writeHubInstanceFile,
18
+ } from "../hub-instance.ts";
19
+
20
+ /**
21
+ * hub#737 loopback-hijack detection. The nonce file is the linchpin — an
22
+ * external process learns THIS hub's true identity from disk, then compares it
23
+ * to what a (possibly hijacked) loopback /health returns. Every side effect
24
+ * (fs, network) is exercised against a tmp dir / injected fetch.
25
+ */
26
+
27
+ function makeDir(): { configDir: string; cleanup: () => void } {
28
+ const dir = mkdtempSync(join(tmpdir(), "parachute-instance-test-"));
29
+ return { configDir: dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
30
+ }
31
+
32
+ function record(over: Partial<HubInstanceRecord> = {}): HubInstanceRecord {
33
+ return {
34
+ instance: "nonce-1",
35
+ pid: 4242,
36
+ port: 1939,
37
+ startedAt: "2026-07-02T00:00:00.000Z",
38
+ ...over,
39
+ };
40
+ }
41
+
42
+ describe("nonce + instance file", () => {
43
+ test("generateInstanceNonce yields distinct UUID-shaped values", () => {
44
+ const a = generateInstanceNonce();
45
+ const b = generateInstanceNonce();
46
+ expect(a).not.toBe(b);
47
+ expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
48
+ });
49
+
50
+ test("write → read round-trips the record (incl. selfProbe)", () => {
51
+ const { configDir, cleanup } = makeDir();
52
+ try {
53
+ const sp: SelfProbeState = {
54
+ status: "ok",
55
+ checkedAt: "2026-07-02T00:01:00.000Z",
56
+ };
57
+ expect(writeHubInstanceFile(record({ selfProbe: sp }), { configDir })).toBe(true);
58
+ const back = readHubInstanceFile(configDir);
59
+ expect(back?.instance).toBe("nonce-1");
60
+ expect(back?.port).toBe(1939);
61
+ expect(back?.pid).toBe(4242);
62
+ expect(back?.selfProbe?.status).toBe("ok");
63
+ // Written 0644 (world-readable diagnostic aid, not a secret).
64
+ const raw = readFileSync(hubInstancePath(configDir), "utf8");
65
+ expect(raw.endsWith("\n")).toBe(true);
66
+ } finally {
67
+ cleanup();
68
+ }
69
+ });
70
+
71
+ test("read returns null on absent / malformed / instance-less files", () => {
72
+ const { configDir, cleanup } = makeDir();
73
+ try {
74
+ expect(readHubInstanceFile(configDir)).toBeNull(); // absent
75
+ writeFileSync(hubInstancePath(configDir), "not json{");
76
+ expect(readHubInstanceFile(configDir)).toBeNull(); // malformed
77
+ writeFileSync(hubInstancePath(configDir), JSON.stringify({ port: 1939 }));
78
+ expect(readHubInstanceFile(configDir)).toBeNull(); // no instance field
79
+ } finally {
80
+ cleanup();
81
+ }
82
+ });
83
+
84
+ test("clearHubInstanceFile removes the file (graceful-shutdown hygiene) + no-ops when absent", () => {
85
+ const { configDir, cleanup } = makeDir();
86
+ try {
87
+ writeHubInstanceFile(record(), { configDir });
88
+ expect(readHubInstanceFile(configDir)).not.toBeNull();
89
+ clearHubInstanceFile(configDir);
90
+ expect(readHubInstanceFile(configDir)).toBeNull();
91
+ // Idempotent — clearing an already-absent file must not throw.
92
+ expect(() => clearHubInstanceFile(configDir)).not.toThrow();
93
+ } finally {
94
+ cleanup();
95
+ }
96
+ });
97
+
98
+ test("write failure is swallowed (returns false, logs) — never throws", () => {
99
+ const logs: string[] = [];
100
+ // An un-writable path (a file where a dir must be) forces the mkdir/write to
101
+ // fail; the helper must degrade, not throw.
102
+ const { configDir, cleanup } = makeDir();
103
+ try {
104
+ const filePath = join(configDir, "blocker");
105
+ writeFileSync(filePath, "x");
106
+ const ok = writeHubInstanceFile(record(), {
107
+ configDir: join(filePath, "nested"),
108
+ log: (l) => logs.push(l),
109
+ });
110
+ expect(ok).toBe(false);
111
+ expect(logs.length).toBe(1);
112
+ } finally {
113
+ cleanup();
114
+ }
115
+ });
116
+ });
117
+
118
+ describe("classifyLoopback", () => {
119
+ test("unreachable when the probe didn't answer", () => {
120
+ expect(classifyLoopback("n1", { reachable: false })).toBe("unreachable");
121
+ });
122
+ test("ok when the returned instance matches ours", () => {
123
+ expect(classifyLoopback("n1", { reachable: true, status: 200, instance: "n1" })).toBe("ok");
124
+ });
125
+ test("hijacked when a different instance answers", () => {
126
+ expect(classifyLoopback("n1", { reachable: true, status: 200, instance: "n2" })).toBe(
127
+ "hijacked",
128
+ );
129
+ });
130
+ test("hijacked when a reachable process carries NO instance (old/foreign hub)", () => {
131
+ expect(classifyLoopback("n1", { reachable: true, status: 200 })).toBe("hijacked");
132
+ });
133
+ });
134
+
135
+ describe("probeLoopbackInstance", () => {
136
+ test("extracts instance + isHub from a well-formed /health body", async () => {
137
+ const fetchFn = (async () =>
138
+ new Response(JSON.stringify({ service: "parachute-hub", instance: "xyz" }), {
139
+ status: 200,
140
+ })) as unknown as typeof fetch;
141
+ const out = await probeLoopbackInstance(1939, { fetchFn });
142
+ expect(out).toEqual({ reachable: true, status: 200, instance: "xyz", isHub: true });
143
+ });
144
+
145
+ test("reachable-but-junk body → reachable with no instance (foreign server shape)", async () => {
146
+ const fetchFn = (async () =>
147
+ new Response("<html>not a hub</html>", { status: 200 })) as unknown as typeof fetch;
148
+ const out = await probeLoopbackInstance(1939, { fetchFn });
149
+ expect(out.reachable).toBe(true);
150
+ expect(out.instance).toBeUndefined();
151
+ expect(out.isHub).toBeUndefined();
152
+ });
153
+
154
+ test("network error → not reachable, never throws", async () => {
155
+ const fetchFn = (async () => {
156
+ throw new Error("ECONNREFUSED");
157
+ }) as unknown as typeof fetch;
158
+ const out = await probeLoopbackInstance(1939, { fetchFn });
159
+ expect(out).toEqual({ reachable: false });
160
+ });
161
+ });
162
+
163
+ describe("armHubSelfProbe", () => {
164
+ /** A probe stub that returns a fixed LoopbackProbe. */
165
+ function fixedProbe(p: LoopbackProbe): (port: number) => Promise<LoopbackProbe> {
166
+ return async () => p;
167
+ }
168
+
169
+ test("ok verdict: writes state, stays quiet (no loud log)", async () => {
170
+ const logs: string[] = [];
171
+ const states: SelfProbeState[] = [];
172
+ const probe = armHubSelfProbe(
173
+ { port: 1939, nonce: "n1", record: record() },
174
+ {
175
+ probe: fixedProbe({ reachable: true, status: 200, instance: "n1" }),
176
+ writeState: (s) => states.push(s),
177
+ log: (l) => logs.push(l),
178
+ setIntervalFn: () => 0,
179
+ },
180
+ );
181
+ const verdict = await probe.probeOnce();
182
+ expect(verdict).toBe("ok");
183
+ expect(logs).toEqual([]);
184
+ expect(states.at(-1)?.status).toBe("ok");
185
+ });
186
+
187
+ test("hijacked verdict: LOUD alert on EVERY probe + observedInstance persisted", async () => {
188
+ const logs: string[] = [];
189
+ const states: SelfProbeState[] = [];
190
+ const probe = armHubSelfProbe(
191
+ { port: 1939, nonce: "n1", record: record() },
192
+ {
193
+ probe: fixedProbe({ reachable: true, status: 200, instance: "rogue-9" }),
194
+ writeState: (s) => states.push(s),
195
+ log: (l) => logs.push(l),
196
+ setIntervalFn: () => 0,
197
+ },
198
+ );
199
+ expect(await probe.probeOnce()).toBe("hijacked");
200
+ expect(await probe.probeOnce()).toBe("hijacked");
201
+ // Repeated verbatim — a hijack is a standing emergency, not a one-shot.
202
+ expect(logs.length).toBe(2);
203
+ expect(logs[0]).toContain("LOOPBACK HIJACK");
204
+ expect(logs[0]).toContain("lsof -nP -iTCP:1939");
205
+ expect(states.at(-1)?.observedInstance).toBe("rogue-9");
206
+ expect(states.at(-1)?.status).toBe("hijacked");
207
+ });
208
+
209
+ test("unreachable verdict: logs ONCE per state change, not per tick", async () => {
210
+ const logs: string[] = [];
211
+ const probe = armHubSelfProbe(
212
+ { port: 1939, nonce: "n1", record: record() },
213
+ {
214
+ probe: fixedProbe({ reachable: false }),
215
+ writeState: () => {},
216
+ log: (l) => logs.push(l),
217
+ setIntervalFn: () => 0,
218
+ },
219
+ );
220
+ await probe.probeOnce();
221
+ await probe.probeOnce();
222
+ expect(logs.length).toBe(1); // second consecutive unreachable is silent
223
+ expect(logs[0]).toContain("did not answer");
224
+ });
225
+
226
+ test("recovery: hijacked → ok logs a single 'cleared' line", async () => {
227
+ const logs: string[] = [];
228
+ let current: LoopbackProbe = { reachable: true, status: 200, instance: "rogue" };
229
+ const probe = armHubSelfProbe(
230
+ { port: 1939, nonce: "n1", record: record() },
231
+ {
232
+ probe: async () => current,
233
+ writeState: () => {},
234
+ log: (l) => logs.push(l),
235
+ setIntervalFn: () => 0,
236
+ },
237
+ );
238
+ expect(await probe.probeOnce()).toBe("hijacked");
239
+ current = { reachable: true, status: 200, instance: "n1" };
240
+ expect(await probe.probeOnce()).toBe("ok");
241
+ expect(logs.length).toBe(2);
242
+ expect(logs[1]).toContain("Hijack cleared");
243
+ });
244
+
245
+ test("stop() clears the interval handle", () => {
246
+ let cleared: unknown;
247
+ const probe = armHubSelfProbe(
248
+ { port: 1939, nonce: "n1", record: record() },
249
+ {
250
+ probe: fixedProbe({ reachable: true, instance: "n1" }),
251
+ writeState: () => {},
252
+ setIntervalFn: () => "the-handle",
253
+ clearIntervalFn: (h) => {
254
+ cleared = h;
255
+ },
256
+ },
257
+ );
258
+ probe.stop();
259
+ expect(cleared).toBe("the-handle");
260
+ });
261
+
262
+ test("default writeState patches selfProbe into the real instance file", async () => {
263
+ const { configDir, cleanup } = makeDir();
264
+ try {
265
+ const rec = record();
266
+ writeHubInstanceFile(rec, { configDir });
267
+ const probe = armHubSelfProbe(
268
+ { port: 1939, nonce: "n1", record: rec, configDir },
269
+ {
270
+ probe: fixedProbe({ reachable: true, status: 200, instance: "someone-else" }),
271
+ log: () => {},
272
+ setIntervalFn: () => 0,
273
+ },
274
+ );
275
+ await probe.probeOnce();
276
+ const back = readHubInstanceFile(configDir);
277
+ expect(back?.selfProbe?.status).toBe("hijacked");
278
+ expect(back?.instance).toBe("nonce-1"); // base record preserved
279
+ } finally {
280
+ cleanup();
281
+ }
282
+ });
283
+ });
284
+
285
+ describe("hijackAlertMessage", () => {
286
+ test("names the observed instance + the diagnosis commands + incident ref", () => {
287
+ const msg = hijackAlertMessage(1939, "rogue-42");
288
+ expect(msg).toContain("instance=rogue-42");
289
+ expect(msg).toContain("lsof -nP -iTCP:1939 -sTCP:LISTEN");
290
+ expect(msg).toContain("orb list");
291
+ expect(msg).toContain("hub#737");
292
+ });
293
+ test("degrades gracefully when the foreign process carries no nonce", () => {
294
+ const msg = hijackAlertMessage(1939);
295
+ expect(msg).toContain("no hub instance nonce");
296
+ });
297
+ });
@@ -15,6 +15,7 @@ import {
15
15
  layerOf,
16
16
  parseArgs,
17
17
  resolveClientIp,
18
+ stripHopByHopHeaders,
18
19
  } from "../hub-server.ts";
19
20
  import { setNotesRedirectDisabled } from "../hub-settings.ts";
20
21
  import { clearNotesRedirectLogState } from "../notes-redirect.ts";
@@ -139,6 +140,27 @@ describe("hubFetch routing", () => {
139
140
  }
140
141
  });
141
142
 
143
+ test("/health echoes the instance nonce when set, omits it when absent (hub#737)", async () => {
144
+ const h = makeHarness();
145
+ try {
146
+ // With a nonce threaded in, /health carries it as `instance`.
147
+ const withNonce = (await (
148
+ await hubFetch(h.dir, { instanceNonce: "nonce-abc-123" })(req("/health"))
149
+ ).json()) as { service: string; instance?: string };
150
+ expect(withNonce.service).toBe("parachute-hub");
151
+ expect(withNonce.instance).toBe("nonce-abc-123");
152
+
153
+ // Without one (DB-less / dev entrypoint), the field is omitted — additive,
154
+ // so no strict /health consumer breaks.
155
+ const withoutNonce = (await (await hubFetch(h.dir, {})(req("/health"))).json()) as {
156
+ instance?: string;
157
+ };
158
+ expect(withoutNonce.instance).toBeUndefined();
159
+ } finally {
160
+ h.cleanup();
161
+ }
162
+ });
163
+
142
164
  test("/health surfaces db:error:path-gone when the proactive probe sees a wiped path (#610)", async () => {
143
165
  // The ghost-fd lie: SELECT 1 still succeeds against the unlinked inode, so
144
166
  // probeDbLiveness alone would report ok. probeDbPath stat()s the PATH and
@@ -5963,6 +5985,153 @@ describe("substrate trust headers — X-Parachute-Layer / X-Parachute-Client-IP
5963
5985
  });
5964
5986
  });
5965
5987
 
5988
+ describe("stripHopByHopHeaders (hub#738)", () => {
5989
+ test("drops the standard hop-by-hop set", () => {
5990
+ const h = new Headers({
5991
+ connection: "keep-alive",
5992
+ "keep-alive": "timeout=5",
5993
+ "proxy-authorization": "Basic xxx",
5994
+ te: "trailers",
5995
+ "transfer-encoding": "chunked",
5996
+ upgrade: "h2c",
5997
+ authorization: "Bearer keep-me",
5998
+ "content-type": "application/json",
5999
+ });
6000
+ stripHopByHopHeaders(h);
6001
+ expect(h.has("connection")).toBe(false);
6002
+ expect(h.has("keep-alive")).toBe(false);
6003
+ expect(h.has("proxy-authorization")).toBe(false);
6004
+ expect(h.has("te")).toBe(false);
6005
+ expect(h.has("transfer-encoding")).toBe(false);
6006
+ expect(h.has("upgrade")).toBe(false);
6007
+ // End-to-end headers survive.
6008
+ expect(h.get("authorization")).toBe("Bearer keep-me");
6009
+ expect(h.get("content-type")).toBe("application/json");
6010
+ });
6011
+
6012
+ test("drops headers NAMED in the Connection field-value (RFC 9110 §7.6.1)", () => {
6013
+ const h = new Headers({
6014
+ connection: "close, X-Custom-Hop",
6015
+ "x-custom-hop": "should-go",
6016
+ "x-real-header": "should-stay",
6017
+ });
6018
+ stripHopByHopHeaders(h);
6019
+ expect(h.has("connection")).toBe(false);
6020
+ expect(h.has("x-custom-hop")).toBe(false);
6021
+ expect(h.get("x-real-header")).toBe("should-stay");
6022
+ });
6023
+ });
6024
+
6025
+ describe("proxy upstream keep-alive (hub#738)", () => {
6026
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
6027
+
6028
+ function writeUpstreamService(h: Harness, port: number): void {
6029
+ writeManifest(
6030
+ {
6031
+ services: [
6032
+ {
6033
+ name: "keepalive-svc",
6034
+ port,
6035
+ paths: ["/keepalive-svc"],
6036
+ health: "/keepalive-svc/health",
6037
+ version: "0.1.0",
6038
+ },
6039
+ ],
6040
+ },
6041
+ h.manifestPath,
6042
+ );
6043
+ }
6044
+
6045
+ test("200 proxied requests reuse a handful of upstream sockets even when the client sends Connection: close", async () => {
6046
+ // Regression for hub#738: the proxy forwarded the client's hop-by-hop
6047
+ // `Connection: close` verbatim, so Bun opened a FRESH ephemeral socket per
6048
+ // proxied request instead of reusing its per-origin pool → 16k+ TIME_WAIT
6049
+ // → host-wide port exhaustion (the 2026-07-02 incident, amplifying an agent
6050
+ // reconciler 401 loop). We count the distinct client (ephemeral) ports the
6051
+ // upstream sees: a working pool reuses one (occasionally a couple); the bug
6052
+ // produced ~N distinct ports.
6053
+ const seenPorts = new Set<number>();
6054
+ const upstream = Bun.serve({
6055
+ port: 0,
6056
+ hostname: "127.0.0.1",
6057
+ fetch: (r, server) => {
6058
+ const ip = server.requestIP(r);
6059
+ if (ip) seenPorts.add(ip.port);
6060
+ return new Response("unauthorized", { status: 401 }); // the incident's 401 loop
6061
+ },
6062
+ });
6063
+ const h = makeHarness();
6064
+ try {
6065
+ writeUpstreamService(h, upstream.port as number);
6066
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
6067
+ const N = 200;
6068
+ for (let i = 0; i < N; i++) {
6069
+ const res = await fetcher(
6070
+ req("/keepalive-svc/x", { headers: { connection: "close" } }),
6071
+ fakeServer("127.0.0.1"),
6072
+ );
6073
+ await res.arrayBuffer(); // fully drain so the socket returns to the pool
6074
+ }
6075
+ expect(seenPorts.size).toBeLessThanOrEqual(8);
6076
+ } finally {
6077
+ upstream.stop(true);
6078
+ h.cleanup();
6079
+ }
6080
+ });
6081
+
6082
+ test("a client hang-up mid-stream aborts the upstream fetch (socket released, not left streaming to nobody)", async () => {
6083
+ // Without forwarding req.signal, an aborted client left the upstream
6084
+ // streaming its whole body to a gone client and holding the socket. We
6085
+ // forward the signal so the upstream is torn down (cancel() fires or the
6086
+ // enqueue loop errors) instead of running to completion.
6087
+ let tornDown = false;
6088
+ const upstream = Bun.serve({
6089
+ port: 0,
6090
+ hostname: "127.0.0.1",
6091
+ fetch: () =>
6092
+ new Response(
6093
+ new ReadableStream({
6094
+ async start(controller) {
6095
+ try {
6096
+ for (let i = 0; i < 40; i++) {
6097
+ controller.enqueue(new TextEncoder().encode(`chunk ${i}\n`));
6098
+ await Bun.sleep(10);
6099
+ }
6100
+ controller.close();
6101
+ } catch {
6102
+ tornDown = true; // enqueue threw after the peer dropped
6103
+ }
6104
+ },
6105
+ cancel() {
6106
+ tornDown = true; // Bun cancelled the source on disconnect
6107
+ },
6108
+ }),
6109
+ { headers: { "content-type": "text/plain" } },
6110
+ ),
6111
+ });
6112
+ const h = makeHarness();
6113
+ try {
6114
+ writeUpstreamService(h, upstream.port as number);
6115
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
6116
+ const ac = new AbortController();
6117
+ const res = await fetcher(
6118
+ req("/keepalive-svc/stream", { signal: ac.signal }),
6119
+ fakeServer("127.0.0.1"),
6120
+ );
6121
+ const reader = res.body!.getReader();
6122
+ await reader.read(); // consume one chunk, then hang up
6123
+ ac.abort();
6124
+ // Full stream is 40 * 10ms = 400ms; wait past it. With the signal
6125
+ // forwarded the upstream tears down early; without, it runs to close.
6126
+ await Bun.sleep(700);
6127
+ expect(tornDown).toBe(true);
6128
+ } finally {
6129
+ upstream.stop(true);
6130
+ h.cleanup();
6131
+ }
6132
+ });
6133
+ });
6134
+
5966
6135
  describe("resolveClientIp (H2)", () => {
5967
6136
  test("CF-Connecting-IP wins over X-Forwarded-For and peer", () => {
5968
6137
  const r = req("/", {