mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(setup): retain bounded startup diagnostics without losing failures
This commit is contained in:
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,
|
||||
|
||||
@@ -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.`;
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
Reference in new issue
Block a user