@specific.dev/spectest 0.53.0 → 0.54.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/daemon.js +112 -5
- package/dist/recorder.d.ts +8 -0
- package/dist/terminal.js +29 -7
- package/package.json +1 -1
- package/src/daemon.ts +119 -5
- package/src/recorder.ts +8 -0
- package/src/terminal.ts +28 -7
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"];
|
|
@@ -3822,6 +3928,7 @@ async function runOne(testCase) {
|
|
|
3822
3928
|
rows: term.rows,
|
|
3823
3929
|
outputPreview: preview.value,
|
|
3824
3930
|
outputTruncated: preview.truncated,
|
|
3931
|
+
interactive: true,
|
|
3825
3932
|
});
|
|
3826
3933
|
return term;
|
|
3827
3934
|
};
|
package/dist/recorder.d.ts
CHANGED
|
@@ -481,6 +481,14 @@ export interface TerminalEvent extends BaseEvent {
|
|
|
481
481
|
outputTruncated: boolean;
|
|
482
482
|
/** Set if spawning the PTY itself failed. */
|
|
483
483
|
error?: string;
|
|
484
|
+
/**
|
|
485
|
+
* Set on the open marker of an interactive session (`ctx.terminal.open`).
|
|
486
|
+
* A one-shot `ctx.terminal(...)` event covers a whole command run, so the
|
|
487
|
+
* UI seeks its replay to the end; the open marker is the state at open,
|
|
488
|
+
* so the UI seeks to 0 instead. Older recordings lack this flag — the UI
|
|
489
|
+
* falls back to the marker's sentinel shape (exitCode -1, durationMs 0).
|
|
490
|
+
*/
|
|
491
|
+
interactive?: boolean;
|
|
484
492
|
}
|
|
485
493
|
/** Action taken on an open interactive terminal session. */
|
|
486
494
|
export type TerminalStepAction = "send" | "sendLine" | "press" | "waitFor" | "exit" | "close";
|
package/dist/terminal.js
CHANGED
|
@@ -128,8 +128,23 @@ export async function openTerminal(args) {
|
|
|
128
128
|
// sendLine / press so the recorded `castTimeSec` lands on the echo's
|
|
129
129
|
// frame instead of microseconds before it.
|
|
130
130
|
const frameWaiters = [];
|
|
131
|
+
// Time of the most recently ingested frame. Two jobs: (1) frame
|
|
132
|
+
// timestamps are forced strictly increasing (Date.now() has ms
|
|
133
|
+
// resolution, so a fast PTY can land two frames — or two *ops* —
|
|
134
|
+
// in the same millisecond), and (2) it is what `recordOpEvent`
|
|
135
|
+
// stamps as the step's `castTimeSec`. Together they make the
|
|
136
|
+
// player's `seek(castTimeSec)` (which applies frames `<= target`)
|
|
137
|
+
// land on exactly the frames this op had seen: the step's own last
|
|
138
|
+
// frame is included, and the next op's first frame can never share
|
|
139
|
+
// the stamp. Before this, a `waitFor` that matched in the same
|
|
140
|
+
// millisecond as the following `sendLine`'s echo showed that echo
|
|
141
|
+
// — the replay ran one step ahead of the step list.
|
|
142
|
+
let lastFrameT = 0;
|
|
131
143
|
const ingest = (chunk) => {
|
|
132
|
-
|
|
144
|
+
let t = (Date.now() - start) / 1000;
|
|
145
|
+
if (t <= lastFrameT)
|
|
146
|
+
t = lastFrameT + 0.001;
|
|
147
|
+
lastFrameT = t;
|
|
133
148
|
sink.pushFrame(t, chunk);
|
|
134
149
|
emu.write(chunk);
|
|
135
150
|
if (output.length < OUTPUT_CAP_BYTES) {
|
|
@@ -223,8 +238,8 @@ export async function openTerminal(args) {
|
|
|
223
238
|
}
|
|
224
239
|
// Record cast time AFTER drain, so the `exit` step we record below
|
|
225
240
|
// points the player at the very last frame (the program's final
|
|
226
|
-
// output before EOF).
|
|
227
|
-
exitCastTimeSec =
|
|
241
|
+
// output before EOF). Same between-frames stamp as `recordOpEvent`.
|
|
242
|
+
exitCastTimeSec = lastFrameT + 0.0005;
|
|
228
243
|
sink.markClosed();
|
|
229
244
|
resolveExit(code);
|
|
230
245
|
})();
|
|
@@ -265,10 +280,17 @@ export async function openTerminal(args) {
|
|
|
265
280
|
if (!args.recordEvents)
|
|
266
281
|
return undefined;
|
|
267
282
|
const preview = truncateUtf8(renderScreen());
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
283
|
+
// Stamp the step half a millisecond past its last ingested frame:
|
|
284
|
+
// frame times are strictly increasing in 1ms steps (see `ingest`),
|
|
285
|
+
// so the stamp sits strictly BETWEEN the op's own last frame and
|
|
286
|
+
// any later op's first frame. That makes the boundary unambiguous
|
|
287
|
+
// in both directions — an inclusive seek (`<= stamp`) shows the
|
|
288
|
+
// op's own frame and can never pull in the next op's, and the
|
|
289
|
+
// viewer's exclusive `waitFor` seek (stamp − 0.0004, which
|
|
290
|
+
// disambiguates legacy wall-clock stamps that tied with the next
|
|
291
|
+
// op's echo) still lands past the op's own frame. Same clock as
|
|
292
|
+
// the frames (offset from `start`), so the UI seeks directly.
|
|
293
|
+
const castTimeSec = lastFrameT + 0.0005;
|
|
272
294
|
// Return the event seq so callers can `wrap()` their result value
|
|
273
295
|
// against it — e.g. `expect(await term.waitFor(...))` then links its
|
|
274
296
|
// assertion to this step (sourceSeq) and the UI nests it here.
|
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
|
|
|
@@ -4702,6 +4815,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
4702
4815
|
rows: term.rows,
|
|
4703
4816
|
outputPreview: preview.value,
|
|
4704
4817
|
outputTruncated: preview.truncated,
|
|
4818
|
+
interactive: true,
|
|
4705
4819
|
});
|
|
4706
4820
|
return term;
|
|
4707
4821
|
};
|
package/src/recorder.ts
CHANGED
|
@@ -537,6 +537,14 @@ export interface TerminalEvent extends BaseEvent {
|
|
|
537
537
|
outputTruncated: boolean;
|
|
538
538
|
/** Set if spawning the PTY itself failed. */
|
|
539
539
|
error?: string;
|
|
540
|
+
/**
|
|
541
|
+
* Set on the open marker of an interactive session (`ctx.terminal.open`).
|
|
542
|
+
* A one-shot `ctx.terminal(...)` event covers a whole command run, so the
|
|
543
|
+
* UI seeks its replay to the end; the open marker is the state at open,
|
|
544
|
+
* so the UI seeks to 0 instead. Older recordings lack this flag — the UI
|
|
545
|
+
* falls back to the marker's sentinel shape (exitCode -1, durationMs 0).
|
|
546
|
+
*/
|
|
547
|
+
interactive?: boolean;
|
|
540
548
|
}
|
|
541
549
|
|
|
542
550
|
/** Action taken on an open interactive terminal session. */
|
package/src/terminal.ts
CHANGED
|
@@ -305,8 +305,22 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
|
|
|
305
305
|
// sendLine / press so the recorded `castTimeSec` lands on the echo's
|
|
306
306
|
// frame instead of microseconds before it.
|
|
307
307
|
const frameWaiters: Array<() => void> = [];
|
|
308
|
+
// Time of the most recently ingested frame. Two jobs: (1) frame
|
|
309
|
+
// timestamps are forced strictly increasing (Date.now() has ms
|
|
310
|
+
// resolution, so a fast PTY can land two frames — or two *ops* —
|
|
311
|
+
// in the same millisecond), and (2) it is what `recordOpEvent`
|
|
312
|
+
// stamps as the step's `castTimeSec`. Together they make the
|
|
313
|
+
// player's `seek(castTimeSec)` (which applies frames `<= target`)
|
|
314
|
+
// land on exactly the frames this op had seen: the step's own last
|
|
315
|
+
// frame is included, and the next op's first frame can never share
|
|
316
|
+
// the stamp. Before this, a `waitFor` that matched in the same
|
|
317
|
+
// millisecond as the following `sendLine`'s echo showed that echo
|
|
318
|
+
// — the replay ran one step ahead of the step list.
|
|
319
|
+
let lastFrameT = 0;
|
|
308
320
|
const ingest = (chunk: string): void => {
|
|
309
|
-
|
|
321
|
+
let t = (Date.now() - start) / 1000;
|
|
322
|
+
if (t <= lastFrameT) t = lastFrameT + 0.001;
|
|
323
|
+
lastFrameT = t;
|
|
310
324
|
sink.pushFrame(t, chunk);
|
|
311
325
|
emu.write(chunk);
|
|
312
326
|
if (output.length < OUTPUT_CAP_BYTES) {
|
|
@@ -397,8 +411,8 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
|
|
|
397
411
|
}
|
|
398
412
|
// Record cast time AFTER drain, so the `exit` step we record below
|
|
399
413
|
// points the player at the very last frame (the program's final
|
|
400
|
-
// output before EOF).
|
|
401
|
-
exitCastTimeSec =
|
|
414
|
+
// output before EOF). Same between-frames stamp as `recordOpEvent`.
|
|
415
|
+
exitCastTimeSec = lastFrameT + 0.0005;
|
|
402
416
|
sink.markClosed();
|
|
403
417
|
resolveExit(code);
|
|
404
418
|
})();
|
|
@@ -444,10 +458,17 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
|
|
|
444
458
|
): number | undefined => {
|
|
445
459
|
if (!args.recordEvents) return undefined;
|
|
446
460
|
const preview = truncateUtf8(renderScreen());
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
461
|
+
// Stamp the step half a millisecond past its last ingested frame:
|
|
462
|
+
// frame times are strictly increasing in 1ms steps (see `ingest`),
|
|
463
|
+
// so the stamp sits strictly BETWEEN the op's own last frame and
|
|
464
|
+
// any later op's first frame. That makes the boundary unambiguous
|
|
465
|
+
// in both directions — an inclusive seek (`<= stamp`) shows the
|
|
466
|
+
// op's own frame and can never pull in the next op's, and the
|
|
467
|
+
// viewer's exclusive `waitFor` seek (stamp − 0.0004, which
|
|
468
|
+
// disambiguates legacy wall-clock stamps that tied with the next
|
|
469
|
+
// op's echo) still lands past the op's own frame. Same clock as
|
|
470
|
+
// the frames (offset from `start`), so the UI seeks directly.
|
|
471
|
+
const castTimeSec = lastFrameT + 0.0005;
|
|
451
472
|
// Return the event seq so callers can `wrap()` their result value
|
|
452
473
|
// against it — e.g. `expect(await term.waitFor(...))` then links its
|
|
453
474
|
// assertion to this step (sourceSeq) and the UI nests it here.
|