@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 +111 -5
- package/package.json +1 -1
- package/src/daemon.ts +118 -5
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
|
-
|
|
3622
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
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
|
-
|
|
4444
|
-
|
|
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
|
-
:
|
|
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
|
|