From f78121f6e736764ca5526de4a5c6759ba6464e46 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 30 May 2026 10:43:35 +0800 Subject: [PATCH] fix(runtime): make exit() first-call-wins (Ink parity, G33) (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first exit() call now captures its value/error and initiates teardown synchronously; subsequent exit() calls are complete no-ops, so waitUntilExit resolves/rejects with the FIRST value rather than the last. This mirrors Ink's handleAppExit guard (isUnmounted || isUnmounting → early return). Previously each exit() queued a microtask that overwrote pendingExitResult/ pendingExitError before resolveExit ran, making it last-wins. An exitInitiated flag set at the top of exit() now guards the value capture and re-resolve, while the deferred microtask teardown (needed because exit() is called from inside the Vue update cycle) is preserved. Reverses the sweep-1 refutation: exit() was NOT already first-wins guarded. Also guard unmount-in-progress (isUnmounting parity) + value→error test. Co-authored-by: Claude Opus 4.8 --- .agents/docs/parity-ledger.md | 5 +- .../integration/lifecycle/exit.test.tsx | 163 ++++++++++++++++-- packages/runtime/src/render.ts | 39 ++++- 3 files changed, 181 insertions(+), 26 deletions(-) diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index 43b590d..52c4e7d 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -29,6 +29,7 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor - **2026-05-30 — G02 (useAnimation throttle):** to actually fix the gap by default (not just when a caller passes maxFps), `maxFps` now defaults to **30** (Ink: `options.maxFps ?? 30`) and a single `renderThrottleMs = ceil(1000/maxFps)` feeds BOTH the commit scheduler and `createAnimationScheduler`, mirroring Ink's one-value architecture. Behavior change: the default commit-throttle shifts 32ms→34ms (no test depended on 32ms; explicit `maxFps` already drove commit cadence on main). Test rewrite: the pre-existing `"delta accounts for throttled ticks"` test was weak/self-contradictory (asserted only `delta>0`, passed trivially against the bug) — replaced with `"delta accumulates across coalesced ticks…"` (maxFps:5, 200ms window) plus a default-path test using a count-based assertion (≈31 rendered ticks unfixed vs <15 fixed) so it deterministically discriminates. Both verified red on unfixed. - **2026-05-30 — G05+G15 (border geometry):** rewrote `paint.ts` drawBorder to draw each edge independently (mirroring Ink render-border.ts): removed the blanket `w<2||h<2` early-return (→ `w<1||h<1`) and changed the vertical-side loop to start at `offsetY = top?1:0` with run length `max(0, h - visibleTop - visibleBottom)`. Per conflict policy, 4 existing snapshot tests that encoded the buggy hide-top/bottom output were updated to Ink-correct values (rails on the content row, not shifted/missing) — codex traced each against Ink render-border.ts and confirmed they match (not just code-blessed). Normal (top+bottom) boxes are byte-identical to before. - **2026-05-30 — G07 → CANDIDATE (needs human decision, not auto-fixed):** Ink genuinely does NOT exit on kitty-protocol Ctrl+C (`use-input.ts:245-247` only returns; the `\x03` exit path never fires under kitty). vue-tui DOES exit on kitty Ctrl+C (useInput.ts:100-105 calls app.exit()). But vue-tui's behavior is arguably BETTER UX (Ctrl+C exits under all protocols) and Ink's non-exit looks like a kitty-era oversight, not a deliberate design. Rather than auto-remove a working Ctrl+C-exits behavior to match an Ink limitation, appended it to [[ink-parity]]'s 'Candidate intentional divergences' section for the maintainer to decide (keep & allowlist, or match Ink). No code change. +- **2026-05-30 — G33 (exit() first-wins) — REVERSES sweep-1 refutation + test rewrite:** sweep-1 wrongly refuted exit()-second-wins ('already guarded'); sweep-4 + a red test confirmed vue-tui was LAST-wins. Added an `exitInitiated || teardownStarted` guard (Ink's `isUnmounted || isUnmounting` first-wins). Per conflict policy, 3 existing exit tests that ASSERTED the last-wins bug (resolving 'second', with comments noting Ink does first-wins) were rewritten to assert first-wins ('first') — they had documented the divergence; now aligned to Ink. - **2026-05-30 — G23 (SR ) — spec corrected via empirical Ink check:** the sweep-2 finding claimed Ink applies the Transform's fn to its squashed SR children. Building Ink from source and tracing squash-text-nodes.ts showed Ink only applies `internal_transform` of CHILD nodes, never the top-level node handed to squashTextNodes — so a `` directly under a `` outputs its children CONCATENATED with no transform. Only the `\n`→`""` join was a real bug. Fixed to match Ink (concat, no top-level transform); applying it would have DIVERGED. (Per align-with-Ink; codex independently confirmed.) - **2026-05-29 — G06 REFUTED (false positive from the audit):** the audit claimed ``'s fn gets a hardcoded index `0` "instead of the childNode index". Re-verification against Ink `output.ts:230-239` shows Ink's index is the **line index** (transformers apply per output line: `transformer(line, index)`), not a child index — the audit misread it. vue-tui **already** applies per-line line indices for multi-line (block) transforms via the yoga-carrier path: the existing tests `transform with multiple lines` → `[0: hello world]\n[1: goodbye world]` and transform-yoga `[0: hello]\n[1: world]` pass on unmodified code. `paint.ts:314`'s `transform(innerText, 0)` is only the inline ``-inside-`` path, whose content is a single logical line where `0` matches Ink (all inline tests assert `[0: …]`). No observable gap; not fixed. @@ -69,8 +70,8 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor | G29 | render-lifecycle-reconciler | useCursor()/setCursorPosition never applied during normal render commits (only after console writes) | P3 | todo | — | — | | G30 | app-exit-instances-animation-sr | SR: nested inside a box-level drops the INNER transform fn (refines G23) | P3 | todo | — | — | | G31 | app-exit-instances-animation-sr | useAnimation `interval` option is not reactive; Ink re-subscribes+resets when interval changes (cf. G08 id-reactivity) | P3 | todo | — | — | -| G32 | text-wrap-transform | nested directly inside another (in ) is silently DROPPED — paint+measure lose all content | P1 | todo | — | — | -| G33 | app-exit-instances-animation-sr | exit() resolves last-call-wins; Ink is first-call-wins (REVERSES the sweep-1 exit()-second-wins refutation) | P1 | todo | — | — | +| G32 | text-wrap-transform | nested directly inside another (in ) is silently DROPPED — paint+measure lose all content | P1 | merged | `fix/parity-transform-nesting` | #54 | +| G33 | app-exit-instances-animation-sr | exit() resolves last-call-wins; Ink is first-call-wins (REVERSES the sweep-1 exit()-second-wins refutation) | P1 | pr-open | `fix/parity-exit-first-wins` | #55 | | G34 | box-layout-border | Box border glyphs + bg fill are subject to ancestor transformers; Ink renders chrome with empty transformer list | P3 | todo | — | — | | G35 | static-newline-spacer | container's own borderStyle/backgroundColor (non-yoga visual style) not painted | P3 | todo | — | — | | G36 | focus | useFocus autoFocus prop not reactive (captured once); Ink re-registers on autoFocus change | P3 | todo | — | — | diff --git a/packages/runtime-tests/integration/lifecycle/exit.test.tsx b/packages/runtime-tests/integration/lifecycle/exit.test.tsx index c897eae..499aa75 100644 --- a/packages/runtime-tests/integration/lifecycle/exit.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/exit.test.tsx @@ -151,7 +151,8 @@ test("onScopeDispose fires when exit(error) is called", async () => { test("exit(error) followed by exit(value) still rejects", async () => { // Edge case: when exit is called with an error first, a subsequent exit() // call with a plain value should not override the rejection. The error - // should take precedence because teardown runs only once. + // should take precedence because the FIRST exit() call wins (Ink parity G33, + // isUnmounted||isUnmounting guard). let exitFn!: (errorOrResult?: unknown) => void; const App = defineComponent(() => { @@ -168,10 +169,9 @@ test("exit(error) followed by exit(value) still rejects", async () => { await expect(waitUntilExit()).rejects.toThrow("first-error"); }); -test("exit(value) resolves even when called rapidly twice", async () => { - // Verifies rapid duplicate exit() calls don't crash or hang. - // Both calls queue microtasks; teardown() is idempotent so - // the app shuts down cleanly regardless. +test("exit(value) resolves with the FIRST value when called rapidly twice", async () => { + // First-call-wins (Ink parity G33): the FIRST exit() captures the value and + // initiates teardown; the second is a no-op. waitUntilExit resolves "first". let exitFn!: (errorOrResult?: unknown) => void; const App = defineComponent(() => { @@ -184,20 +184,144 @@ test("exit(value) resolves even when called rapidly twice", async () => { exitFn("first"); exitFn("second"); - // Should resolve without throwing or hanging; second value wins because - // both exit() calls queue microtasks that overwrite pendingExitResult - // before the write barrier fires. const result = await waitUntilExit(); - expect(result).toBe("second"); + expect(result).toBe("first"); +}); + +test("exit(err1) then exit(err2) rejects with the FIRST error", async () => { + // First-call-wins for errors (Ink parity G33): the first error is captured + // and the second exit() is a no-op, so waitUntilExit rejects with err1. + let exitFn!: (errorOrResult?: unknown) => void; + + const App = defineComponent(() => { + exitFn = useExit(); + return () => hello; + }); + + const { waitUntilExit } = await render(App); + + const err1 = new Error("err1"); + const err2 = new Error("err2"); + exitFn(err1); + exitFn(err2); + + await expect(waitUntilExit()).rejects.toBe(err1); +}); + +test("exit(value) then exit(error) resolves with the FIRST value", async () => { + // value→error ordering (Ink parity G33): the FIRST exit() captures the value + // and initiates teardown; the later exit(error) is a complete no-op, so + // waitUntilExit RESOLVES with the original value rather than rejecting. + let exitFn!: (errorOrResult?: unknown) => void; + + const App = defineComponent(() => { + exitFn = useExit(); + return () => hello; + }); + + const { waitUntilExit } = await render(App); + + exitFn("x"); + exitFn(new Error("e")); + + await expect(waitUntilExit()).resolves.toBe("x"); +}); + +test("exit('late') after app.unmount() is a no-op (unmount value wins)", async () => { + // isUnmounting parity (Ink parity G33): app.unmount() runs teardown()+ + // resolveExit() without setting exitInitiated. A retained useExit() called + // AFTER unmount has started teardown must be a complete no-op — it must not + // overwrite the resolved exit value. waitUntilExit resolves the original + // unmount value (undefined), NOT 'late'. Without the teardownStarted guard in + // exit(), the late exit captures 'late' into pendingExitResult and 'late' + // wins; the guard makes it a no-op. + let exitFn!: (errorOrResult?: unknown) => void; + + const App = defineComponent(() => { + exitFn = useExit(); + return () => hello; + }); + + const { unmount, waitUntilExit } = await render(App); + + unmount(); + exitFn("late"); + + await expect(waitUntilExit()).resolves.toBeUndefined(); +}); + +test("retained exit() re-entered DURING unmount teardown writes is a no-op", async () => { + // isUnmounting parity (Ink parity G33), faithful reentrancy: a useExit() + // captured during setup is invoked re-entrantly from inside the stdout write + // that unmount()'s final commit performs. teardownStarted is already true at + // that point, so exit("reentrant") is a complete no-op and the original + // unmount value (undefined) wins. Without the teardownStarted guard the + // re-entrant exit would overwrite pendingExitResult before resolveExit runs. + let exitFn: ((value?: unknown) => void) | undefined; + let shouldReenterExit = false; + let didReenterExit = false; + + const stdout = new Writable({ + write( + _chunk: string | Uint8Array, + _encoding: BufferEncoding, + callback: (error?: Error) => void, + ) { + if (shouldReenterExit && !didReenterExit && exitFn) { + didReenterExit = true; + exitFn("reentrant"); + } + callback(); + }, + }) as unknown as NodeJS.WriteStream; + stdout.columns = 100; + stdout.isTTY = true; + + const App = defineComponent(() => { + const exit = useExit(); + onMounted(() => { + exitFn = exit; + }); + return () => Hello; + }); + + const app = createApp(App); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + + // Let the app mount and capture exitFn, then trigger unmount. unmount()'s + // final commit writes to stdout, which re-enters exit("reentrant") while + // teardownStarted is already true. + await new Promise((r) => setTimeout(r, 0)); + shouldReenterExit = true; + app.unmount(); + + const result = await app.waitUntilExit(); + expect(didReenterExit).toBe(true); + expect(result).toBeUndefined(); +}); + +test("single exit('x') resolves with 'x' (control)", async () => { + let exitFn!: (errorOrResult?: unknown) => void; + + const App = defineComponent(() => { + exitFn = useExit(); + return () => hello; + }); + + const { waitUntilExit } = await render(App); + + exitFn("x"); + await expect(waitUntilExit()).resolves.toBe("x"); }); // --- Exit re-entrance tests (ported from Ink render.tsx) --- -test("waitUntilExit resolves last exit value when duplicate exits happen during teardown", async () => { - // In vue-tui, exit() queues a microtask that overwrites pendingExitResult - // before the write barrier callback fires. When exit() is called twice, - // the second call's value wins because both microtasks run before the - // write barrier resolves (Ink preserves the first value instead). +test("waitUntilExit resolves FIRST exit value when duplicate exits happen during teardown", async () => { + // First-call-wins (Ink parity G33): the FIRST exit() captures the value and + // initiates teardown; a later exit() is a complete no-op, so waitUntilExit + // resolves "first" regardless of write-barrier timing. let barrierWriteCallback: (() => void) | undefined; const stdout = new Writable({ @@ -236,12 +360,13 @@ test("waitUntilExit resolves last exit value when duplicate exits happen during barrierWriteCallback(); } const result = await exitPromise; - expect(result).toBe("second"); + expect(result).toBe("first"); }); -test("waitUntilExit resolves last exit value when exit is re-entered during unmount writes", async () => { - // Same as above: the re-entrant exit("second") overwrites pendingExitResult - // because teardown() is idempotent but the result assignment still executes. +test("waitUntilExit resolves FIRST exit value when exit is re-entered during unmount writes", async () => { + // First-call-wins (Ink parity G33): a re-entrant exit("second") during the + // unmount write is a no-op because exitInitiated is already set, so + // waitUntilExit resolves the original "first" value. let exitFn: ((value?: unknown) => void) | undefined; let shouldReenterExit = false; let didReenterExit = false; @@ -279,7 +404,7 @@ test("waitUntilExit resolves last exit value when exit is re-entered during unmo const result = await app.waitUntilExit(); expect(didReenterExit).toBe(true); - expect(result).toBe("second"); + expect(result).toBe("first"); }); test("exit with cross-realm Error resolves after stdout write callback", async () => { diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 13d1996..370ccd3 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -175,6 +175,15 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // Used by the error boundary to route errors through exit(). let exitWithError: (e: Error) => void = () => {}; + // First-call-wins guard for exit() (Ink parity G33). Ink's handleAppExit + // returns early on `isUnmounted || isUnmounting`, so the FIRST exit() call + // captures the value/error and initiates teardown while any subsequent + // exit() is a complete no-op. This flag mirrors that guard: it is set + // synchronously by the first exit() so a re-entrant exit() (e.g. fired from + // inside an unmount-time write callback or a later Vue tick) cannot overwrite + // the recorded value or re-resolve the exit promise with a later value. + let exitInitiated = false; + let mountedRoot: TuiRoot | null = null; let mountedWriter: ReturnType | null = null; let mountedStdinController: StdinController | null = null; @@ -528,16 +537,36 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp const appContext: AppContext = { exit(errorOrResult?: unknown) { + // First-call-wins guard (Ink parity G33, mirrors handleAppExit's + // `if (this.isUnmounted || this.isUnmounting) return;`): the FIRST + // exit() captures its value/error and initiates teardown; any + // SUBSEQUENT exit() is a no-op so it can neither overwrite the recorded + // value nor re-resolve the exit promise with a later value. + // + // teardownStarted mirrors Ink's `isUnmounting` half of that guard: + // app.unmount() runs teardown()+resolveExit() WITHOUT setting + // exitInitiated, so a retained useExit() called re-entrantly DURING + // unmount teardown (or any exit() after unmount) would otherwise pass + // the exitInitiated check, overwrite pendingExitResult/pendingExitError + // and queue a microtask — letting that late value win over the unmount. + // Gating on teardownStarted too makes exit() a no-op once unmount/ + // teardown is in progress. At the FIRST exit() both flags are false, so + // a normal exit-from-Vue-cycle still proceeds. + if (exitInitiated || teardownStarted) return; + exitInitiated = true; + // Record the FIRST value/error synchronously (before the deferred + // teardown microtask) so a re-entrant exit() — which is blocked above + // anyway — and the eventual resolveExit() always settle on this value. + if (errorOrResult instanceof Error) { + pendingExitError = errorOrResult; + } else { + pendingExitResult = errorOrResult; + } // Defer teardown to a microtask: exit() is frequently called from // inside the Vue update cycle (useInput handler, setup(), errorHandler) // and unmounting synchronously would tear Vue down mid-flush. queueMicrotask(() => { teardown(); - if (errorOrResult instanceof Error) { - pendingExitError = errorOrResult; - } else { - pendingExitResult = errorOrResult; - } resolveExit(); }); },