From 05c4dd50765b48af1fdf348fef4d4f6ee8d34e05 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Tue, 26 May 2026 22:18:56 +0800 Subject: [PATCH] feat: add beforeExit natural unmount + stdout write barrier Register process.once('beforeExit') in waitUntilExit() so TUI apps that finish naturally (no explicit exit/unmount) clean up before the event loop drains. Add stdout write barrier in resolveExit() to ensure all output is flushed before settling the exit promise. Update testing render helper to await the additional async tick introduced by the write barrier so early-error detection still works. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/runtime/src/render.ts | 43 +++++++++++++++++++++++++++++++--- packages/testing/src/render.ts | 8 ++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 6598fd6..def4bde 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -113,6 +113,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp let mountedAppContext: AppContext | null = null; let mountedResizeHandler: (() => void) | null = null; let mountedExitListener: (() => void) | null = null; + let mountedBeforeExitHandler: (() => void) | null = null; let mountedDebug = false; let mountedInteractive = true; let mountedRawMode = false; @@ -125,6 +126,31 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // even though it's not used until mount. let scheduledCommit: () => void = () => {}; + // Pending exit state — stored so resolveExit() can flush stdout before + // settling the exit promise. + let pendingExitError: unknown = undefined; + let pendingExitResult: unknown = undefined; + + function resolveExit() { + const stdout = mountedAppContext?.stdout ?? process.stdout; + const canWrite = stdout && !stdout.destroyed && !(stdout as any).writableEnded; + const hasWritableState = (stdout as any)._writableState !== undefined; + + const finish = () => { + if (pendingExitError instanceof Error) { + exitReject(pendingExitError); + } else { + exitResolve(pendingExitResult); + } + }; + + if (canWrite && hasWritableState) { + stdout.write("", () => finish()); + } else { + setImmediate(() => finish()); + } + } + let teardownStarted = false; function teardown() { if (teardownStarted) return; @@ -159,6 +185,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp if (mountedExitListener) { process.off("exit", mountedExitListener); } + if (mountedBeforeExitHandler) { + process.off("beforeExit", mountedBeforeExitHandler); + mountedBeforeExitHandler = null; + } if (mountedRawMode && mountedAppContext) { mountedAppContext.setRawMode(false); mountedRawMode = false; @@ -296,10 +326,11 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp queueMicrotask(() => { teardown(); if (errorOrResult instanceof Error) { - exitReject(errorOrResult); + pendingExitError = errorOrResult; } else { - exitResolve(errorOrResult); + pendingExitResult = errorOrResult; } + resolveExit(); }); }, stdout, @@ -486,10 +517,16 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp app.unmount = function unmount(): void { teardown(); - exitResolve(); + resolveExit(); }; app.waitUntilExit = function waitUntilExit(): Promise { + if (!mountedBeforeExitHandler) { + mountedBeforeExitHandler = () => { + app.unmount(); + }; + process.once("beforeExit", mountedBeforeExitHandler); + } return exitPromise; }; diff --git a/packages/testing/src/render.ts b/packages/testing/src/render.ts index cc0b66e..dbbae76 100644 --- a/packages/testing/src/render.ts +++ b/packages/testing/src/render.ts @@ -77,11 +77,17 @@ export async function render( earlyError = e as Error; }); - // Flush the Vue queue. Chain: onErrorCaptured → nextTick → exit → queueMicrotask → reject + // Flush the Vue queue. Chain: onErrorCaptured → nextTick → exit → queueMicrotask + // → teardown → resolveExit() → stdout.write("", callback) → reject. + // The stdout write barrier fires via process.nextTick (inside stream internals), + // so we need setImmediate (runs after all process.nextTick callbacks), then one + // more microtask yield so the .catch() handler on exitPromise can set earlyError. await nextTick(); await nextTick(); await Promise.resolve(); await Promise.resolve(); + await new Promise((r) => setImmediate(r)); + await Promise.resolve(); if (earlyError) { throw earlyError;