@specific.dev/spectest 0.53.0 → 0.54.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/dist/daemon.js CHANGED
@@ -1305,6 +1305,44 @@ const INGRESS_HTTP_SERVERS = new Map();
1305
1305
  /** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
1306
1306
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1307
1307
  const INGRESS_HTTPS_SERVERS = new Map();
1308
+ /**
1309
+ * Servers replaced by a rebind and now draining. On Bun 1.3.14 a request
1310
+ * arriving on a kept-alive connection of a `stop(false)`-drained server
1311
+ * dispatches into freed per-server state and can SEGFAULT the process
1312
+ * (use-after-free class fixed upstream by oven-sh/bun#36790, first in Bun
1313
+ * 1.4.0; observed here as `panic: Segmentation fault at address 0xA` in
1314
+ * `server.zig onRequestFor` on ~2-3 % of runtime-TLS rebinds). Until the
1315
+ * Bun bump lands, shrink the number of requests a drained server can ever
1316
+ * see: every response it still serves carries `Connection: close` (one
1317
+ * more request per surviving connection, not unlimited), and a grace timer
1318
+ * force-closes whatever is left ({@link REBIND_DRAIN_GRACE_MS}).
1319
+ */
1320
+ const DRAINING_INGRESS = new WeakSet();
1321
+ /** How long a drained listener may keep serving in-flight work before its
1322
+ * remaining connections are force-closed. Long enough for a slow proxied
1323
+ * response to finish, short enough to bound the 1.3.14 UAF window. */
1324
+ const REBIND_DRAIN_GRACE_MS = 15_000;
1325
+ /** Stamp `Connection: close` on a response served by a draining listener so
1326
+ * the kept-alive connection retires instead of lingering as a UAF trigger.
1327
+ * Proxied responses can carry immutable headers; rewrap when needed. */
1328
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1329
+ function withDrainClose(server, res) {
1330
+ if (!DRAINING_INGRESS.has(server))
1331
+ return res;
1332
+ try {
1333
+ res.headers.set("connection", "close");
1334
+ return res;
1335
+ }
1336
+ catch {
1337
+ const headers = new Headers(res.headers);
1338
+ headers.set("connection", "close");
1339
+ return new Response(res.body, {
1340
+ status: res.status,
1341
+ statusText: res.statusText,
1342
+ headers,
1343
+ });
1344
+ }
1345
+ }
1308
1346
  /**
1309
1347
  * The live ingress tables — per-port routes and the :443 SNI cert table.
1310
1348
  *
@@ -1627,6 +1665,23 @@ function rebindHttpsListener(Bun) {
1627
1665
  // upload through ingress must not be collateral damage of another
1628
1666
  // service being provisioned.
1629
1667
  old.stop(false);
1668
+ // Bun 1.3.14 landmine: a request arriving later on one of the old
1669
+ // server's kept-alive connections dispatches into freed state and can
1670
+ // segfault the daemon (see {@link DRAINING_INGRESS}). Mark it so any
1671
+ // response it still serves closes its connection, and force-close the
1672
+ // stragglers once in-flight work has had a fair window to finish.
1673
+ DRAINING_INGRESS.add(old);
1674
+ const graceTimer = setTimeout(() => {
1675
+ try {
1676
+ old.stop(true);
1677
+ }
1678
+ catch {
1679
+ /* already fully stopped */
1680
+ }
1681
+ }, REBIND_DRAIN_GRACE_MS);
1682
+ // Don't let the grace timer keep the process alive on shutdown.
1683
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1684
+ graceTimer.unref?.();
1630
1685
  }
1631
1686
  catch (err) {
1632
1687
  // eslint-disable-next-line no-console
@@ -1766,7 +1821,7 @@ Bun, port, byHost, listenerLabel, tlsEntries) {
1766
1821
  // short-lived, leaked-connection risk is bounded by the fork.
1767
1822
  idleTimeout: 0,
1768
1823
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1769
- fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, proto),
1824
+ fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, proto).then((res) => withDrainClose(server, res)),
1770
1825
  websocket: {
1771
1826
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1772
1827
  async open(ws) {
@@ -3308,6 +3363,26 @@ function describeRequestBody(input, init) {
3308
3363
  }
3309
3364
  return { body: `[non-text body: ${body.constructor?.name ?? typeof body}]` };
3310
3365
  }
3366
+ /**
3367
+ * Marks an error thrown by the instrumented fetch as *transport-level* —
3368
+ * the connection itself failed (refused, unresolvable, reset) before any
3369
+ * HTTP reply existed. `fetch` never rejects for an HTTP status, so every
3370
+ * rejection short of an abort is transport. `Symbol.for` so a duplicated
3371
+ * SDK module instance (the bun hardlink landmine) still recognises it.
3372
+ *
3373
+ * Why it exists: `ctx.poll` waits for convergence, and right after a fork
3374
+ * restore the guest can serve a ~10 s window where a connect or a DNS
3375
+ * lookup fails once and then heals (measured 2026-08-21: a poll's first
3376
+ * fetch hung 12 s in resolution, threw, and killed a 60 s poll on attempt
3377
+ * 1 while attempt 2 would have passed). During a poll, a dead connection
3378
+ * is just "not ready yet"; outside one it stays a hard error.
3379
+ */
3380
+ const TRANSPORT_ERROR = Symbol.for("spectest.transportError");
3381
+ function isTransportError(err) {
3382
+ return (typeof err === "object" &&
3383
+ err !== null &&
3384
+ err[TRANSPORT_ERROR] === true);
3385
+ }
3311
3386
  /**
3312
3387
  * Install a fetch wrapper on `globalThis` that emits HTTP events into the
3313
3388
  * active recorder. Returns a restore function. Calls outside of a running
@@ -3378,6 +3453,20 @@ function installFetchWrapper() {
3378
3453
  durationMs: Date.now() - start,
3379
3454
  error: e?.message ?? String(err),
3380
3455
  }, resv);
3456
+ // Tag transport failures for ctx.poll (see TRANSPORT_ERROR). An abort
3457
+ // is the caller's own signal (their AbortController or their
3458
+ // AbortSignal.timeout) — their semantics, never retried for them.
3459
+ if (typeof err === "object" &&
3460
+ err !== null &&
3461
+ e?.name !== "AbortError" &&
3462
+ e?.name !== "TimeoutError") {
3463
+ try {
3464
+ err[TRANSPORT_ERROR] = true;
3465
+ }
3466
+ catch {
3467
+ /* frozen error object — stays a hard error */
3468
+ }
3469
+ }
3381
3470
  throw err;
3382
3471
  }
3383
3472
  };
@@ -3589,6 +3678,7 @@ async function pollCall(description, fn, opts) {
3589
3678
  let value;
3590
3679
  let success = false;
3591
3680
  let predicateError;
3681
+ let lastTransportError;
3592
3682
  // Record all iterations normally, but keep only the newest one: a new
3593
3683
  // attempt drops the events the previous attempt emitted, so the timeline
3594
3684
  // never fills with polling noise. Whichever attempt is last when the loop
@@ -3618,8 +3708,19 @@ async function pollCall(description, fn, opts) {
3618
3708
  }
3619
3709
  }
3620
3710
  catch (err) {
3621
- predicateError = err;
3622
- break;
3711
+ // A transport-level fetch failure (connection refused, DNS miss —
3712
+ // see TRANSPORT_ERROR) is "not ready yet", not a verdict: polls wait
3713
+ // for convergence, and services legitimately refuse connections
3714
+ // while they come up. Keep polling; the kept last attempt's http
3715
+ // event carries the error for the timeline. Anything else — an
3716
+ // assertion, a TypeError, a user abort — stays fatal on attempt 1.
3717
+ if (isTransportError(err)) {
3718
+ lastTransportError = err;
3719
+ }
3720
+ else {
3721
+ predicateError = err;
3722
+ break;
3723
+ }
3623
3724
  }
3624
3725
  if (Date.now() - start + intervalMs > timeoutMs)
3625
3726
  break;
@@ -3629,7 +3730,9 @@ async function pollCall(description, fn, opts) {
3629
3730
  ? (predicateError?.message ?? String(predicateError))
3630
3731
  : success
3631
3732
  ? undefined
3632
- : `timed out after ${timeoutMs}ms`;
3733
+ : lastTransportError !== undefined
3734
+ ? `timed out after ${timeoutMs}ms (last attempt: ${lastTransportError?.message ?? String(lastTransportError)})`
3735
+ : `timed out after ${timeoutMs}ms`;
3633
3736
  const seq = recordWait({
3634
3737
  description,
3635
3738
  attempts,
@@ -3648,7 +3751,10 @@ async function pollCall(description, fn, opts) {
3648
3751
  if (success) {
3649
3752
  return wrap(value, seq);
3650
3753
  }
3651
- throw new Error(`poll ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`);
3754
+ const lastAttempt = lastTransportError !== undefined
3755
+ ? `; last attempt: ${lastTransportError?.message ?? String(lastTransportError)}`
3756
+ : "";
3757
+ throw new Error(`poll ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts${lastAttempt})`);
3652
3758
  }
3653
3759
  function captureConsole(chunks) {
3654
3760
  const methods = ["log", "info", "warn", "error", "debug"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/daemon.ts CHANGED
@@ -1677,6 +1677,43 @@ const INGRESS_HTTP_SERVERS = new Map<number, any>();
1677
1677
  /** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
1678
1678
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1679
1679
  const INGRESS_HTTPS_SERVERS = new Map<number, any>();
1680
+ /**
1681
+ * Servers replaced by a rebind and now draining. On Bun 1.3.14 a request
1682
+ * arriving on a kept-alive connection of a `stop(false)`-drained server
1683
+ * dispatches into freed per-server state and can SEGFAULT the process
1684
+ * (use-after-free class fixed upstream by oven-sh/bun#36790, first in Bun
1685
+ * 1.4.0; observed here as `panic: Segmentation fault at address 0xA` in
1686
+ * `server.zig onRequestFor` on ~2-3 % of runtime-TLS rebinds). Until the
1687
+ * Bun bump lands, shrink the number of requests a drained server can ever
1688
+ * see: every response it still serves carries `Connection: close` (one
1689
+ * more request per surviving connection, not unlimited), and a grace timer
1690
+ * force-closes whatever is left ({@link REBIND_DRAIN_GRACE_MS}).
1691
+ */
1692
+ const DRAINING_INGRESS = new WeakSet<object>();
1693
+ /** How long a drained listener may keep serving in-flight work before its
1694
+ * remaining connections are force-closed. Long enough for a slow proxied
1695
+ * response to finish, short enough to bound the 1.3.14 UAF window. */
1696
+ const REBIND_DRAIN_GRACE_MS = 15_000;
1697
+
1698
+ /** Stamp `Connection: close` on a response served by a draining listener so
1699
+ * the kept-alive connection retires instead of lingering as a UAF trigger.
1700
+ * Proxied responses can carry immutable headers; rewrap when needed. */
1701
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1702
+ function withDrainClose(server: any, res: Response): Response {
1703
+ if (!DRAINING_INGRESS.has(server)) return res;
1704
+ try {
1705
+ res.headers.set("connection", "close");
1706
+ return res;
1707
+ } catch {
1708
+ const headers = new Headers(res.headers);
1709
+ headers.set("connection", "close");
1710
+ return new Response(res.body, {
1711
+ status: res.status,
1712
+ statusText: res.statusText,
1713
+ headers,
1714
+ });
1715
+ }
1716
+ }
1680
1717
  /**
1681
1718
  * The live ingress tables — per-port routes and the :443 SNI cert table.
1682
1719
  *
@@ -2033,6 +2070,22 @@ function rebindHttpsListener(Bun: any): void {
2033
2070
  // upload through ingress must not be collateral damage of another
2034
2071
  // service being provisioned.
2035
2072
  old.stop(false);
2073
+ // Bun 1.3.14 landmine: a request arriving later on one of the old
2074
+ // server's kept-alive connections dispatches into freed state and can
2075
+ // segfault the daemon (see {@link DRAINING_INGRESS}). Mark it so any
2076
+ // response it still serves closes its connection, and force-close the
2077
+ // stragglers once in-flight work has had a fair window to finish.
2078
+ DRAINING_INGRESS.add(old);
2079
+ const graceTimer = setTimeout(() => {
2080
+ try {
2081
+ old.stop(true);
2082
+ } catch {
2083
+ /* already fully stopped */
2084
+ }
2085
+ }, REBIND_DRAIN_GRACE_MS);
2086
+ // Don't let the grace timer keep the process alive on shutdown.
2087
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2088
+ (graceTimer as any).unref?.();
2036
2089
  } catch (err) {
2037
2090
  // eslint-disable-next-line no-console
2038
2091
  console.warn("[ingress] failed to drain the previous https listener:", err);
@@ -2191,7 +2244,9 @@ function bindIngressServer(
2191
2244
  idleTimeout: 0,
2192
2245
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2193
2246
  fetch: (req: Request, server: any): Response | Promise<Response> =>
2194
- dispatchIngress(req, server, byHost, listenerLabel, proto),
2247
+ dispatchIngress(req, server, byHost, listenerLabel, proto).then((res) =>
2248
+ withDrainClose(server, res),
2249
+ ),
2195
2250
  websocket: {
2196
2251
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2197
2252
  async open(ws: any) {
@@ -4090,6 +4145,30 @@ function describeRequestBody(
4090
4145
  return { body: `[non-text body: ${body.constructor?.name ?? typeof body}]` };
4091
4146
  }
4092
4147
 
4148
+ /**
4149
+ * Marks an error thrown by the instrumented fetch as *transport-level* —
4150
+ * the connection itself failed (refused, unresolvable, reset) before any
4151
+ * HTTP reply existed. `fetch` never rejects for an HTTP status, so every
4152
+ * rejection short of an abort is transport. `Symbol.for` so a duplicated
4153
+ * SDK module instance (the bun hardlink landmine) still recognises it.
4154
+ *
4155
+ * Why it exists: `ctx.poll` waits for convergence, and right after a fork
4156
+ * restore the guest can serve a ~10 s window where a connect or a DNS
4157
+ * lookup fails once and then heals (measured 2026-08-21: a poll's first
4158
+ * fetch hung 12 s in resolution, threw, and killed a 60 s poll on attempt
4159
+ * 1 while attempt 2 would have passed). During a poll, a dead connection
4160
+ * is just "not ready yet"; outside one it stays a hard error.
4161
+ */
4162
+ const TRANSPORT_ERROR = Symbol.for("spectest.transportError");
4163
+
4164
+ function isTransportError(err: unknown): boolean {
4165
+ return (
4166
+ typeof err === "object" &&
4167
+ err !== null &&
4168
+ (err as Record<symbol, unknown>)[TRANSPORT_ERROR] === true
4169
+ );
4170
+ }
4171
+
4093
4172
  /**
4094
4173
  * Install a fetch wrapper on `globalThis` that emits HTTP events into the
4095
4174
  * active recorder. Returns a restore function. Calls outside of a running
@@ -4159,6 +4238,21 @@ function installFetchWrapper(): () => void {
4159
4238
  durationMs: Date.now() - start,
4160
4239
  error: e?.message ?? String(err),
4161
4240
  }, resv);
4241
+ // Tag transport failures for ctx.poll (see TRANSPORT_ERROR). An abort
4242
+ // is the caller's own signal (their AbortController or their
4243
+ // AbortSignal.timeout) — their semantics, never retried for them.
4244
+ if (
4245
+ typeof err === "object" &&
4246
+ err !== null &&
4247
+ e?.name !== "AbortError" &&
4248
+ e?.name !== "TimeoutError"
4249
+ ) {
4250
+ try {
4251
+ (err as Record<symbol, unknown>)[TRANSPORT_ERROR] = true;
4252
+ } catch {
4253
+ /* frozen error object — stays a hard error */
4254
+ }
4255
+ }
4162
4256
  throw err;
4163
4257
  }
4164
4258
  };
@@ -4410,6 +4504,7 @@ async function pollCall<T>(
4410
4504
  let value: T | undefined;
4411
4505
  let success = false;
4412
4506
  let predicateError: unknown;
4507
+ let lastTransportError: unknown;
4413
4508
 
4414
4509
  // Record all iterations normally, but keep only the newest one: a new
4415
4510
  // attempt drops the events the previous attempt emitted, so the timeline
@@ -4440,8 +4535,18 @@ async function pollCall<T>(
4440
4535
  break;
4441
4536
  }
4442
4537
  } catch (err) {
4443
- predicateError = err;
4444
- break;
4538
+ // A transport-level fetch failure (connection refused, DNS miss —
4539
+ // see TRANSPORT_ERROR) is "not ready yet", not a verdict: polls wait
4540
+ // for convergence, and services legitimately refuse connections
4541
+ // while they come up. Keep polling; the kept last attempt's http
4542
+ // event carries the error for the timeline. Anything else — an
4543
+ // assertion, a TypeError, a user abort — stays fatal on attempt 1.
4544
+ if (isTransportError(err)) {
4545
+ lastTransportError = err;
4546
+ } else {
4547
+ predicateError = err;
4548
+ break;
4549
+ }
4445
4550
  }
4446
4551
  if (Date.now() - start + intervalMs > timeoutMs) break;
4447
4552
  await new Promise((r) => setTimeout(r, intervalMs));
@@ -4452,7 +4557,11 @@ async function pollCall<T>(
4452
4557
  ? ((predicateError as Error)?.message ?? String(predicateError))
4453
4558
  : success
4454
4559
  ? undefined
4455
- : `timed out after ${timeoutMs}ms`;
4560
+ : lastTransportError !== undefined
4561
+ ? `timed out after ${timeoutMs}ms (last attempt: ${
4562
+ (lastTransportError as Error)?.message ?? String(lastTransportError)
4563
+ })`
4564
+ : `timed out after ${timeoutMs}ms`;
4456
4565
  const seq = recordWait({
4457
4566
  description,
4458
4567
  attempts,
@@ -4472,8 +4581,12 @@ async function pollCall<T>(
4472
4581
  if (success) {
4473
4582
  return wrap(value as T, seq) as T;
4474
4583
  }
4584
+ const lastAttempt =
4585
+ lastTransportError !== undefined
4586
+ ? `; last attempt: ${(lastTransportError as Error)?.message ?? String(lastTransportError)}`
4587
+ : "";
4475
4588
  throw new Error(
4476
- `poll ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`,
4589
+ `poll ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts${lastAttempt})`,
4477
4590
  );
4478
4591
  }
4479
4592