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:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user