fix(runtime): reject non-Error throws with the message ErrorOverview displays (#158)

A thrown non-Error whose .message is a string (throw {message:'x'})
displayed 'x' in the ErrorOverview but rejected waitUntilExit() with
new Error(String(value)) = '[object Object]' — display and reject
disagreed. Introduce one messageForNonError(value) helper (string
.message else String(value)) and feed it to BOTH the overview header
and the two non-Error reject-wrap sites, so the shown and rejected
messages can never drift. Overview output is byte-identical (the helper
is the prior inline logic extracted); real-Error, cross-realm, and
no-synthetic-stack paths are unchanged.

Blesses vue-tui's uniform show-the-error-and-reject behavior for any
thrown value (audit e17): Ink instead resolves waitUntilExit() with a
truthy thrown value and silently hangs on a falsy throw — abnormal, so
vue-tui deliberately diverges. Ledger entry rewritten to the full
run-verified scope with Maintainer decision (2026-06-12): KEEP.

Red-first: a consistency test asserting throw {message:'objmsg'} shows
AND rejects 'objmsg'.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-12 21:15:48 +08:00
committed by GitHub
parent 86b94b9fa5
commit d1fe39f96c
4 changed files with 170 additions and 26 deletions
@@ -22,6 +22,28 @@ const stackUtils = new StackUtils({
internals: StackUtils.nodeInternals(),
});
// The user-facing message for a NON-Error thrown value. SINGLE source of truth
// shared by the ErrorOverview header (what the user SEES) and render.ts's
// reject-wrap (`new Error(messageForNonError(value))`, what waitUntilExit()
// REJECTS with), so the displayed and rejected messages can never drift apart
// (audit finding e17). Prefer a string `.message` (covers `throw {message:'x'}`
// and a cross-realm Error read structurally); otherwise fall back to
// `String(value)` (covers `throw 'boom'`, `throw 42`, a non-string `.message`).
export function messageForNonError(value: unknown): string {
// Read `.message` exactly once: the typecheck and the returned value must see
// the SAME read, and the read is guarded because this feeds the error-display
// / reject path — it must not itself throw on a pathological thrown object
// (e.g. a `.message` getter that throws), which the old `String(value)` form
// never touched. Fall back to `String(value)` on any failure, as before.
let message: unknown;
try {
message = (value as { message?: unknown })?.message;
} catch {
return String(value);
}
return typeof message === "string" ? message : String(value);
}
export const ErrorOverview = defineComponent({
name: "ErrorOverview",
props: {
@@ -43,11 +65,10 @@ export const ErrorOverview = defineComponent({
: undefined;
// Ink renders `{error.message}`. A cross-realm Error has a different
// prototype and fails `instanceof Error`, so read a string `.message`
// structurally; primitives still fall back to String(value).
const errorMessage =
typeof (error as { message?: unknown })?.message === "string"
? (error as { message: string }).message
: String(error);
// structurally; primitives still fall back to String(value). The same
// helper computes the message render.ts rejects waitUntilExit() with, so
// the shown and rejected messages stay identical (e17).
const errorMessage = messageForNonError(error);
// First stack line is the message; the rest are frames. The first frame
// is the throw origin used for the file:line:col header and excerpt.
+8 -5
View File
@@ -44,7 +44,7 @@ import {
} from "./context.ts";
import { devState, DevStateKey, initHmrBridge } from "./hmr.ts";
import { createDevOverlayWrapper } from "./overlay.ts";
import { ErrorOverview } from "./components/error-overview.ts";
import { ErrorOverview, messageForNonError } from "./components/error-overview.ts";
import { resolveSize } from "./composables/useTerminalSize.ts";
export interface MountOptions {
@@ -502,8 +502,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// `instanceof Error`, passes the `[object Error]` brand check) — so the
// ORIGINAL thrown error reaches exit()/waitUntilExit() unchanged,
// matching Ink's ErrorBoundary (rejects with the thrown value itself).
// A true non-Error throw (`throw "x"`, `throw 0`) is still wrapped.
const e = isErrorInput(err) ? err : new Error(String(err));
// A true non-Error throw (`throw "x"`, `throw 0`, `throw {message:'x'}`)
// is wrapped with the SAME message ErrorOverview displays
// (messageForNonError), so the shown and rejected messages agree (e17).
const e = isErrorInput(err) ? err : new Error(messageForNonError(err));
caught.value = err;
errored.value = true;
// Flush the ErrorOverview frame, then exit
@@ -1218,8 +1220,9 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// false to stop propagation, so caught errors won't reach here.
baseApp.config.errorHandler = (err) => {
// Preserve a genuine (incl. cross-realm) Error so the original survives to
// exit(); only wrap a true non-Error. See isErrorInput / onErrorCaptured.
appContext.exit(isErrorInput(err) ? err : new Error(String(err)));
// exit(); only wrap a true non-Error — with the SAME message ErrorOverview
// displays (messageForNonError, e17). See isErrorInput / onErrorCaptured.
appContext.exit(isErrorInput(err) ? err : new Error(messageForNonError(err)));
};
// Only listen for resize in interactive mode (matching Ink).