diff --git a/src/application/SetupRegistrationPrewarm.test.ts b/src/application/SetupRegistrationPrewarm.test.ts index 9b6d3288d7..0362089381 100644 --- a/src/application/SetupRegistrationPrewarm.test.ts +++ b/src/application/SetupRegistrationPrewarm.test.ts @@ -147,6 +147,48 @@ describe("materializeRegistrationRunnerCache", () => { }); describe("launchRegistrationRunnerCommand", () => { + it("retains bounded head and tail bytes and preserves UTF-8 across writes", async () => { + const result = await launchRegistrationRunnerCommand( + process.execPath, + [ + "-e", + ` + process.stdout.write(Buffer.from([0xe4])); + setTimeout(() => { + process.stdout.write(Buffer.from([0xb8, 0xad])); + process.stderr.write('x'.repeat(60000)); + setTimeout(() => { + process.stderr.write('y'.repeat(60000)); + process.stderr.write('FINAL: package permission denied'); + process.exitCode = 1; + }, 30); + }, 30); + `, + ], + { + env: process.env, + cwd: process.cwd(), + timeoutMs: 5000, + platform: process.platform, + }, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe("中"); + expect(Buffer.byteLength(result.stderr)).toBeLessThan(64 * 1024 + 50); + expect(result.stderr).toContain("[output omitted]"); + expect(result.stderr).toContain("FINAL: package permission denied"); + const prewarm = await materializeRegistrationRunnerCache( + ["npx", "-y", specifier, "mcp"], + {}, + async () => result, + ); + expect(prewarm.status).toBe("failed"); + if (prewarm.status === "failed") { + expect(prewarm.reason).toContain("exit 1"); + expect(prewarm.reason).toContain("FINAL: package permission denied"); + } + }); + it("captures a successful command exit", async () => { const result = await launchRegistrationRunnerCommand( process.execPath, diff --git a/src/application/SetupRegistrationPrewarm.ts b/src/application/SetupRegistrationPrewarm.ts index 63cf3878f0..7457ff87c3 100644 --- a/src/application/SetupRegistrationPrewarm.ts +++ b/src/application/SetupRegistrationPrewarm.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcess } from "node:child_process"; import { tmpdir } from "node:os"; +import { BoundedOutputExcerpt } from "../process/BoundedOutputExcerpt.js"; import { cleanupWindowsProcessTree } from "../process/ProcessOwnership.js"; import { inspectClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; @@ -123,8 +124,10 @@ export const launchRegistrationRunnerCommand: RegistrationRunnerLauncher = ( // group no longer receives the terminal's signal. ...(options.platform === "win32" ? {} : { detached: true }), }); - const stdout = boundedStream(child.stdout); - const stderr = boundedStream(child.stderr); + const stdout = new BoundedOutputExcerpt(PREWARM_CAPTURE_LIMIT); + const stderr = new BoundedOutputExcerpt(PREWARM_CAPTURE_LIMIT); + child.stdout?.on("data", (chunk: Buffer) => stdout.append(chunk)); + child.stderr?.on("data", (chunk: Buffer) => stderr.append(chunk)); let timedOut = false; let spawnError: string | undefined; let interruptedSignal: string | undefined; @@ -164,8 +167,8 @@ export const launchRegistrationRunnerCommand: RegistrationRunnerLauncher = ( resolve({ exitCode: code ?? signal, signal: signal ?? null, - stdout: stdout(), - stderr: stderr(), + stdout: stdout.text, + stderr: stderr.text, timedOut, ...(spawnError === undefined ? {} : { spawnError }), ...(interruptedSignal === undefined ? {} : { interruptedSignal }), @@ -176,32 +179,25 @@ export const launchRegistrationRunnerCommand: RegistrationRunnerLauncher = ( /** Terminal interrupts that must also stop a detached package-runner tree. */ const FORWARD_SIGNALS: readonly NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; -const boundedStream = ( - stream: NodeJS.ReadableStream | null, -): (() => string) => { - let captured = ""; - stream?.on("data", (chunk: Buffer) => { - if (captured.length < PREWARM_CAPTURE_LIMIT) - captured += chunk.toString("utf8").slice(0, PREWARM_CAPTURE_LIMIT); - }); - return () => captured; -}; - const prewarmFailureReason = (launch: RegistrationRunnerLaunch): string => { - const observed = + const outcome = launch.spawnError !== undefined - ? launch.spawnError + ? "spawn failed" : launch.interruptedSignal !== undefined ? `the package runner was stopped by ${launch.interruptedSignal}` : launch.timedOut ? `the package runner timed out after the bounded prewarm window` - : `exit ${launch.exitCode === null ? "unknown" : String(launch.exitCode)}${ - launch.stderr.trim() === "" ? "" : `: ${launch.stderr.trim()}` - }`; - const normalized = observed + : `exit ${launch.exitCode === null ? "unknown" : String(launch.exitCode)}`; + const normalized = (launch.spawnError ?? launch.stderr) .replace(/\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\([0-9A-Za-z]|[@-Z\\^_])/gu, "") .replace(/[\u0000-\u001f\u007f]+/gu, " ") .replace(/\s+/gu, " ") .trim(); - return `The pinned package-runner entry could not be materialized before the first client start (${normalized.slice(0, PREWARM_REASON_LIMIT)}); the first client start will install it inside its startup deadline.`; + const detailLimit = PREWARM_REASON_LIMIT - outcome.length - 2; + const detail = + normalized.length > detailLimit + ? `… ${normalized.slice(-(detailLimit - 2))}` + : normalized; + const observed = `${outcome}${detail === "" ? "" : `: ${detail}`}`; + return `The pinned package-runner entry could not be materialized before the first client start (${observed}); the first client start will install it inside its startup deadline.`; }; diff --git a/src/process/BoundedOutputExcerpt.ts b/src/process/BoundedOutputExcerpt.ts new file mode 100644 index 0000000000..439df43cb2 --- /dev/null +++ b/src/process/BoundedOutputExcerpt.ts @@ -0,0 +1,46 @@ +/** Byte-bounded head and tail diagnostics, decoded after stream chunks are joined. */ +export class BoundedOutputExcerpt { + #head: Buffer = Buffer.alloc(0); + #tail: Buffer = Buffer.alloc(0); + #total = 0; + readonly #headLimit: number; + readonly #tailLimit: number; + + constructor(readonly limit: number) { + this.#headLimit = Math.ceil(limit / 2); + this.#tailLimit = limit - this.#headLimit; + } + + append(chunk: Buffer): void { + this.#total += chunk.byteLength; + const headCount = Math.min( + chunk.byteLength, + this.#headLimit - this.#head.byteLength, + ); + if (headCount > 0) + this.#head = Buffer.concat([this.#head, chunk.subarray(0, headCount)]); + const rest = chunk.subarray(headCount); + if (rest.byteLength > 0 && this.#tailLimit > 0) { + const retained = rest.subarray(-this.#tailLimit); + this.#tail = Buffer.concat([ + this.#tail.subarray( + Math.max( + 0, + this.#tail.byteLength + retained.byteLength - this.#tailLimit, + ), + ), + retained, + ]); + } + } + + get truncated(): boolean { + return this.#total > this.limit; + } + + get text(): string { + return this.truncated + ? `${this.#head.toString("utf8")} … [output omitted] … ${this.#tail.toString("utf8")}` + : Buffer.concat([this.#head, this.#tail]).toString("utf8"); + } +} diff --git a/src/process/ExecFileBoundedOutput.ts b/src/process/ExecFileBoundedOutput.ts index fad51e2bce..a7d01e6411 100644 --- a/src/process/ExecFileBoundedOutput.ts +++ b/src/process/ExecFileBoundedOutput.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { BoundedOutputExcerpt } from "./BoundedOutputExcerpt.js"; export interface BoundedCommandOutput { readonly exitCode: string | number | null; @@ -21,31 +22,10 @@ export const execFileBoundedOutput = ( env: options.env, stdio: ["ignore", "pipe", "pipe"], }); - const headLimit = Math.ceil(options.limit / 2); - const tailLimit = options.limit - headLimit; - const stdout = { head: Buffer.alloc(0), tail: Buffer.alloc(0), total: 0 }; - const stderr = { head: Buffer.alloc(0), tail: Buffer.alloc(0), total: 0 }; - let outputTruncated = false; - const capture = - (stream: typeof stdout) => - (chunk: Buffer): void => { - stream.total += chunk.length; - const headCount = Math.min( - chunk.length, - Math.max(0, headLimit - stream.head.length), - ); - if (headCount > 0) - stream.head = Buffer.concat([ - stream.head, - chunk.subarray(0, headCount), - ]); - const rest = chunk.subarray(headCount); - if (rest.length > 0 && tailLimit > 0) - stream.tail = Buffer.concat([stream.tail, rest]).subarray(-tailLimit); - if (stream.total > options.limit) outputTruncated = true; - }; - child.stdout.on("data", capture(stdout)); - child.stderr.on("data", capture(stderr)); + const stdout = new BoundedOutputExcerpt(options.limit); + const stderr = new BoundedOutputExcerpt(options.limit); + child.stdout.on("data", (chunk: Buffer) => stdout.append(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.append(chunk)); let spawnError: NodeJS.ErrnoException | undefined; child.on("error", (error: NodeJS.ErrnoException) => { spawnError = error; @@ -53,21 +33,9 @@ export const execFileBoundedOutput = ( child.on("close", (code, signal) => { resolve({ exitCode: spawnError?.code ?? code ?? signal, - stdout: retainedOutput(stdout, options.limit), - stderr: retainedOutput(stderr, options.limit), - outputTruncated, + stdout: stdout.text, + stderr: stderr.text, + outputTruncated: stdout.truncated || stderr.truncated, }); }); }); - -const retainedOutput = ( - stream: { - readonly head: Buffer; - readonly tail: Buffer; - readonly total: number; - }, - limit: number, -): string => - stream.total > limit - ? `${stream.head.toString("utf8")} … [output omitted] … ${stream.tail.toString("utf8")}` - : Buffer.concat([stream.head, stream.tail]).toString("utf8");