fix(setup): retain bounded startup diagnostics without losing failures

This commit is contained in:
morluto committed 2026-10-26 02:33:31 +08:00
1 parent 37a7c2d652
commit db4d3abcda
4 files changed
+114 -62

No files matched your search

@@ -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,
+18 -22
View File
@@ -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.`;
};
+46
View File
@@ -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");
}
}
+8 -40
View File
@@ -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");