fix(native): bound captured output carried by native command failures (#2764)
* fix(native): bound captured output carried by native command failures The native command runner retains up to 64 MiB of output because successful analysis needs it complete, and failureCapture copied all of it into the failure. An inspect_plist whose plutil output exceeded the budget therefore returned a 67 MB resource_constraint error whose details.captured_output.stdout repeated the whole retained output. Keep a 64 KiB UTF-8-safe excerpt per stream in failure diagnostics, mark them truncated, and keep the observed stdout/stderr byte totals. Successful captures are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(native): bound codesign failures and cover excerpt cuts translateCodeSignExitFailure wrapped an accepted nonzero codesign capture into a NativeCommandFailure by copying both complete streams, so inspect_signature failures still bypassed the diagnostic bound. Route it through the shared failureCapture builder; accepted output stays complete until the provider decides it is a failure. Add a focused runner test for an ordinary nonzero exit with stdout and stderr just above the excerpt size and a two-byte character straddling the cut, and a provider test for the codesign path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(native): build excerpt test output inside the child process The output strings embedded in one node -e argument totalled about 133 KB, above Linux's 128 KiB per-argument limit, so spawn failed with E2BIG on the CI runner. Generate the repeated output in the child instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c938f97fb2
commit
5996d8160f
|
|
@ -15,11 +15,18 @@ import type {
|
|||
ProviderProcessSupervisor,
|
||||
SpawnedOwnedProviderProcess,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
|
||||
|
||||
/** Maximum wall-clock duration for one native command. */
|
||||
export const NATIVE_COMMAND_TIMEOUT_MS = 60_000;
|
||||
/** Retain complete command output up to a bounded aggregate size. */
|
||||
export const NATIVE_COMMAND_OUTPUT_BUDGET_BYTES = 64 * 1024 * 1024;
|
||||
/**
|
||||
* Failure diagnostics keep only an excerpt of each stream. Successful
|
||||
* analysis needs complete output, but a failed command's error would
|
||||
* otherwise repeat up to the whole output budget to the caller.
|
||||
*/
|
||||
export const NATIVE_FAILURE_DIAGNOSTIC_BYTES = 64 * 1024;
|
||||
|
||||
export interface NativeCommandCapture {
|
||||
readonly tool: string;
|
||||
|
|
@ -383,18 +390,30 @@ type ProcessCapture = Omit<
|
|||
| "arguments"
|
||||
>;
|
||||
|
||||
const failureCapture = (
|
||||
/** Bound one command's output for failure diagnostics, keeping observed byte totals. */
|
||||
export const failureCapture = (
|
||||
capture: ProcessCapture,
|
||||
truncated: boolean,
|
||||
): NativeCommandFailureCapture => ({
|
||||
stdout: capture.stdout,
|
||||
stderr: capture.stderr,
|
||||
stdoutBytes: capture.stdoutBytes,
|
||||
stderrBytes: capture.stderrBytes,
|
||||
exitCode: capture.exitCode,
|
||||
signal: capture.signal,
|
||||
truncated,
|
||||
});
|
||||
): NativeCommandFailureCapture => {
|
||||
const stdout = boundedUtf8Text(
|
||||
capture.stdout,
|
||||
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
|
||||
);
|
||||
const stderr = boundedUtf8Text(
|
||||
capture.stderr,
|
||||
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
|
||||
);
|
||||
return {
|
||||
stdout: stdout.text,
|
||||
stderr: stderr.text,
|
||||
// Byte counts stay the observed totals, not the excerpt lengths.
|
||||
stdoutBytes: capture.stdoutBytes,
|
||||
stderrBytes: capture.stderrBytes,
|
||||
exitCode: capture.exitCode,
|
||||
signal: capture.signal,
|
||||
truncated: truncated || stdout.truncated || stderr.truncated,
|
||||
};
|
||||
};
|
||||
|
||||
const captureProcess = async (
|
||||
executable: string,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import {
|
|||
NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
|
||||
NativeCommandFailure,
|
||||
XcrunCommandRunner,
|
||||
failureCapture,
|
||||
type NativeCommandCleanupFailure,
|
||||
type NativeCommandCapture,
|
||||
type NativeCommandRunner,
|
||||
|
|
@ -997,15 +998,8 @@ const translateCodeSignExitFailure = (
|
|||
"nonzero-exit",
|
||||
capture.exitCode,
|
||||
undefined,
|
||||
{
|
||||
stdout: capture.stdout,
|
||||
stderr: capture.stderr,
|
||||
stdoutBytes: capture.stdoutBytes,
|
||||
stderrBytes: capture.stderrBytes,
|
||||
exitCode: capture.exitCode,
|
||||
signal: capture.signal,
|
||||
truncated: false,
|
||||
},
|
||||
// Accepted output stays complete until the provider decides it failed.
|
||||
failureCapture(capture, false),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js
|
|||
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
|
||||
import {
|
||||
NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
|
||||
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
|
||||
NativeCommandFailure,
|
||||
XcrunCommandRunner,
|
||||
type NativeCommandRunner,
|
||||
|
|
@ -381,6 +382,36 @@ describe("native command failure capture and limits", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("bounds both failure streams at a UTF-8 boundary while keeping their totals", async () => {
|
||||
const runner = new XcrunCommandRunner({}, () =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
);
|
||||
// "é" is two bytes and straddles the excerpt boundary, so it is dropped whole.
|
||||
const stdout = `${"x".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES - 1)}é${"y".repeat(1024)}`;
|
||||
const stderr = "z".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES + 1024);
|
||||
// Build the output in the child: one Linux argument is limited to 128 KiB.
|
||||
const result = await runner.run(
|
||||
"file",
|
||||
[
|
||||
"-e",
|
||||
`const stdout = "x".repeat(${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES - 1)}) + "\u00e9" + "y".repeat(1024); const stderr = "z".repeat(${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES + 1024)}); process.stdout.write(stdout, () => process.stderr.write(stderr, () => process.exit(3)));`,
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.reason).toBe("nonzero-exit");
|
||||
expect(result.error.capture).toMatchObject({
|
||||
stdout: "x".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES - 1),
|
||||
stderr: "z".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES),
|
||||
stdoutBytes: Buffer.byteLength(stdout),
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
exitCode: 3,
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects output beyond the complete native-command budget", async () => {
|
||||
const runner = new XcrunCommandRunner({}, () =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
|
|
@ -404,6 +435,13 @@ describe("native command failure capture and limits", () => {
|
|||
(result.error.capture?.stdoutBytes ?? 0) +
|
||||
(result.error.capture?.stderrBytes ?? 0),
|
||||
).toBeLessThanOrEqual(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES);
|
||||
// The failure carries an excerpt, not the whole retained output budget.
|
||||
expect(result.error.capture?.stdout.length).toBe(
|
||||
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
|
||||
);
|
||||
expect(result.error.capture?.stdoutBytes).toBeGreaterThan(
|
||||
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -834,6 +872,43 @@ describe("native signature entitlements", () => {
|
|||
});
|
||||
});
|
||||
|
||||
/** Fail every codesign call nonzero after writing more than one excerpt. */
|
||||
class VerboseCodeSignFailureRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || tool !== "codesign") return result;
|
||||
const stderr = "e".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES * 2);
|
||||
return ok({
|
||||
...result.value,
|
||||
stderr,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("native signature failure diagnostics", () => {
|
||||
it("bounds accepted nonzero codesign output once it becomes a failure", async () => {
|
||||
directory = await createTestTempDirectory("rea-signature-failure-");
|
||||
const executable = join(directory, "fixture");
|
||||
await writeFile(executable, "fixture");
|
||||
const client = new NativeMacOSProvider(
|
||||
{},
|
||||
new VerboseCodeSignFailureRunner(),
|
||||
"darwin",
|
||||
).createClient(await nativeMachoTargetForFile(executable));
|
||||
|
||||
const signature = await client.execute("inspect_signature", {});
|
||||
|
||||
if (signature.ok) throw new Error("A failed codesign run must fail");
|
||||
expect(signature.error.capturedOutput).toMatchObject({
|
||||
stderr: "e".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES),
|
||||
stderr_bytes: NATIVE_FAILURE_DIAGNOSTIC_BYTES * 2,
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("native dispatch metadata error results", () => {
|
||||
it("preserves tagged cancellation and integrity failures", async () => {
|
||||
directory = await createTestTempDirectory("rea-dispatch-errors-");
|
||||
|
|
|
|||
Loading…
Reference in New Issue