From 81e22522f3e1d71933f200affb74a74a7e56ec6c Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Fri, 9 Oct 2026 05:03:54 +0800 Subject: [PATCH 1/8] fix(cli): preserve UTF-8 across subprocess output chunks --- .../cli/src/utils/cancellableProcess.test.ts | 60 +++++++++++++++++++ packages/cli/src/utils/cancellableProcess.ts | 16 +++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/utils/cancellableProcess.test.ts b/packages/cli/src/utils/cancellableProcess.test.ts index 4c868f11d69..71e7a811444 100644 --- a/packages/cli/src/utils/cancellableProcess.test.ts +++ b/packages/cli/src/utils/cancellableProcess.test.ts @@ -7,6 +7,66 @@ import { testProcessIsAlive, waitForTestCondition } from "./processTestUtils.js" const IS_POSIX = process.platform !== "win32"; +const splitOutputSource = [ + "const [output, exitCode, keepAlive] = process.argv.slice(1);", + "const bytes = Buffer.from(output);", + "process.stdout.write(bytes.subarray(0, 1));", + "process.stderr.write(bytes.subarray(0, 1));", + "setTimeout(() => {", + " process.stdout.write(bytes.subarray(1));", + " process.stderr.write(bytes.subarray(1));", + " process.exitCode = Number(exitCode);", + " if (keepAlive === '1') setTimeout(() => {}, 10_000);", + "}, 50);", +].join("\n"); + +describe("cancellable process output", () => { + it.each(["é", "项目/音声.wav", "🎬 render", "ASCII output"])( + "preserves split output on both streams for %s", + async (output) => { + const result = await runCancellableProcess(process.execPath, [ + "--eval", + splitOutputSource, + output, + "0", + ]); + expect(result).toEqual({ stdout: output, stderr: output }); + }, + ); + + it("preserves split diagnostics when the process fails", async () => { + const output = "診断: 🎬"; + await expect( + runCancellableProcess(process.execPath, ["--eval", splitOutputSource, output, "1"]), + ).rejects.toMatchObject({ status: 1, stdout: output, stderr: output }); + }); + + it("counts a split UTF-8 character once at the combined byte limit", async () => { + const result = await runCancellableProcess( + process.execPath, + ["--eval", splitOutputSource, "é", "0"], + { maxBufferBytes: 4 }, + ); + expect(result).toEqual({ stdout: "é", stderr: "é" }); + }); + + it("still rejects output that exceeds the combined raw byte limit", async () => { + await expect( + runCancellableProcess(process.execPath, ["--eval", splitOutputSource, "é", "0", "1"], { + maxBufferBytes: 3, + }), + ).rejects.toMatchObject({ code: "ENOBUFS", killed: true }); + }); + + it("flushes incomplete trailing UTF-8 bytes when the streams end", async () => { + const result = await runCancellableProcess(process.execPath, [ + "--eval", + "process.stdout.write(Buffer.from([0xe2, 0x82])); process.stderr.write(Buffer.from([0xf0, 0x9f]));", + ]); + expect(result).toEqual({ stdout: "�", stderr: "�" }); + }); +}); + describe.skipIf(!IS_POSIX)("cancellable process tree teardown", () => { it("reaps a SIGTERM-resistant grandchild after the setup root exits", async () => { const testDir = mkdtempSync(join(tmpdir(), "hyperframes-cancellable-process-")); diff --git a/packages/cli/src/utils/cancellableProcess.ts b/packages/cli/src/utils/cancellableProcess.ts index 32bfa27772e..c59628516fe 100644 --- a/packages/cli/src/utils/cancellableProcess.ts +++ b/packages/cli/src/utils/cancellableProcess.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; import { fileURLToPath } from "node:url"; import { terminateProcessTree } from "./processTree.js"; @@ -74,6 +75,9 @@ export function runCancellableProcess( if (child.pid) options.onSpawn?.(child.pid); let stdout = ""; let stderr = ""; + const stdoutDecoder = new StringDecoder("utf8"); + const stderrDecoder = new StringDecoder("utf8"); + let outputBytes = 0; let settled = false; let terminationReason: unknown; let terminationSignal: NodeJS.Signals | undefined; @@ -114,14 +118,16 @@ export function runCancellableProcess( const onAbort = (): void => terminate(signal?.reason ?? new Error("Operation aborted")); child.stdout?.on("data", (chunk: Buffer | string) => { - stdout += chunk.toString(); - if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBufferBytes) { + stdout += typeof chunk === "string" ? chunk : stdoutDecoder.write(chunk); + outputBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length; + if (outputBytes > maxBufferBytes) { terminate(new Error(`Process output exceeded ${maxBufferBytes} bytes`), "ENOBUFS"); } }); child.stderr?.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); - if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBufferBytes) { + stderr += typeof chunk === "string" ? chunk : stderrDecoder.write(chunk); + outputBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length; + if (outputBytes > maxBufferBytes) { terminate(new Error(`Process output exceeded ${maxBufferBytes} bytes`), "ENOBUFS"); } }); @@ -129,6 +135,8 @@ export function runCancellableProcess( // fallow-ignore-next-line complexity child.once("close", async (status, closeSignal) => { if (settled) return; + stdout += stdoutDecoder.end(); + stderr += stderrDecoder.end(); if (terminationTask) { try { terminationSignal = await terminationTask; From def6250d1ff2e428e40ffb258a1e9156052d6b08 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Thu, 15 Oct 2026 22:56:54 +0800 Subject: [PATCH 2/8] chore: prepare conflict resolution against current main --- packages/cli/src/utils/cancellableProcess.ts | 67 ++++++++------------ 1 file changed, 28 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/utils/cancellableProcess.ts b/packages/cli/src/utils/cancellableProcess.ts index c59628516fe..6916e15d28a 100644 --- a/packages/cli/src/utils/cancellableProcess.ts +++ b/packages/cli/src/utils/cancellableProcess.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; -import { StringDecoder } from "node:string_decoder"; import { fileURLToPath } from "node:url"; import { terminateProcessTree } from "./processTree.js"; +import { renderSetupFailureFrom, renderSetupResultFrom } from "../renderSetupWorkerLifecycle.js"; export interface CancellableProcessResult { stdout: string; @@ -75,9 +75,6 @@ export function runCancellableProcess( if (child.pid) options.onSpawn?.(child.pid); let stdout = ""; let stderr = ""; - const stdoutDecoder = new StringDecoder("utf8"); - const stderrDecoder = new StringDecoder("utf8"); - let outputBytes = 0; let settled = false; let terminationReason: unknown; let terminationSignal: NodeJS.Signals | undefined; @@ -93,13 +90,7 @@ export function runCancellableProcess( }; const finishReject = async (error: unknown): Promise => { if (settled) return; - if (terminationTask) { - try { - terminationSignal = await terminationTask; - } catch (terminationError) { - error = terminationError; - } - } + if (terminationTask) terminationSignal = await terminationTask.catch(() => terminationSignal); if (settled) return; settled = true; cleanup(); @@ -118,16 +109,14 @@ export function runCancellableProcess( const onAbort = (): void => terminate(signal?.reason ?? new Error("Operation aborted")); child.stdout?.on("data", (chunk: Buffer | string) => { - stdout += typeof chunk === "string" ? chunk : stdoutDecoder.write(chunk); - outputBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length; - if (outputBytes > maxBufferBytes) { + stdout += chunk.toString(); + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBufferBytes) { terminate(new Error(`Process output exceeded ${maxBufferBytes} bytes`), "ENOBUFS"); } }); child.stderr?.on("data", (chunk: Buffer | string) => { - stderr += typeof chunk === "string" ? chunk : stderrDecoder.write(chunk); - outputBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length; - if (outputBytes > maxBufferBytes) { + stderr += chunk.toString(); + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBufferBytes) { terminate(new Error(`Process output exceeded ${maxBufferBytes} bytes`), "ENOBUFS"); } }); @@ -135,16 +124,7 @@ export function runCancellableProcess( // fallow-ignore-next-line complexity child.once("close", async (status, closeSignal) => { if (settled) return; - stdout += stdoutDecoder.end(); - stderr += stderrDecoder.end(); - if (terminationTask) { - try { - terminationSignal = await terminationTask; - } catch (error) { - await finishReject(error); - return; - } - } + if (terminationTask) terminationSignal = await terminationTask.catch(() => terminationSignal); if (settled) return; settled = true; cleanup(); @@ -195,7 +175,21 @@ export function runCancellableProcess( }); } -const SETUP_RESULT_PREFIX = "HYPERFRAMES_RENDER_SETUP_RESULT:"; +function renderSetupError(error: unknown): unknown { + if (!(error instanceof CancellableProcessError)) return error; + const failure = renderSetupFailureFrom(error.stderr); + if (failure?.earlierOutput.trim()) process.stderr.write(`${failure.earlierOutput.trimEnd()}\n`); + const reason = failure?.reason ?? error.stderr.trim(); + return reason ? new Error(reason, { cause: error }) : error; +} + +function tsxLoaderFromCliInstall(): string { + try { + return import.meta.resolve("tsx"); + } catch { + return "tsx"; + } +} export async function runRenderSetupWorker( mode: "browser" | "lint" | "orphan-cleanup", @@ -207,7 +201,8 @@ export async function runRenderSetupWorker( sourceMode ? "../renderSetupWorker.ts" : "./renderSetupWorker.js", import.meta.url, ); - const execArgv = sourceMode ? ["--import", "tsx"] : []; + const execArgv = + sourceMode && !process.versions.bun ? ["--import", tsxLoaderFromCliInstall()] : []; let result: CancellableProcessResult; try { result = await runCancellableProcess( @@ -224,16 +219,10 @@ export async function runRenderSetupWorker( ); } catch (error) { if (options.signal?.aborted) options.signal.throwIfAborted(); - if (error instanceof CancellableProcessError && error.stderr.trim()) { - throw new Error(error.stderr.trim(), { cause: error }); - } - throw error; + throw renderSetupError(error); } if (result.stderr) process.stderr.write(result.stderr); - const encoded = result.stdout - .split(/\r?\n/) - .find((line) => line.startsWith(SETUP_RESULT_PREFIX)) - ?.slice(SETUP_RESULT_PREFIX.length); - if (!encoded) throw new Error(`${mode} setup process exited without a result`); - return JSON.parse(encoded) as T; + const value = renderSetupResultFrom(result.stdout); + if (value === undefined) throw new Error(`${mode} setup process exited without a result`); + return value as T; } From 134d009d84dc6a51608fc6dd7f3651493ec5f418 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Thu, 15 Oct 2026 22:57:01 +0800 Subject: [PATCH 3/8] fix(cli): retain UTF-8 output handling with current main --- packages/cli/src/utils/cancellableProcess.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/utils/cancellableProcess.ts b/packages/cli/src/utils/cancellableProcess.ts index 6916e15d28a..8cc7ba5921e 100644 --- a/packages/cli/src/utils/cancellableProcess.ts +++ b/packages/cli/src/utils/cancellableProcess.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; import { fileURLToPath } from "node:url"; import { terminateProcessTree } from "./processTree.js"; import { renderSetupFailureFrom, renderSetupResultFrom } from "../renderSetupWorkerLifecycle.js"; @@ -75,6 +76,9 @@ export function runCancellableProcess( if (child.pid) options.onSpawn?.(child.pid); let stdout = ""; let stderr = ""; + const stdoutDecoder = new StringDecoder("utf8"); + const stderrDecoder = new StringDecoder("utf8"); + let outputBytes = 0; let settled = false; let terminationReason: unknown; let terminationSignal: NodeJS.Signals | undefined; @@ -109,14 +113,16 @@ export function runCancellableProcess( const onAbort = (): void => terminate(signal?.reason ?? new Error("Operation aborted")); child.stdout?.on("data", (chunk: Buffer | string) => { - stdout += chunk.toString(); - if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBufferBytes) { + stdout += typeof chunk === "string" ? chunk : stdoutDecoder.write(chunk); + outputBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length; + if (outputBytes > maxBufferBytes) { terminate(new Error(`Process output exceeded ${maxBufferBytes} bytes`), "ENOBUFS"); } }); child.stderr?.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); - if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBufferBytes) { + stderr += typeof chunk === "string" ? chunk : stderrDecoder.write(chunk); + outputBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length; + if (outputBytes > maxBufferBytes) { terminate(new Error(`Process output exceeded ${maxBufferBytes} bytes`), "ENOBUFS"); } }); @@ -124,6 +130,8 @@ export function runCancellableProcess( // fallow-ignore-next-line complexity child.once("close", async (status, closeSignal) => { if (settled) return; + stdout += stdoutDecoder.end(); + stderr += stderrDecoder.end(); if (terminationTask) terminationSignal = await terminationTask.catch(() => terminationSignal); if (settled) return; settled = true; From d6bdba57268578828878cde675a851a832ec7803 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sat, 24 Oct 2026 13:51:33 +0800 Subject: [PATCH 4/8] chore: prepare conflict resolution against current main --- .../cli/src/utils/cancellableProcess.test.ts | 90 +++++++------------ 1 file changed, 31 insertions(+), 59 deletions(-) diff --git a/packages/cli/src/utils/cancellableProcess.test.ts b/packages/cli/src/utils/cancellableProcess.test.ts index 56755d109c7..5161085b14b 100644 --- a/packages/cli/src/utils/cancellableProcess.test.ts +++ b/packages/cli/src/utils/cancellableProcess.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,65 +8,9 @@ import { testProcessIsAlive, waitForTestCondition } from "./processTestUtils.js" const IS_POSIX = process.platform !== "win32"; -const splitOutputSource = [ - "const [output, exitCode, keepAlive] = process.argv.slice(1);", - "const bytes = Buffer.from(output);", - "process.stdout.write(bytes.subarray(0, 1));", - "process.stderr.write(bytes.subarray(0, 1));", - "setTimeout(() => {", - " process.stdout.write(bytes.subarray(1));", - " process.stderr.write(bytes.subarray(1));", - " process.exitCode = Number(exitCode);", - " if (keepAlive === '1') setTimeout(() => {}, 10_000);", - "}, 50);", -].join("\n"); - -describe("cancellable process output", () => { - it.each(["é", "项目/音声.wav", "🎬 render", "ASCII output"])( - "preserves split output on both streams for %s", - async (output) => { - const result = await runCancellableProcess(process.execPath, [ - "--eval", - splitOutputSource, - output, - "0", - ]); - expect(result).toEqual({ stdout: output, stderr: output }); - }, - ); - - it("preserves split diagnostics when the process fails", async () => { - const output = "診断: 🎬"; - await expect( - runCancellableProcess(process.execPath, ["--eval", splitOutputSource, output, "1"]), - ).rejects.toMatchObject({ status: 1, stdout: output, stderr: output }); - }); - - it("counts a split UTF-8 character once at the combined byte limit", async () => { - const result = await runCancellableProcess( - process.execPath, - ["--eval", splitOutputSource, "é", "0"], - { maxBufferBytes: 4 }, - ); - expect(result).toEqual({ stdout: "é", stderr: "é" }); - }); - - it("still rejects output that exceeds the combined raw byte limit", async () => { - await expect( - runCancellableProcess(process.execPath, ["--eval", splitOutputSource, "é", "0", "1"], { - maxBufferBytes: 3, - }), - ).rejects.toMatchObject({ code: "ENOBUFS", killed: true }); - }); - - it("flushes incomplete trailing UTF-8 bytes when the streams end", async () => { - const result = await runCancellableProcess(process.execPath, [ - "--eval", - "process.stdout.write(Buffer.from([0xe2, 0x82])); process.stderr.write(Buffer.from([0xf0, 0x9f]));", - ]); - expect(result).toEqual({ stdout: "�", stderr: "�" }); - }); -}); +function processGroupOf(pid: number): string { + return execFileSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8" }).trim(); +} describe.skipIf(!IS_POSIX)("cancellable process tree teardown", () => { it("reaps a SIGTERM-resistant grandchild after the setup root exits", async () => { @@ -138,6 +83,33 @@ describe.skipIf(!IS_POSIX)("cancellable process tree teardown", () => { rmSync(testDir, { recursive: true, force: true }); } }, 5_000); + + it("keeps a child it does not detach in the caller's process group, and a cancel still stops it", async () => { + const controller = new AbortController(); + const abortReason = new Error("cancel unzip"); + let childPid: number | undefined; + try { + const run = runCancellableProcess( + process.execPath, + ["--eval", "setTimeout(() => undefined, 10_000)"], + { + signal: controller.signal, + detached: false, + onSpawn: (pid) => { + childPid = pid; + }, + }, + ); + expect(processGroupOf(childPid!)).toBe(processGroupOf(process.pid)); + + controller.abort(abortReason); + await expect(run).rejects.toBe(abortReason); + + expect(testProcessIsAlive(childPid!)).toBe(false); + } finally { + if (childPid && testProcessIsAlive(childPid)) process.kill(childPid, "SIGKILL"); + } + }, 5_000); }); describe("render setup worker failure", () => { From ef181ad99f59605bacf6823566f41c4c1b419e4d Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sat, 24 Oct 2026 13:51:56 +0800 Subject: [PATCH 5/8] fix(cli): retain UTF-8 decoding with process teardown updates --- docs/developers/cli.mdx | 4 ++ .../cli/src/utils/cancellableProcess.test.ts | 60 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/docs/developers/cli.mdx b/docs/developers/cli.mdx index 9ec21a74861..aaa74afefa8 100644 --- a/docs/developers/cli.mdx +++ b/docs/developers/cli.mdx @@ -48,6 +48,10 @@ The CLI also renders in batches and from variables, writes image sequences and several video formats, and runs in the cloud — managed, on AWS Lambda, or on Google Cloud Run. Most commands take `--json` when you need to read the result from a script. +Diagnostics from child tools preserve Unicode filenames and non-English text +even when the output arrives in separate chunks. Output limits count the +combined UTF-8 bytes from standard output and standard error. + Start with the simple command, then open the [full reference](/packages/cli) for the exact options needed by automation or infrastructure. diff --git a/packages/cli/src/utils/cancellableProcess.test.ts b/packages/cli/src/utils/cancellableProcess.test.ts index 5161085b14b..c1be0cc7ee9 100644 --- a/packages/cli/src/utils/cancellableProcess.test.ts +++ b/packages/cli/src/utils/cancellableProcess.test.ts @@ -8,6 +8,66 @@ import { testProcessIsAlive, waitForTestCondition } from "./processTestUtils.js" const IS_POSIX = process.platform !== "win32"; +const splitOutputSource = [ + "const [output, exitCode, keepAlive] = process.argv.slice(1);", + "const bytes = Buffer.from(output);", + "process.stdout.write(bytes.subarray(0, 1));", + "process.stderr.write(bytes.subarray(0, 1));", + "setTimeout(() => {", + " process.stdout.write(bytes.subarray(1));", + " process.stderr.write(bytes.subarray(1));", + " process.exitCode = Number(exitCode);", + " if (keepAlive === '1') setTimeout(() => {}, 10_000);", + "}, 50);", +].join("\n"); + +describe("cancellable process output", () => { + it.each(["é", "项目/音声.wav", "🎬 render", "ASCII output"])( + "preserves split output on both streams for %s", + async (output) => { + const result = await runCancellableProcess(process.execPath, [ + "--eval", + splitOutputSource, + output, + "0", + ]); + expect(result).toEqual({ stdout: output, stderr: output }); + }, + ); + + it("preserves split diagnostics when the process fails", async () => { + const output = "診断: 🎬"; + await expect( + runCancellableProcess(process.execPath, ["--eval", splitOutputSource, output, "1"]), + ).rejects.toMatchObject({ status: 1, stdout: output, stderr: output }); + }); + + it("counts a split UTF-8 character once at the combined byte limit", async () => { + const result = await runCancellableProcess( + process.execPath, + ["--eval", splitOutputSource, "é", "0"], + { maxBufferBytes: 4 }, + ); + expect(result).toEqual({ stdout: "é", stderr: "é" }); + }); + + it("still rejects output that exceeds the combined raw byte limit", async () => { + await expect( + runCancellableProcess(process.execPath, ["--eval", splitOutputSource, "é", "0", "1"], { + maxBufferBytes: 3, + }), + ).rejects.toMatchObject({ code: "ENOBUFS", killed: true }); + }); + + it("flushes incomplete trailing UTF-8 bytes when the streams end", async () => { + const result = await runCancellableProcess(process.execPath, [ + "--eval", + "process.stdout.write(Buffer.from([0xe2, 0x82])); process.stderr.write(Buffer.from([0xf0, 0x9f]));", + ]); + expect(result).toEqual({ stdout: "�", stderr: "�" }); + }); +}); + function processGroupOf(pid: number): string { return execFileSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8" }).trim(); } From e74967c6c5373cf2a96fe36da244364158fa0853 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sun, 25 Oct 2026 02:46:45 +0800 Subject: [PATCH 6/8] chore: prepare conflict resolution against current main --- packages/cli/src/utils/cancellableProcess.ts | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/utils/cancellableProcess.ts b/packages/cli/src/utils/cancellableProcess.ts index 1b0a82a9ee9..59985b8a63d 100644 --- a/packages/cli/src/utils/cancellableProcess.ts +++ b/packages/cli/src/utils/cancellableProcess.ts @@ -1,5 +1,4 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; -import { StringDecoder } from "node:string_decoder"; import { fileURLToPath } from "node:url"; import { terminateProcessTree } from "./processTree.js"; import { renderSetupFailureFrom, renderSetupResultFrom } from "../renderSetupWorkerLifecycle.js"; @@ -17,6 +16,7 @@ export interface CancellableProcessOptions { env?: NodeJS.ProcessEnv; detached?: boolean; onSpawn?: (pid: number) => void; + onStdout?: (chunk: string) => void; } class CancellableProcessError extends Error { @@ -78,9 +78,6 @@ export function runCancellableProcess( if (child.pid) options.onSpawn?.(child.pid); let stdout = ""; let stderr = ""; - const stdoutDecoder = new StringDecoder("utf8"); - const stderrDecoder = new StringDecoder("utf8"); - let outputBytes = 0; let settled = false; let terminationReason: unknown; let terminationSignal: NodeJS.Signals | undefined; @@ -115,16 +112,15 @@ export function runCancellableProcess( const onAbort = (): void => terminate(signal?.reason ?? new Error("Operation aborted")); child.stdout?.on("data", (chunk: Buffer | string) => { - stdout += typeof chunk === "string" ? chunk : stdoutDecoder.write(chunk); - outputBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length; - if (outputBytes > maxBufferBytes) { + stdout += chunk.toString(); + options.onStdout?.(chunk.toString()); + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBufferBytes) { terminate(new Error(`Process output exceeded ${maxBufferBytes} bytes`), "ENOBUFS"); } }); child.stderr?.on("data", (chunk: Buffer | string) => { - stderr += typeof chunk === "string" ? chunk : stderrDecoder.write(chunk); - outputBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length; - if (outputBytes > maxBufferBytes) { + stderr += chunk.toString(); + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBufferBytes) { terminate(new Error(`Process output exceeded ${maxBufferBytes} bytes`), "ENOBUFS"); } }); @@ -132,8 +128,6 @@ export function runCancellableProcess( // fallow-ignore-next-line complexity child.once("close", async (status, closeSignal) => { if (settled) return; - stdout += stdoutDecoder.end(); - stderr += stderrDecoder.end(); if (terminationTask) terminationSignal = await terminationTask.catch(() => terminationSignal); if (settled) return; settled = true; @@ -202,7 +196,7 @@ function tsxLoaderFromCliInstall(): string { } export async function runRenderSetupWorker( - mode: "browser" | "lint" | "orphan-cleanup", + mode: "browser" | "browser-install" | "lint" | "orphan-cleanup", input: unknown, options: CancellableProcessOptions, ): Promise { From 18da9754173de75fbf3a2d25d49eac65553dd6be Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sun, 25 Oct 2026 02:46:51 +0800 Subject: [PATCH 7/8] fix(cli): preserve decoded subprocess progress on current main --- .../cli/src/utils/cancellableProcess.test.ts | 27 ++++++++++++------- packages/cli/src/utils/cancellableProcess.ts | 21 +++++++++++---- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/utils/cancellableProcess.test.ts b/packages/cli/src/utils/cancellableProcess.test.ts index c1be0cc7ee9..d418b255b34 100644 --- a/packages/cli/src/utils/cancellableProcess.test.ts +++ b/packages/cli/src/utils/cancellableProcess.test.ts @@ -25,13 +25,14 @@ describe("cancellable process output", () => { it.each(["é", "项目/音声.wav", "🎬 render", "ASCII output"])( "preserves split output on both streams for %s", async (output) => { - const result = await runCancellableProcess(process.execPath, [ - "--eval", - splitOutputSource, - output, - "0", - ]); + const chunks: string[] = []; + const result = await runCancellableProcess( + process.execPath, + ["--eval", splitOutputSource, output, "0"], + { onStdout: (chunk) => chunks.push(chunk) }, + ); expect(result).toEqual({ stdout: output, stderr: output }); + expect(chunks.join("")).toBe(output); }, ); @@ -60,11 +61,17 @@ describe("cancellable process output", () => { }); it("flushes incomplete trailing UTF-8 bytes when the streams end", async () => { - const result = await runCancellableProcess(process.execPath, [ - "--eval", - "process.stdout.write(Buffer.from([0xe2, 0x82])); process.stderr.write(Buffer.from([0xf0, 0x9f]));", - ]); + const chunks: string[] = []; + const result = await runCancellableProcess( + process.execPath, + [ + "--eval", + "process.stdout.write(Buffer.from([0xe2, 0x82])); process.stderr.write(Buffer.from([0xf0, 0x9f]));", + ], + { onStdout: (chunk) => chunks.push(chunk) }, + ); expect(result).toEqual({ stdout: "�", stderr: "�" }); + expect(chunks.join("")).toBe("�"); }); }); diff --git a/packages/cli/src/utils/cancellableProcess.ts b/packages/cli/src/utils/cancellableProcess.ts index 59985b8a63d..9aa3b0aaa9f 100644 --- a/packages/cli/src/utils/cancellableProcess.ts +++ b/packages/cli/src/utils/cancellableProcess.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; import { fileURLToPath } from "node:url"; import { terminateProcessTree } from "./processTree.js"; import { renderSetupFailureFrom, renderSetupResultFrom } from "../renderSetupWorkerLifecycle.js"; @@ -78,6 +79,9 @@ export function runCancellableProcess( if (child.pid) options.onSpawn?.(child.pid); let stdout = ""; let stderr = ""; + const stdoutDecoder = new StringDecoder("utf8"); + const stderrDecoder = new StringDecoder("utf8"); + let outputBytes = 0; let settled = false; let terminationReason: unknown; let terminationSignal: NodeJS.Signals | undefined; @@ -112,15 +116,18 @@ export function runCancellableProcess( const onAbort = (): void => terminate(signal?.reason ?? new Error("Operation aborted")); child.stdout?.on("data", (chunk: Buffer | string) => { - stdout += chunk.toString(); - options.onStdout?.(chunk.toString()); - if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBufferBytes) { + const decoded = typeof chunk === "string" ? chunk : stdoutDecoder.write(chunk); + stdout += decoded; + options.onStdout?.(decoded); + outputBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length; + if (outputBytes > maxBufferBytes) { terminate(new Error(`Process output exceeded ${maxBufferBytes} bytes`), "ENOBUFS"); } }); child.stderr?.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); - if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBufferBytes) { + stderr += typeof chunk === "string" ? chunk : stderrDecoder.write(chunk); + outputBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length; + if (outputBytes > maxBufferBytes) { terminate(new Error(`Process output exceeded ${maxBufferBytes} bytes`), "ENOBUFS"); } }); @@ -128,6 +135,10 @@ export function runCancellableProcess( // fallow-ignore-next-line complexity child.once("close", async (status, closeSignal) => { if (settled) return; + const trailingStdout = stdoutDecoder.end(); + stdout += trailingStdout; + if (trailingStdout) options.onStdout?.(trailingStdout); + stderr += stderrDecoder.end(); if (terminationTask) terminationSignal = await terminationTask.catch(() => terminationSignal); if (settled) return; settled = true; From ca0ef6b15ea5933c7435c244fc0a26b321672d03 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sun, 25 Oct 2026 08:25:40 +0800 Subject: [PATCH 8/8] docs(cli): scope Unicode output guarantees to cancellable workers --- docs/developers/cli.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/developers/cli.mdx b/docs/developers/cli.mdx index aaa74afefa8..e795e354203 100644 --- a/docs/developers/cli.mdx +++ b/docs/developers/cli.mdx @@ -48,9 +48,9 @@ The CLI also renders in batches and from variables, writes image sequences and several video formats, and runs in the cloud — managed, on AWS Lambda, or on Google Cloud Run. Most commands take `--json` when you need to read the result from a script. -Diagnostics from child tools preserve Unicode filenames and non-English text -even when the output arrives in separate chunks. Output limits count the -combined UTF-8 bytes from standard output and standard error. +For workers launched through `runCancellableProcess`, diagnostics preserve +Unicode filenames and non-English text across output chunks. Its output limit +counts the combined UTF-8 bytes from standard output and standard error. Start with the simple command, then open the [full reference](/packages/cli) for the exact options needed by automation or infrastructure.