From 65ac08800170d89bbf4b35ef9f589fda544dac41 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Fri, 5 Jun 2026 20:56:25 +0800 Subject: [PATCH] fix(runtime): align stdout writability guards Co-authored-by: Claude --- .../lifecycle/unmount-stream.test.tsx | 33 ++++++++++++ .../integration/lifecycle/wait-flush.test.tsx | 53 +++++++++++++++++++ packages/runtime/src/render.ts | 52 ++++++++++++++---- 3 files changed, 127 insertions(+), 11 deletions(-) diff --git a/packages/runtime-tests/integration/lifecycle/unmount-stream.test.tsx b/packages/runtime-tests/integration/lifecycle/unmount-stream.test.tsx index c1d5b9f..7f83be6 100644 --- a/packages/runtime-tests/integration/lifecycle/unmount-stream.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/unmount-stream.test.tsx @@ -88,6 +88,39 @@ test("non-interactive mode writes only last frame at unmount", async () => { expect(postUnmountOutput).toContain("the-content"); }); +test("non-interactive unmount skips final frame when stdout is not writable", async () => { + const App = defineComponent(() => () => the-content); + + const stdout = makeFakeWritable({ columns: 80 }); + const stderr = makeFakeWritable({ columns: 80 }); + const { stream: stdin } = makeFakeStdin(); + + (stdout as unknown as { isTTY: boolean }).isTTY = false; + (stdout as NodeJS.WriteStream & { writable?: boolean }).writable = false; + + const chunks: string[] = []; + (stdout as unknown as PassThrough).on("data", (chunk: Buffer) => { + chunks.push(chunk.toString()); + }); + + const app = createApp(App); + app.mount({ + stdout, + stdin, + stderr, + exitOnCtrlC: false, + interactive: false, + }); + + await nextTick(); + await nextTick(); + + app.unmount(); + await app.waitUntilExit(); + + expect(chunks.join("")).not.toContain("the-content"); +}); + test("non-interactive mode does not emit erase or cursor sequences", async () => { // In non-interactive mode (non-TTY), the runtime should not emit any // ANSI erase sequences or cursor manipulation — only plain text output. diff --git a/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx b/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx index 609ea72..87ef610 100644 --- a/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx @@ -114,11 +114,64 @@ test("waitUntilRenderFlush resolves when stdout is not writable", async () => { await nextTick(); (stdout as NodeJS.WriteStream & { writable?: boolean }).writable = false; await app.waitUntilRenderFlush(); + expect(getContentWrites(writes).some((w) => stripAnsi(w).includes("World"))).toBe(false); app.unmount(); await app.waitUntilExit(); }); +test("waitUntilExit waits for stdout barrier when only writableLength is exposed", async () => { + let didBarrierCallbackFire = false; + let barrierWrites = 0; + const writes: string[] = []; + const stdout = { + columns: 80, + rows: 24, + isTTY: false, + destroyed: false, + writable: true, + writableEnded: false, + writableLength: 0, + write(chunk: string | Uint8Array, callback?: () => void) { + const text = String(chunk); + writes.push(text); + if (text === "" && callback) { + barrierWrites++; + setTimeout(() => { + didBarrierCallbackFire = true; + callback(); + }, 20); + } else { + callback?.(); + } + return true; + }, + on() { + return this; + }, + off() { + return this; + }, + } as unknown as NodeJS.WriteStream; + + const App = defineComponent(() => () => Hello); + const app = createApp(App); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, interactive: false, patchConsole: false }); + await nextTick(); + await nextTick(); + + const exited = app.waitUntilExit(); + app.unmount(); + await exited; + + expect(writes.some((w) => stripAnsi(w).includes("Hello"))).toBe(true); + expect(barrierWrites).toBe(1); + expect(didBarrierCallbackFire).toBe(true); +}); + test("waitUntilRenderFlush waits for rerender write callback", async () => { let didSecondWriteCallbackFire = false; diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 87b6eb1..3ee35cd 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -193,6 +193,24 @@ function isErrorInput(value: unknown): value is Error { return value instanceof Error || Object.prototype.toString.call(value) === "[object Error]"; } +type MaybeWritableStream = NodeJS.WriteStream & { + writable?: boolean; + writableEnded?: boolean; + destroyed?: boolean; + writableLength?: number; + _writableState?: unknown; +}; + +function getWritableStreamState(stdout: MaybeWritableStream): { + canWriteToStdout: boolean; + hasWritableState: boolean; +} { + return { + canWriteToStdout: !stdout.destroyed && !stdout.writableEnded && (stdout.writable ?? true), + hasWritableState: stdout._writableState !== undefined || stdout.writableLength !== undefined, + }; +} + export function createApp(root: Component, rootProps?: RootProps | null): TuiApp { // exit promise — created at createApp time so waitUntilExit() works even // before mount (it just hangs until mount + exit). @@ -268,9 +286,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp } return; } - const stdout = mountedAppContext?.stdout ?? process.stdout; - const canWrite = stdout && !stdout.destroyed && !(stdout as any).writableEnded; - const hasWritableState = (stdout as any)._writableState !== undefined; + const stdout = (mountedAppContext?.stdout ?? process.stdout) as MaybeWritableStream; + const { canWriteToStdout, hasWritableState } = getWritableStreamState(stdout); const finish = () => { if (isErrorInput(pendingExitError)) { @@ -280,7 +297,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp } }; - if (canWrite && hasWritableState) { + if (canWriteToStdout && hasWritableState) { stdout.write("", () => finish()); } else { setImmediate(() => finish()); @@ -288,7 +305,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp } function writeBestEffort(stream: NodeJS.WriteStream, data: string, sync = false) { - if (stream.destroyed || stream.writableEnded) return; + if (!getWritableStreamState(stream as MaybeWritableStream).canWriteToStdout) return; try { if (sync) { // Signal-exit path (G18, Finding A): signal-exit re-raises the signal @@ -353,7 +370,9 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // Prevent post-unmount app.clear() from writing to a torn-down stream. mountedClear = null; const stdout = mountedAppContext?.stdout; - const stdoutWritable = stdout && !stdout.destroyed && !stdout.writableEnded; + const stdoutWritable = stdout + ? getWritableStreamState(stdout as MaybeWritableStream).canWriteToStdout + : false; // Final-frame re-emit at unmount. Ink's settleThrottle path (ink.tsx:749-762) // runs a final onRender when shouldRenderFinalFrame is true; for the DEBUG // path throttledOnRender is undefined, so `!this.throttledOnRender` makes @@ -407,7 +426,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp } } } - if (mountedWriter && !mountedDebug && mountedInteractive) mountedWriter.done(); + if (mountedWriter && !mountedDebug && mountedInteractive && stdoutWritable) + mountedWriter.done(); if (mountedAlternateScreen && mountedAppContext) { writeBestEffort(mountedAppContext.stdout, ansiEscapes.exitAlternativeScreen, sync); writeBestEffort(mountedAppContext.stdout, "\x1b[?25h", sync); @@ -1266,18 +1286,28 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // SAME implementation via useApp().waitUntilRenderFlush — both the // TuiApp handle and the in-tree composable resolve identically. async function waitUntilRenderFlush(): Promise { + const stream = (mountedAppContext?.stdout ?? process.stdout) as MaybeWritableStream; + const { canWriteToStdout, hasWritableState } = getWritableStreamState(stream); + // Flush any pending OR scheduled render. Gating on hasPending() alone // misses the window after schedule() queues a commit but before the // post-flush callback sets hasPendingFlag, letting this resolve early. - // flush() resolves immediately when nothing is scheduled or pending, so - // delegating unconditionally is safe and closes that window. + // When stdout cannot be written, match Ink's settleThrottle behavior: + // cancel instead of flushing so delayed callbacks cannot write later. if (mountedScheduler) { - await mountedScheduler.flush(); + if (canWriteToStdout) { + await mountedScheduler.flush(); + } else { + mountedScheduler.cancel(); + } } // Wait for stdout write barrier — ensures the written frame is // flushed to the underlying stream. - const stream = mountedAppContext?.stdout ?? process.stdout; await new Promise((resolve) => { + if (!canWriteToStdout || !hasWritableState) { + setImmediate(resolve); + return; + } // PassThrough (test fakes) may not support the write callback form; // fall back to setImmediate so we still yield the event loop. try {