fix(runtime): make error capture first-wins and crash-safe against a racing unmount (#182)

* fix(runtime): make error capture first-wins and crash-safe against a racing unmount

Two confirmed bugs in the InternalErrorBoundary's onErrorCaptured:

BUG #2 — a component error was silently swallowed when host code threw
during an update flush and then synchronously called app.unmount() in the
same task. The exit was routed entirely through `void nextTick(() =>
exitWithError(e))`, so pendingExitError was not recorded until that deferred
microtask ran; the racing unmount's resolveExit() read it as undefined and
RESOLVED the exit promise clean instead of REJECTING with the error.

Fix: record the error SYNCHRONOUSLY via a new recordExitError() bridge
(first-wins: only sets pendingExitError if no exit is already decided), while
keeping teardown DEFERRED via nextTick. Deferring teardown is load-bearing —
teardown()'s final mountedCommit() paints the ErrorOverview frame, and the
boundary's errored->true re-render must commit before it; a synchronous exit
would drop the overview frame on non-interactive/non-debug mounts. Frame/paint
timing is now byte-identical to before in every mode.

BUG #5 — two descendants throwing in the same synchronous flush left the
displayed overview (caught, last-wins) and the rejected error (pendingExitError,
first-wins) disagreeing. Fix: guard the capture body with `if (!errored.value)`
so the first thrown error drives both the display and the rejection (e17).

Tests: the racing-unmount swallow (interactive/debug AND non-interactive/
non-debug), the two-throw display/reject agreement, and frame-painting guards
that pin the overview behavior to main in each mode. Also corrected a stale
exit-chain comment in @vue-tui/testing's render().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): exit() must not clobber an error already recorded by the boundary (first-wins)

Final review found an asymmetry: recordExitError() first-wins-guards its write,
but appContext.exit() recorded the error unconditionally. So a captured throw
(Error1, shown in the overview, recorded via recordExitError) followed by a
racing exit(Error2) before the deferred teardown made waitUntilExit() reject
Error2 while the overview displayed Error1 — the BUG #5 display/reject
disagreement through a different door.

Fix: exit() uses `pendingExitError ??= errorOrResult`, so it keeps a
synchronously-recorded error. Identical to `=` in every other case
(pendingExitError is undefined on a normal first exit()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-15 01:16:51 +08:00
committed by GitHub
parent 670cca402a
commit 889e0cdb00
3 changed files with 407 additions and 17 deletions
@@ -0,0 +1,332 @@
import { PassThrough } from "node:stream";
import { defineComponent, h, nextTick, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import stripAnsi from "strip-ansi";
import { createApp, Text, useApp } from "@vue-tui/runtime";
import {
captureWrites,
getContentWrites,
makeFakeStdin,
makeFakeWritable,
} from "./test-streams.ts";
// A NON-TTY writable (isTTY=false). makeFakeWritable() forces isTTY=true, which
// makes the app interactive; for the non-interactive non-debug race below we need
// a piped stream so `interactive` derives false (render.ts:
// `options.interactive ?? (!isInCi && Boolean(stdout.isTTY))`). We also pass
// `interactive: false` explicitly so the case is deterministic regardless of CI.
function makeNonTtyWritable(): NodeJS.WriteStream {
const s = new PassThrough() as unknown as NodeJS.WriteStream;
Object.assign(s, { columns: 100, rows: 100, isTTY: false });
return s;
}
// These two tests pin down the error-capture/exit-race contract. They mount
// directly (not via @vue-tui/testing's render(), which re-throws and discards
// the painted frame) so we can read the ErrorOverview frame AND inspect what
// waitUntilExit() settles with from a single mount.
// --- BUG #2: error swallowed when unmount() races the deferred exit ---
// When a render-flush throw queues the boundary's exit and host code calls
// app.unmount() synchronously in the SAME task (before the deferred exit runs),
// the thrown error must still reject waitUntilExit(). Before the fix the
// pendingExitError was recorded only inside the deferred exit, so the racing
// unmount's resolveExit() read undefined and RESOLVED clean — swallowing the
// error. The fix (Option B) records pendingExitError SYNCHRONOUSLY in
// onErrorCaptured (recordExitError) while keeping teardown DEFERRED, so the race
// rejects with the error without disturbing frame/paint timing.
test("update-flush throw then synchronous unmount(): waitUntilExit REJECTS with the thrown error", async () => {
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const trigger = shallowRef(false);
const ThrowsOnUpdate = defineComponent(() => {
return () => {
if (trigger.value) {
throw new Error("UPDATE_FLUSH_BOOM");
}
return h(Text, null, "ok");
};
});
const app = createApp(ThrowsOnUpdate);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
type Settled = { kind: "rejected"; message: unknown } | { kind: "resolved"; value: unknown };
const done: Promise<Settled> = app.waitUntilExit().then(
(value: unknown): Settled => ({ kind: "resolved", value }),
(e: unknown): Settled => ({ kind: "rejected", message: (e as Error)?.message }),
);
// Let the initial mount flush.
await nextTick();
// Flip a ref that throws on render. The throw happens INSIDE the flush this
// await waits on, which queues the boundary's deferred exit.
trigger.value = true;
await nextTick();
// Race: synchronously unmount in the SAME task, before the queued exit runs.
app.unmount();
const settled = await done;
expect(settled.kind).toBe("rejected");
if (settled.kind !== "rejected") throw new Error("expected rejection, got resolve");
expect(settled.message).toBe("UPDATE_FLUSH_BOOM");
});
// --- BUG #5: two throws in one flush — overlay vs reject must AGREE ---
// If two siblings throw in the same synchronous flush, the DISPLAYED overview
// and the REJECTED error must be the SAME (first-thrown) error. Before the fix
// `caught` was last-wins (displayed B) while the exit path was first-wins
// (rejected A) — a display/reject mismatch.
test("two sibling throws in one flush: displayed overview and rejected error AGREE (both first-thrown)", async () => {
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
const ThrowerA = defineComponent(() => {
return () => {
throw new Error("ERROR_A_FIRST");
};
});
const ThrowerB = defineComponent(() => {
return () => {
throw new Error("ERROR_B_SECOND");
};
});
// Two siblings under one parent: both render (and throw) in the same flush.
const Root = defineComponent(() => {
return () => h(Text, null, [h(ThrowerA), h(ThrowerB)]);
});
const app = createApp(Root);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
type Reject = { kind: "rejected"; message: unknown } | { kind: "resolved" };
const settled: Promise<Reject> = app.waitUntilExit().then(
(): Reject => ({ kind: "resolved" }),
(e: unknown): Reject => ({ kind: "rejected", message: (e as Error)?.message }),
);
await new Promise<void>((r) => setImmediate(r));
await new Promise<void>((r) => setImmediate(r));
const reject = await settled;
const content = getContentWrites(writes);
const lastContentWrite = content.at(-1);
if (lastContentWrite === undefined) throw new Error("no content write captured");
const frame = stripAnsi(lastContentWrite);
// Reject is first-wins.
expect(reject.kind).toBe("rejected");
if (reject.kind !== "rejected") throw new Error("expected rejection");
expect(reject.message).toBe("ERROR_A_FIRST");
// Display agrees: the overview shows the SAME (first-thrown) error, not B.
expect(frame).toContain("ERROR_A_FIRST");
expect(frame).not.toContain("ERROR_B_SECOND");
});
// --- BUG #5, through the exit() door: a captured throw then a racing exit(err) ---
// Same display/reject contract as the two-sibling case, but the SECOND error
// arrives via host code calling useApp().exit(Error2) — not a second throw.
// Sequence: a descendant throws Error1 during a flush → the boundary's
// onErrorCaptured shows Error1 in the overview AND calls recordExitError(Error1),
// which sets pendingExitError=Error1 WITHOUT setting exitInitiated, then defers
// nextTick(exitWithError(Error1)). Before that microtask runs, host code calls
// exit(Error2) synchronously in the same task: exitInitiated is still false so
// exit() proceeds. With `pendingExitError = errorOrResult` it CLOBBERS to Error2,
// so the overview shows Error1 while waitUntilExit() rejects Error2 — the exact
// display/reject disagreement BUG #5 forbids. With `pendingExitError ??=` exit()
// keeps the already-recorded Error1, so display and reject AGREE.
test("captured throw then racing exit(err): displayed overview and rejected error AGREE (both Error1)", async () => {
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
const trigger = shallowRef(false);
// A non-throwing sibling retains exit() (via useApp()) so the host can call it
// synchronously from the test, after the boundary has captured but before the
// deferred exitWithError microtask runs.
// Held in an object so TS doesn't narrow it to `never`: the assignment happens
// inside Retainer's setup closure, which control-flow analysis can't see, so a
// plain `let` read after the await would narrow to its `null` initializer.
const retainer: { exit: ((errorOrResult?: unknown) => void) | null } = { exit: null };
const Retainer = defineComponent(() => {
retainer.exit = useApp().exit;
return () => h(Text, null, "retainer");
});
const ThrowsOnUpdate = defineComponent(() => {
return () => {
if (trigger.value) {
throw new Error("ERROR_1_THROWN");
}
return h(Text, null, "ok");
};
});
const Root = defineComponent(() => {
return () => h(Text, null, [h(Retainer), h(ThrowsOnUpdate)]);
});
const app = createApp(Root);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
type Settled = { kind: "rejected"; message: unknown } | { kind: "resolved"; value: unknown };
const done: Promise<Settled> = app.waitUntilExit().then(
(value: unknown): Settled => ({ kind: "resolved", value }),
(e: unknown): Settled => ({ kind: "rejected", message: (e as Error)?.message }),
);
// Let the initial mount flush so the Retainer has captured exit().
await nextTick();
const retainedExit = retainer.exit;
if (retainedExit === null) throw new Error("exit() was not retained from useApp()");
// Throw Error1 inside the update flush: onErrorCaptured runs synchronously
// during this flush — it shows Error1 in the overview and records it via
// recordExitError(Error1), then queues nextTick(exitWithError(Error1)).
trigger.value = true;
await nextTick();
// Race: host calls exit(Error2) in the SAME task, before the deferred
// exitWithError(Error1) microtask runs. With `=` this clobbers pendingExitError
// to Error2; with `??=` it leaves Error1 intact.
retainedExit(new Error("EXIT_ERROR_2"));
const settled = await done;
// Reject keeps the first (thrown, displayed) error.
expect(settled.kind).toBe("rejected");
if (settled.kind !== "rejected") throw new Error("expected rejection, got resolve");
expect(settled.message).toBe("ERROR_1_THROWN");
// Display agrees: the overview painted the SAME first error, never Error2.
const content = getContentWrites(writes);
const lastContentWrite = content.at(-1);
if (lastContentWrite === undefined) throw new Error("no content write captured");
const frame = stripAnsi(lastContentWrite);
expect(frame).toContain("ERROR_1_THROWN");
expect(frame).not.toContain("EXIT_ERROR_2");
});
// --- BUG #2, the NON-INTERACTIVE NON-DEBUG case ---
// This is the exact case the discarded synchronous-exit approach broke and the
// case Option B must handle: a piped (non-TTY) stdout with no debug flag. The
// racing app.unmount() runs teardown() + resolveExit() SYNCHRONOUSLY (only the
// boundary's deferred exitWithError teardown is microtask-driven), so resolveExit
// reads pendingExitError in the same task. The synchronous record
// (recordExitError) is what makes the race reject; remove it and this test goes
// RED (resolves clean — the original swallow).
test("non-interactive non-debug: update-flush throw + synchronous unmount() still REJECTS", async () => {
const stdout = makeNonTtyWritable();
const stderr = makeNonTtyWritable();
const { stream: stdin } = makeFakeStdin();
const trigger = shallowRef(false);
const ThrowsOnUpdate = defineComponent(() => {
return () => {
if (trigger.value) {
throw new Error("NONINTERACTIVE_BOOM");
}
return h(Text, null, "ok");
};
});
const app = createApp(ThrowsOnUpdate);
// Non-interactive (piped stdout), non-debug, interactive:false pinned.
app.mount({ stdout, stdin, stderr, interactive: false, exitOnCtrlC: false });
type Settled = { kind: "rejected"; message: unknown } | { kind: "resolved"; value: unknown };
const done: Promise<Settled> = app.waitUntilExit().then(
(value: unknown): Settled => ({ kind: "resolved", value }),
(e: unknown): Settled => ({ kind: "rejected", message: (e as Error)?.message }),
);
await nextTick();
// Throw inside the update flush, then race the unmount in the SAME task.
trigger.value = true;
await nextTick();
app.unmount();
const settled = await done;
expect(settled.kind).toBe("rejected");
if (settled.kind !== "rejected") throw new Error("expected rejection, got resolve");
expect(settled.message).toBe("NONINTERACTIVE_BOOM");
});
// --- FRAME-PAINTING regression guard (the synchronous approach would have
// broken the interactive/debug paint by letting teardown run before the
// errored→true re-render committed) ---
//
// Two mode-specific assertions, both pinned to MAIN's empirically-measured
// behavior (Option B keeps teardown timing byte-identical to main):
// - interactive/debug: main DOES paint the ErrorOverview frame (the last
// content write contains the error message). Option B must preserve that.
// - non-interactive non-debug: main does NOT paint ErrorOverview — its only
// content write is a bare trailing "\n" (Ink's `this.lastOutput + '\n'`
// branch, and lastOutput is empty because the dynamic frame was deferred and
// the boundary's error frame is never committed on this path). We assert the
// fix MATCHES main: no error message reaches stdout, just the trailing
// newline. (Verified by probe: main writes ["\n",""]; Option B writes the
// same.)
test("interactive/debug: an error STILL paints the ErrorOverview frame", async () => {
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
const Throws = defineComponent(() => {
return () => {
throw new Error("PAINTED_BOOM");
};
});
const app = createApp(Throws);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
app.waitUntilExit().catch(() => {});
await new Promise<void>((r) => setImmediate(r));
await new Promise<void>((r) => setImmediate(r));
const content = getContentWrites(writes);
const lastContentWrite = content.at(-1);
if (lastContentWrite === undefined) throw new Error("no content write captured");
const frame = stripAnsi(lastContentWrite);
expect(frame).toContain("PAINTED_BOOM");
});
test("non-interactive non-debug: error paint MATCHES main (no overview frame, just trailing newline)", async () => {
const stdout = makeNonTtyWritable();
const stderr = makeNonTtyWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
const Throws = defineComponent(() => {
return () => {
throw new Error("UNPAINTED_BOOM");
};
});
const app = createApp(Throws);
app.mount({ stdout, stdin, stderr, interactive: false, exitOnCtrlC: false });
app.waitUntilExit().catch(() => {});
await new Promise<void>((r) => setImmediate(r));
await new Promise<void>((r) => setImmediate(r));
await new Promise<void>((r) => setImmediate(r));
// Main paints NO ErrorOverview here: the error message never reaches stdout.
const allContent = writes.join("");
expect(stripAnsi(allContent)).not.toContain("UNPAINTED_BOOM");
// The only content write is the trailing newline teardown owes (lastFrame is
// empty + "\n"), matching Ink's non-interactive non-debug branch.
const content = getContentWrites(writes);
expect(content).toEqual(["\n"]);
});