@specific.dev/spectest 0.68.0 → 0.70.0

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/src/daemon.ts CHANGED
@@ -116,6 +116,7 @@ import {
116
116
  certCovers as hostmatchCertCovers,
117
117
  hostWithoutPort,
118
118
  matchRoute,
119
+ selectCertName,
119
120
  wildcardCoversHost,
120
121
  wildcardSuffix,
121
122
  } from "./harness/hostmatch.js";
@@ -123,7 +124,6 @@ import {
123
124
  INGRESS_HTTPS_PORT,
124
125
  INGRESS_HTTP_PORT,
125
126
  bindRoute,
126
- certEntries,
127
127
  clearTables,
128
128
  emptyTables,
129
129
  planBind,
@@ -132,6 +132,7 @@ import {
132
132
  unbindRoute,
133
133
  type Route as IngressRoute,
134
134
  } from "./harness/ingress-table.js";
135
+ import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
135
136
  import { runContainerArgs } from "./harness/container-run.js";
136
137
  import {
137
138
  assertAbsolute,
@@ -1769,42 +1770,29 @@ const INGRESS_HTTP_SERVERS = new Map<number, any>();
1769
1770
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1770
1771
  const INGRESS_HTTPS_SERVERS = new Map<number, any>();
1771
1772
  /**
1772
- * Servers replaced by a rebind and now draining. On Bun 1.3.14 a request
1773
- * arriving on a kept-alive connection of a `stop(false)`-drained server
1774
- * dispatches into freed per-server state and can SEGFAULT the process
1775
- * (use-after-free class fixed upstream by oven-sh/bun#36790, first in Bun
1776
- * 1.4.0; observed here as `panic: Segmentation fault at address 0xA` in
1777
- * `server.zig onRequestFor` on ~2-3 % of runtime-TLS rebinds). Until the
1778
- * Bun bump lands, shrink the number of requests a drained server can ever
1779
- * see: every response it still serves carries `Connection: close` (one
1780
- * more request per surviving connection, not unlimited), and a grace timer
1781
- * force-closes whatever is left ({@link REBIND_DRAIN_GRACE_MS}).
1773
+ * The :443 TLS terminator, once per harness process.
1774
+ *
1775
+ * There is deliberately no draining machinery beside it any more. :443
1776
+ * used to be a `Bun.serve` that had to be swapped for every new
1777
+ * certificate, and no swap can be made safe: `stop(false)` FINs an idle
1778
+ * kept-alive connection within 1-2 ms, so a client that wrote a request in
1779
+ * that instant failed with `SocketError: other side closed` — in whatever
1780
+ * unrelated test happened to be talking through the ingress.
1781
+ * {@link startTlsTerminator} chooses the leaf per handshake instead, so
1782
+ * the listener is bound once and lives as long as the project does.
1782
1783
  */
1783
- const DRAINING_INGRESS = new WeakSet<object>();
1784
- /** How long a drained listener may keep serving in-flight work before its
1785
- * remaining connections are force-closed. Long enough for a slow proxied
1786
- * response to finish, short enough to bound the 1.3.14 UAF window. */
1787
- const REBIND_DRAIN_GRACE_MS = 15_000;
1788
-
1789
- /** Stamp `Connection: close` on a response served by a draining listener so
1790
- * the kept-alive connection retires instead of lingering as a UAF trigger.
1791
- * Proxied responses can carry immutable headers; rewrap when needed. */
1792
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1793
- function withDrainClose(server: any, res: Response): Response {
1794
- if (!DRAINING_INGRESS.has(server)) return res;
1795
- try {
1796
- res.headers.set("connection", "close");
1797
- return res;
1798
- } catch {
1799
- const headers = new Headers(res.headers);
1800
- headers.set("connection", "close");
1801
- return new Response(res.body, {
1802
- status: res.status,
1803
- statusText: res.statusText,
1804
- headers,
1805
- });
1806
- }
1807
- }
1784
+ let HTTPS_TERMINATOR: TlsTerminator | undefined;
1785
+ /**
1786
+ * The in-flight first bind of :443, if one is running.
1787
+ *
1788
+ * {@link ensureHttpsIngress} is async where the swap it replaced was
1789
+ * synchronous, and that reintroduces an interleaving the old code could
1790
+ * not have: two `ctx.startService({ tls })` calls issued together by a
1791
+ * fake handler would both find :443 absent and both try to bind it, and
1792
+ * the loser gets EADDRINUSE — a provisioning failure in place of the
1793
+ * dropped connection this change exists to remove. One promise, shared.
1794
+ */
1795
+ let HTTPS_INGRESS_STARTING: Promise<void> | undefined;
1808
1796
  /**
1809
1797
  * The live ingress tables — per-port routes and the :443 SNI cert table.
1810
1798
  *
@@ -1852,6 +1840,12 @@ function stopIngressServers(): void {
1852
1840
  }
1853
1841
  }
1854
1842
  INGRESS_HTTPS_SERVERS.clear();
1843
+ // Force-closed for the same reason the listeners are: the containers
1844
+ // behind these routes are going away, so a connection still riding them
1845
+ // has nothing left to reach.
1846
+ HTTPS_TERMINATOR?.close();
1847
+ HTTPS_TERMINATOR = undefined;
1848
+ HTTPS_INGRESS_STARTING = undefined;
1855
1849
  clearTables(INGRESS);
1856
1850
  }
1857
1851
 
@@ -2089,7 +2083,7 @@ async function startIngress(): Promise<void> {
2089
2083
  console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
2090
2084
  }
2091
2085
  // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
2092
- if (INGRESS.certByHost.size > 0) rebindHttpsListener(Bun);
2086
+ if (INGRESS.certByHost.size > 0) await ensureHttpsIngress(Bun);
2093
2087
 
2094
2088
  // Seed the resolver's names registry: ingress hostnames (fakes, TLS
2095
2089
  // proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
@@ -2110,86 +2104,85 @@ function requireBun(): any {
2110
2104
  return Bun;
2111
2105
  }
2112
2106
 
2113
- /** Flatten the live SNI cert table into Bun's TLS-entry array. */
2114
- function tlsEntriesFromCerts(): Array<{ cert: string; key: string; serverName: string }> {
2115
- return certEntries(INGRESS);
2116
- }
2117
2107
 
2118
2108
  /**
2119
- * (Re)bind the :443 listener from the current cert table + route map.
2109
+ * Bring :443 up, once.
2120
2110
  *
2121
- * Bun fixes a server's TLS config at `Bun.serve` time — `reload()` accepts a
2122
- * new `tls` option and silently keeps serving the old certificates (measured
2123
- * on Bun 1.3.14: after reloading with a second SNI entry, the new hostname
2124
- * still gets the first one's leaf). So adding a certificate really does mean
2125
- * a second listener.
2111
+ * Two pieces: a plaintext `Bun.serve` on an ephemeral loopback port that
2112
+ * carries the HTTPS route table (and therefore all the routing, fake
2113
+ * dispatch, interception, reverse-proxying and `server.upgrade()`
2114
+ * WebSocket bridging that :80 already gets, unchanged), and the TLS
2115
+ * terminator in front of it on :443.
2126
2116
  *
2127
- * **Bind the new one before stopping the old one.** Doing it the other way
2128
- * round — which is what this used to do, with `stop(true)` — has two teeth:
2129
- * the force-close kills every established connection on :443, and the gap
2130
- * before the new listener binds refuses new ones. Neither is limited to the
2131
- * hostname being added; they hit all the unrelated traffic the listener is
2132
- * carrying. On a project that mints a certificate per provisioned database
2133
- * while deploys stream through the same port, that surfaced as the app under
2134
- * test dying with `SocketError: other side closed` or `ECONNREFUSED
2135
- * <gateway>:443` — a different test each run, and nothing pointing at
2136
- * ingress.
2117
+ * **This is idempotent, and that is the feature.** Adding a certificate
2118
+ * mutates {@link IngressTables.certByHost} and nothing else: the
2119
+ * terminator reads that table on every handshake, so the new hostname is
2120
+ * served by the next connection and no established connection is
2121
+ * disturbed. The listener this replaced had to be stopped and re-served
2122
+ * for each new certificate, and every such swap severed the idle
2123
+ * kept-alive connections it was carrying — see
2124
+ * `harness/tls-terminator.ts` for the measurements and the history.
2137
2125
  *
2138
- * Overlapping the two needs SO_REUSEPORT on both sockets ({@link
2139
- * bindIngressServer} sets it unconditionally for that reason), after which
2140
- * the old listener is drained with `stop(false)` so requests in flight
2141
- * finish. During the overlap the kernel may hand a new connection to either
2142
- * socket, which is safe: the only hostname the two disagree about is the one
2143
- * being added, and it does not resolve until the caller writes the names
2144
- * registry after this returns.
2126
+ * The route `Map` is the persistent module object, so the plaintext
2127
+ * server closes over the same table and later route additions need no
2128
+ * restart either.
2145
2129
  *
2146
- * The route Map is the persistent module object, so the new listener closes
2147
- * over the same table and later route additions need no rebind at all.
2130
+ * The loopback port is ephemeral rather than fixed so it can never
2131
+ * collide with a fake's declared port; it is bound on 127.0.0.1, which no
2132
+ * container can reach (containers arrive at the bridge gateway), so the
2133
+ * only way in is through the terminator.
2148
2134
  */
2149
2135
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2150
- function rebindHttpsListener(Bun: any): void {
2151
- // The *same* table object the previous listener closed over: the new
2152
- // listener must serve the routes bound since, and any bound later.
2136
+ async function ensureHttpsIngress(Bun: any): Promise<void> {
2137
+ if (INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT)) return;
2138
+ if (!HTTPS_INGRESS_STARTING) {
2139
+ // Cleared on both outcomes: once bound, the check above short-circuits
2140
+ // every later caller, and a failed bind must be retryable rather than
2141
+ // remembered as a rejection for the life of the project.
2142
+ HTTPS_INGRESS_STARTING = startHttpsIngress(Bun).finally(() => {
2143
+ HTTPS_INGRESS_STARTING = undefined;
2144
+ });
2145
+ }
2146
+ return HTTPS_INGRESS_STARTING;
2147
+ }
2148
+
2149
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2150
+ async function startHttpsIngress(Bun: any): Promise<void> {
2153
2151
  const routes = routesFor(INGRESS, INGRESS_HTTPS_PORT);
2154
- const old = INGRESS_HTTPS_SERVERS.get(INGRESS_HTTPS_PORT);
2155
- const server = bindIngressServer(
2156
- Bun,
2157
- INGRESS_HTTPS_PORT,
2158
- routes,
2159
- `https :${INGRESS_HTTPS_PORT}`,
2160
- tlsEntriesFromCerts(),
2161
- );
2162
- if (old) {
2152
+ // `proto` is passed rather than inferred: this listener speaks plain
2153
+ // HTTP, but everything reaching it arrived over TLS, so X-Forwarded-Proto
2154
+ // must say https.
2155
+ const plain = bindIngressServer(Bun, 0, routes, `https :${INGRESS_HTTPS_PORT}`, {
2156
+ proto: "https",
2157
+ hostname: "127.0.0.1",
2158
+ });
2159
+ INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, plain);
2160
+ try {
2161
+ HTTPS_TERMINATOR = await startTlsTerminator({
2162
+ port: INGRESS_HTTPS_PORT,
2163
+ hostname: "0.0.0.0",
2164
+ upstreamPort: plain.port,
2165
+ certFor: (serverName) => {
2166
+ if (serverName === undefined) return INGRESS.certByHost.values().next().value;
2167
+ const name = selectCertName(INGRESS.certByHost.keys(), serverName.toLowerCase());
2168
+ return name === undefined ? undefined : INGRESS.certByHost.get(name);
2169
+ },
2170
+ });
2171
+ } catch (err) {
2172
+ // Leave nothing half-built: a registered plaintext server with no
2173
+ // terminator would make `planBind` believe :443 is up.
2174
+ INGRESS_HTTPS_SERVERS.delete(INGRESS_HTTPS_PORT);
2163
2175
  try {
2164
- // Graceful: stop accepting, let in-flight requests finish. The old
2165
- // server stays alive until they do, which is the point — a long
2166
- // upload through ingress must not be collateral damage of another
2167
- // service being provisioned.
2168
- old.stop(false);
2169
- // Bun 1.3.14 landmine: a request arriving later on one of the old
2170
- // server's kept-alive connections dispatches into freed state and can
2171
- // segfault the daemon (see {@link DRAINING_INGRESS}). Mark it so any
2172
- // response it still serves closes its connection, and force-close the
2173
- // stragglers once in-flight work has had a fair window to finish.
2174
- DRAINING_INGRESS.add(old);
2175
- const graceTimer = setTimeout(() => {
2176
- try {
2177
- old.stop(true);
2178
- } catch {
2179
- /* already fully stopped */
2180
- }
2181
- }, REBIND_DRAIN_GRACE_MS);
2182
- // Don't let the grace timer keep the process alive on shutdown.
2183
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2184
- (graceTimer as any).unref?.();
2185
- } catch (err) {
2186
- // eslint-disable-next-line no-console
2187
- console.warn("[ingress] failed to drain the previous https listener:", err);
2176
+ plain.stop(true);
2177
+ } catch {
2178
+ /* already gone */
2188
2179
  }
2180
+ throw err;
2189
2181
  }
2190
- INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, server);
2191
2182
  // eslint-disable-next-line no-console
2192
- console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${[...routes.keys()].join(", ")}`);
2183
+ console.log(
2184
+ `[ingress] https :${INGRESS_HTTPS_PORT} (tls terminator -> :${plain.port}) for ${[...routes.keys()].join(", ")}`,
2185
+ );
2193
2186
  }
2194
2187
 
2195
2188
  /**
@@ -2258,9 +2251,12 @@ async function bindRuntimeTls(hostname: string, service: string, port: number):
2258
2251
  );
2259
2252
  }
2260
2253
  if (plan.needsCert) {
2254
+ // The terminator reads this table on every handshake, so the leaf is
2255
+ // live the moment it lands — no listener is touched, and no connection
2256
+ // already on :443 notices.
2261
2257
  INGRESS.certByHost.set(host, await generateHostCert(host, [host]));
2262
2258
  }
2263
- if (plan.needsHttpsRebind) rebindHttpsListener(Bun);
2259
+ if (plan.needsHttpsListener) await ensureHttpsIngress(Bun);
2264
2260
 
2265
2261
  // Resolve the hostname to the daemon gateway (where :443/:80 listen).
2266
2262
  // A wildcard can only live in the resolver's suffix table.
@@ -2312,22 +2308,27 @@ function bindIngressServer(
2312
2308
  port: number,
2313
2309
  byHost: Map<string, Route>,
2314
2310
  listenerLabel: string,
2315
- tlsEntries?: Array<{ cert: string; key: string; serverName: string }>,
2311
+ // Every ingress listener speaks plain HTTP now — the one behind :443
2312
+ // sits under the TLS terminator. So the scheme a client actually used
2313
+ // can no longer be inferred from the socket and is declared instead; it
2314
+ // stamps X-Forwarded-Proto, which upstreams that build absolute URLs or
2315
+ // redirect depend on.
2316
+ serve: { proto: "http" | "https"; hostname?: string } = { proto: "http" },
2316
2317
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2317
2318
  ): any {
2318
- // A TLS listener terminates https; everything else is plain http. Used
2319
- // to stamp X-Forwarded-Proto so upstreams that build absolute URLs or
2320
- // redirect see the scheme the client actually used, not our http hop.
2321
- const proto = tlsEntries ? "https" : "http";
2319
+ const proto = serve.proto;
2322
2320
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2323
2321
  const opts: Record<string, any> = {
2324
2322
  port,
2325
- hostname: "0.0.0.0",
2326
- // SO_REUSEPORT on every ingress listener, so a replacement can be bound
2327
- // while the old one is still serving. That overlap is the only way to
2328
- // add an SNI certificate without a gap — see {@link rebindHttpsListener}
2329
- // — and it only works if *both* sockets opt in: a second plain bind
2330
- // fails with "Is port 443 in use?".
2323
+ hostname: serve.hostname ?? "0.0.0.0",
2324
+ // SO_REUSEPORT on every ingress listener. It was introduced so a
2325
+ // replacement :443 could overlap the original during a certificate
2326
+ // swap; the terminator removed that swap, and this stays for the
2327
+ // remaining case — the `/load` teardown force-closes and rebinds, and
2328
+ // a socket the kernel has not finished releasing would otherwise fail
2329
+ // the next bind with "Is port 443 in use?". Note the flip side, which
2330
+ // {@link stopIngressServers} depends on: a half-alive listener shares
2331
+ // the port silently instead of colliding loudly.
2331
2332
  reusePort: true,
2332
2333
  // Bun.serve defaults to a 10s idleTimeout, which kills any proxied
2333
2334
  // request whose upstream takes >10s to produce bytes — under parallel
@@ -2340,9 +2341,7 @@ function bindIngressServer(
2340
2341
  idleTimeout: 0,
2341
2342
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2342
2343
  fetch: (req: Request, server: any): Response | Promise<Response> =>
2343
- dispatchIngress(req, server, byHost, listenerLabel, proto).then((res) =>
2344
- withDrainClose(server, res),
2345
- ),
2344
+ dispatchIngress(req, server, byHost, listenerLabel, proto),
2346
2345
  websocket: {
2347
2346
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2348
2347
  async open(ws: any) {
@@ -2412,7 +2411,6 @@ function bindIngressServer(
2412
2411
  },
2413
2412
  },
2414
2413
  };
2415
- if (tlsEntries) opts.tls = tlsEntries;
2416
2414
  return Bun.serve(opts);
2417
2415
  }
2418
2416
 
@@ -2489,6 +2487,27 @@ async function dispatchIngress(
2489
2487
  return augmentCorsResponse(req, res);
2490
2488
  }
2491
2489
 
2490
+ /**
2491
+ * The address to report as the client's in `X-Forwarded-For`.
2492
+ *
2493
+ * On :80 that is simply the socket's peer. On :443 the peer is the TLS
2494
+ * terminator on loopback, so the real address has to be recovered from it:
2495
+ * the terminator keys `source port -> client address` for the life of each
2496
+ * upstream connection, and the source port is what `requestIP` reports
2497
+ * here. Gated on `proto === "https"` so a genuinely loopback caller on :80
2498
+ * can never pick up an unrelated terminator connection's port.
2499
+ *
2500
+ * Falling back to the socket's own address means the worst case is the
2501
+ * pre-terminator answer for a plain HTTP hop, never a wrong tenant.
2502
+ */
2503
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2504
+ function ingressClientIp(server: any, req: Request, proto: string): string | undefined {
2505
+ const peer = server.requestIP?.(req) as { address?: string; port?: number } | undefined;
2506
+ if (!peer?.address) return undefined;
2507
+ if (proto !== "https") return peer.address;
2508
+ return HTTPS_TERMINATOR?.clientIpFor(peer.port) ?? peer.address;
2509
+ }
2510
+
2492
2511
  /**
2493
2512
  * Reverse-proxy a request to `http://<service>:<port>` on
2494
2513
  * `spectest-net`. Handles plain HTTP/1.1 + 2 and WebSocket upgrades:
@@ -2548,7 +2567,7 @@ async function proxyToService(
2548
2567
  // Standard reverse-proxy provenance headers: the upstream sees the
2549
2568
  // public scheme/host it was reached through and the client's address,
2550
2569
  // even though we rewrite Host below to the service-net name.
2551
- const clientIp = server.requestIP?.(req)?.address as string | undefined;
2570
+ const clientIp = ingressClientIp(server, req, proto);
2552
2571
  const priorXff = req.headers.get("x-forwarded-for");
2553
2572
  const xff = clientIp ? (priorXff ? `${priorXff}, ${clientIp}` : clientIp) : priorXff;
2554
2573
  if (xff) fwdHeaders.set("x-forwarded-for", xff);
@@ -68,6 +68,41 @@ export function certCovers(serverNames: Iterable<string>, hostname: string): boo
68
68
  return names.some((n) => isWildcard(n) && wildcardCoversHost(n, hostname));
69
69
  }
70
70
 
71
+ /**
72
+ * Pick the certificate server name that should serve `hostname`'s TLS
73
+ * handshake: the exact entry first, then the longest matching wildcard.
74
+ *
75
+ * This is the same precedence {@link matchRoute} applies to routes and the
76
+ * resolver applies to DNS — but over the *certificate* rule, so a wildcard
77
+ * covers exactly one label ({@link wildcardCoversHost}) rather than any
78
+ * depth.
79
+ *
80
+ * It exists because the SNI lookup is now ours to make. Until the :443
81
+ * listener stopped being rebuilt for every new certificate, Bun performed
82
+ * this match internally from the `tls` array it was served with; the
83
+ * terminator picks a certificate per handshake instead, so the rule has to
84
+ * be written down — and, being written down, it is testable, which Bun's
85
+ * internal version never was.
86
+ */
87
+ export function selectCertName(
88
+ serverNames: Iterable<string>,
89
+ hostname: string,
90
+ ): string | undefined {
91
+ const names = [...serverNames];
92
+ if (names.includes(hostname)) return hostname;
93
+ let best: string | undefined;
94
+ let bestLen = -1;
95
+ for (const name of names) {
96
+ if (!isWildcard(name) || !wildcardCoversHost(name, hostname)) continue;
97
+ const len = wildcardSuffix(name).length;
98
+ if (len > bestLen) {
99
+ best = name;
100
+ bestLen = len;
101
+ }
102
+ }
103
+ return best;
104
+ }
105
+
71
106
  /**
72
107
  * Pick the route for `host`: exact match first, then the longest matching
73
108
  * wildcard suffix.
@@ -1,17 +1,25 @@
1
1
  /**
2
- * The :443 rebind must not be collateral damage for traffic already on the
3
- * listener.
2
+ * What a listener swap does and does not cost.
4
3
  *
5
- * Adding an SNI certificate needs a second `Bun.serve` — `reload()` accepts
6
- * a new `tls` option and goes on serving the old certificates. The question
7
- * this file pins down is the *order*: bind the replacement first and drain
8
- * the old one, rather than stopping the old one and leaving a gap.
4
+ * **:443 no longer swaps.** Adding an SNI certificate used to need a
5
+ * second `Bun.serve` (`reload()` accepts a new `tls` option and goes on
6
+ * serving the old certificates — still true on Bun 1.4.0), and this file
7
+ * pinned down the *order*: bind the replacement first and drain the old
8
+ * one, rather than stopping first and leaving a gap. That ordering was
9
+ * necessary and insufficient — it saves a request already in flight and
10
+ * severs every idle kept-alive connection, which is the failure that kept
11
+ * reaching production. `tls-terminator.ts` chooses the certificate per
12
+ * handshake now, so the certificate path swaps nothing; `tls-terminator.
13
+ * test.ts` covers the invariant that replaced this one.
9
14
  *
10
- * These drive real sockets rather than a model of them, because the bug was
15
+ * These stay because the swap itself stays, on the remaining path: the
16
+ * `/load` teardown force-closes every listener and rebinds the next
17
+ * project's. That is what `reusePort` is still for, and the two properties
18
+ * below — a replacement can bind alongside the original, and the port is
19
+ * never refused across the swap — are what make it safe.
20
+ *
21
+ * They drive real sockets rather than a model of them, because the bug was
11
22
  * in socket behaviour: a model would have agreed with the broken code.
12
- * They use plain HTTP — the TLS part is Bun's SNI table, measured
13
- * separately; what is asserted here is that a listener swap costs no
14
- * connection, which is the part that was wrong.
15
23
  */
16
24
  import { afterEach, describe, expect, test } from "bun:test";
17
25
 
@@ -78,21 +78,36 @@ describe("route tables", () => {
78
78
  });
79
79
 
80
80
  describe("planBind", () => {
81
- test("a brand-new hostname needs a cert and a rebind", () => {
81
+ test("a brand-new hostname needs a cert", () => {
82
82
  const t = emptyTables();
83
83
  expect(planBind(t, "new.test", { httpsListening: true })).toEqual({
84
84
  needsCert: true,
85
- needsHttpsRebind: true,
85
+ needsHttpsListener: false,
86
86
  });
87
87
  });
88
88
 
89
- test("the first bind rebinds even when a cert already covers the name", () => {
89
+ /**
90
+ * The rule the terminator bought, and the regression this file is the
91
+ * cheap half of: minting a certificate must NOT imply touching the
92
+ * listener. It used to (`needsCert || !httpsListening`), and every such
93
+ * restart severed the idle kept-alive connections :443 was carrying —
94
+ * failing an unrelated test with `SocketError: other side closed`.
95
+ */
96
+ test("a new certificate never asks for the listener to be restarted", () => {
97
+ const t = emptyTables();
98
+ t.certByHost.set("app.test", leaf("app"));
99
+ expect(planBind(t, "brand-new.test", { httpsListening: true })).toEqual({
100
+ needsCert: true,
101
+ needsHttpsListener: false,
102
+ });
103
+ });
104
+
105
+ test("the listener is asked for when nothing is serving :443 yet", () => {
90
106
  const t = emptyTables();
91
107
  t.certByHost.set("app.test", leaf("app"));
92
- // Nothing is listening yet, so :443 has to be served regardless.
93
108
  expect(planBind(t, "app.test", { httpsListening: false })).toEqual({
94
109
  needsCert: false,
95
- needsHttpsRebind: true,
110
+ needsHttpsListener: true,
96
111
  });
97
112
  });
98
113
 
@@ -101,19 +116,19 @@ describe("planBind", () => {
101
116
  t.certByHost.set("app.test", leaf("app"));
102
117
  expect(planBind(t, "app.test", { httpsListening: true })).toEqual({
103
118
  needsCert: false,
104
- needsHttpsRebind: false,
119
+ needsHttpsListener: false,
105
120
  });
106
121
  });
107
122
 
108
123
  /** The case that makes runtime provisioning cheap: a component claims
109
124
  * `*.db.test` up front, then a fake mints endpoints under it in a loop.
110
- * Rebinding per iteration would restart the listener each time. */
125
+ * Each one is then a route entry and not even a keygen. */
111
126
  test("a hostname under an existing wildcard cert needs neither", () => {
112
127
  const t = emptyTables();
113
128
  t.certByHost.set("*.db.test", leaf("wild"));
114
129
  expect(planBind(t, "tenant-1.db.test", { httpsListening: true })).toEqual({
115
130
  needsCert: false,
116
- needsHttpsRebind: false,
131
+ needsHttpsListener: false,
117
132
  });
118
133
  });
119
134
 
@@ -32,13 +32,14 @@
32
32
  * routes added afterwards go nowhere. {@link routesFor} is the only way to
33
33
  * reach a table so that this can't be done by accident.
34
34
  *
35
- * **A rebind is not free, so it must be earned.** Bun fixes a server's TLS
36
- * config at `Bun.serve` time — `reload` will not add an SNI entry — so a
37
- * genuinely new certificate means stopping and re-serving :443. It's cheap
38
- * (~1 ms) but it is a real interruption of live traffic, and a hostname
39
- * already covered by an existing exact or wildcard cert needs nothing but a
40
- * route entry. {@link planBind} is that judgement, separated from the
41
- * mutation so it can be tested without a network stack.
35
+ * **Adding a certificate no longer touches the listener.** :443 is
36
+ * terminated by `harness/tls-terminator.ts`, which chooses a leaf per TLS
37
+ * handshake, so a new certificate is a `Map` entry and nothing more. It
38
+ * used to mean stopping and re-serving :443, and that swap severed every
39
+ * idle kept-alive connection the listener was carrying — an unrelated
40
+ * test's request, failing with `SocketError: other side closed`. The only
41
+ * thing {@link planBind} still has to say is whether the listener exists
42
+ * yet.
42
43
  */
43
44
 
44
45
  import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
@@ -102,19 +103,22 @@ export function routesFor<F>(tables: IngressTables<F>, port: number): Map<string
102
103
  export interface BindPlan {
103
104
  /** No existing cert covers the hostname, so a leaf must be minted. */
104
105
  needsCert: boolean;
105
- /** The :443 listener must be stopped and re-served. */
106
- needsHttpsRebind: boolean;
106
+ /** The :443 listener does not exist yet and must be started. */
107
+ needsHttpsListener: boolean;
107
108
  }
108
109
 
109
110
  /**
110
111
  * Decide what binding `hostname` for TLS costs.
111
112
  *
112
- * A rebind is needed when a new certificate is going into the SNI table,
113
- * or when :443 isn't listening yet. It is deliberately *not* needed for a
114
- * hostname an existing wildcard already covers — the common case when a
115
- * component claims a whole domain up front and services appear under it
116
- * later, and the reason a fake can mint endpoints in a loop without
117
- * restarting the listener once per iteration.
113
+ * Minting is still earned — a hostname an existing exact or wildcard cert
114
+ * already covers needs nothing but a route, which is why a component can
115
+ * claim a whole domain up front and have services appear under it for
116
+ * free.
117
+ *
118
+ * Starting the listener, on the other hand, is now a once-per-process
119
+ * event: it is needed when :443 is not up yet, and never again. A new
120
+ * certificate does **not** imply it. That is the whole point of the
121
+ * terminator — see the module header.
118
122
  */
119
123
  export function planBind<F>(
120
124
  tables: IngressTables<F>,
@@ -122,7 +126,7 @@ export function planBind<F>(
122
126
  opts: { httpsListening: boolean },
123
127
  ): BindPlan {
124
128
  const needsCert = !certCovers(tables.certByHost.keys(), hostname);
125
- return { needsCert, needsHttpsRebind: needsCert || !opts.httpsListening };
129
+ return { needsCert, needsHttpsListener: !opts.httpsListening };
126
130
  }
127
131
 
128
132
  /**
@@ -141,15 +145,22 @@ export function bindRoute<F>(tables: IngressTables<F>, hostname: string, route:
141
145
  * Drop `hostname`'s routes, so it 404s.
142
146
  *
143
147
  * The certificate is deliberately left in the SNI table: it is harmless
144
- * without a route, and removing it would force an otherwise unnecessary
145
- * :443 rebind at exactly the moment a service is going away.
148
+ * without a route, and a leaf nobody asks for costs one unused `Map`
149
+ * entry. (Before the terminator, removing it would also have forced a
150
+ * :443 rebind at exactly the moment a service was going away.)
146
151
  */
147
152
  export function unbindRoute<F>(tables: IngressTables<F>, hostname: string): void {
148
153
  tables.routesByPort.get(INGRESS_HTTP_PORT)?.delete(hostname);
149
154
  tables.routesByPort.get(INGRESS_HTTPS_PORT)?.delete(hostname);
150
155
  }
151
156
 
152
- /** The SNI entries for `Bun.serve`'s TLS config. */
157
+ /**
158
+ * The SNI table flattened to entries.
159
+ *
160
+ * This was `Bun.serve`'s `tls` array. The terminator selects per handshake
161
+ * instead ({@link import("./hostmatch").selectCertName}), so this survives
162
+ * as the readable projection of the table — for diagnostics and tests.
163
+ */
153
164
  export function certEntries<F>(
154
165
  tables: IngressTables<F>,
155
166
  ): Array<{ cert: string; key: string; serverName: string }> {