* fix(action): cap subprocess stdout/stderr retention to prevent RangeError crashes (#680) unbounded `stdoutBuffer += chunk` / `stderrBuffer += chunk` in `action/utils/subprocess.ts` previously crashed the wrapper with `RangeError: Invalid string length` once V8's ~1 GiB kMaxLength was breached on long-lived agent runs. multi-lens opencode Reviews on large monorepos (e.g. tambo-ai/buildy) hit this consistently — 23 runs in the last 24h, 100% of Review-mode hard failures on that repo. - add `retain: "tail" | "none"` to SpawnOptions, defaulting to "tail" with an 8 MiB cap. tail-mode prepends a `... [N MiB truncated] ...` sentinel so downstream consumers can detect truncation. - export `TailBuffer` helper for callers that need the same bounded accumulator semantics at their own layer. - wrap stream `data` listeners in try/catch as defense in depth — any synchronous throw inside a stream handler is otherwise fatal. - opencode + claude pass `retain: "none"` (they drain via onStdout / onStderr) and switch their own `output` accumulators to TailBuffer. their error paths read the agent-layer bounded mirrors instead of the now-empty `result.stdout` / `result.stderr`. - add `failure:string-length-overflow` heuristic to scripts/analyze-logs.ts so post-fix recurrences are visible at a glance instead of bucketing into `failure:unknown`. - regression tests cover >1 MiB stderr without crash, retain:"none" contract, and TailBuffer truncation semantics. * fix: avoid TS parameter property syntax in TailBuffer for strip-only node loader * address review: clarify try/catch scope + lock retain default to "tail" - the original comment claimed the try/catch caught "any synchronous throw" in the data listener, but `options.onStdout?.(chunk)` returns a Promise in the agent callers (claude.ts:569, opencode.ts:933) — a throw inside an async user callback surfaces as an unhandled Promise rejection, not a synchronous exception. reword to describe the actual protection: defense-in-depth for synchronous throws in the listener body, which is exactly the shape of the original RangeError on `+= chunk`. - add a test that locks `retain` default to "tail" by spawning without the option and asserting `result.stderr` is non-empty. a future refactor that flipped the default to "none" would silently break gitAuth, package installs, and lifecycle hooks that read result.stderr for failure messages, and the rest of the suite wouldn't catch it.
This commit is contained in:
committed by
pullfrog[bot]
parent
60cc8772a6
commit
5aabd1e4a9
+30
-12
@@ -22,7 +22,13 @@ import { formatJsonValue, log } from "../utils/cli.ts";
|
||||
import { installFromNpmTarball } from "../utils/install.ts";
|
||||
import { detectProviderError } from "../utils/providerErrors.ts";
|
||||
import { addSkill, installBundledSkills } from "../utils/skills.ts";
|
||||
import { SPAWN_ACTIVITY_TIMEOUT_CODE, SpawnTimeoutError, spawn } from "../utils/subprocess.ts";
|
||||
import {
|
||||
DEFAULT_MAX_RETAINED_BYTES,
|
||||
SPAWN_ACTIVITY_TIMEOUT_CODE,
|
||||
SpawnTimeoutError,
|
||||
spawn,
|
||||
TailBuffer,
|
||||
} from "../utils/subprocess.ts";
|
||||
import { ThinkingTimer } from "../utils/timer.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||
@@ -536,7 +542,8 @@ export async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
|
||||
let lastProviderError: string | null = null;
|
||||
|
||||
let output = "";
|
||||
// capped accumulator — see opencode.ts for rationale (issue #680).
|
||||
const output = new TailBuffer(DEFAULT_MAX_RETAINED_BYTES);
|
||||
let stdoutBuffer = "";
|
||||
|
||||
try {
|
||||
@@ -554,9 +561,14 @@ export async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
// there's no shim-orphan issue like opencode-ai/bin/opencode, but
|
||||
// detached + killGroup is the right default for any agent runtime.
|
||||
killGroup: true,
|
||||
// claude already drains every chunk via onStdout (NDJSON parsing) and
|
||||
// onStderr (recentStderr ring buffer). retaining a second copy in the
|
||||
// spawn wrapper would grow unbounded for long sessions and previously
|
||||
// crashed the wrapper with RangeError. see issue #680.
|
||||
retain: "none",
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
output += text;
|
||||
output.append(text);
|
||||
markActivity();
|
||||
|
||||
stdoutBuffer += text;
|
||||
@@ -666,20 +678,26 @@ export async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
// hides the actionable provider message and pollutes the run log. cap
|
||||
// the stdout fallback to the last 2KB so it stays readable when neither
|
||||
// a structured error nor stderr is available.
|
||||
const truncatedStdout = result.stdout ? tailLines(result.stdout, 2048) : "";
|
||||
//
|
||||
// result.stdout / result.stderr are empty because we pass retain:"none"
|
||||
// to spawn (see issue #680); the agent layer keeps its own bounded
|
||||
// mirrors via `output` (TailBuffer) and `recentStderr` (ring buffer).
|
||||
const stdoutSnapshot = output.toString();
|
||||
const stderrSnapshot = recentStderr.join("\n");
|
||||
const truncatedStdout = stdoutSnapshot ? tailLines(stdoutSnapshot, 2048) : "";
|
||||
const errorMessage =
|
||||
lastResultError ||
|
||||
result.stderr ||
|
||||
stderrSnapshot ||
|
||||
truncatedStdout ||
|
||||
`unknown error - no output from Claude CLI${errorContext}`;
|
||||
log.error(
|
||||
`${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`
|
||||
);
|
||||
log.debug(`stdout: ${result.stdout?.substring(0, 500)}`);
|
||||
log.debug(`stderr: ${result.stderr?.substring(0, 500)}`);
|
||||
log.debug(`stdout: ${stdoutSnapshot.substring(0, 500)}`);
|
||||
log.debug(`stderr: ${stderrSnapshot.substring(0, 500)}`);
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
output: finalOutput || stdoutSnapshot,
|
||||
error: errorMessage,
|
||||
usage,
|
||||
sessionId,
|
||||
@@ -689,7 +707,7 @@ export async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
if (eventCount === 0 && lastProviderError) {
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
output: finalOutput || output.toString(),
|
||||
error: `provider error: ${lastProviderError}`,
|
||||
usage,
|
||||
sessionId,
|
||||
@@ -699,14 +717,14 @@ export async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
if (resultErrorSubtype) {
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
output: finalOutput || output.toString(),
|
||||
error: lastResultError || `result subtype: ${resultErrorSubtype}`,
|
||||
usage,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, output: finalOutput || output, usage, sessionId };
|
||||
return { success: true, output: finalOutput || output.toString(), usage, sessionId };
|
||||
} catch (error) {
|
||||
params.todoTracker?.cancel();
|
||||
const duration = performance.now() - startTime;
|
||||
@@ -732,7 +750,7 @@ export async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
output: finalOutput || output.toString(),
|
||||
error: `${errorMessage} [${diagnosis}]`,
|
||||
usage: buildUsage(),
|
||||
sessionId,
|
||||
|
||||
+37
-12
@@ -22,7 +22,13 @@ import { formatJsonValue, log } from "../utils/cli.ts";
|
||||
import { installFromNpmTarball } from "../utils/install.ts";
|
||||
import { detectProviderError } from "../utils/providerErrors.ts";
|
||||
import { addSkill, installBundledSkills } from "../utils/skills.ts";
|
||||
import { SPAWN_ACTIVITY_TIMEOUT_CODE, SpawnTimeoutError, spawn } from "../utils/subprocess.ts";
|
||||
import {
|
||||
DEFAULT_MAX_RETAINED_BYTES,
|
||||
SPAWN_ACTIVITY_TIMEOUT_CODE,
|
||||
SpawnTimeoutError,
|
||||
spawn,
|
||||
TailBuffer,
|
||||
} from "../utils/subprocess.ts";
|
||||
import { ThinkingTimer } from "../utils/timer.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||
@@ -888,7 +894,12 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
let lastProviderError: string | null = null;
|
||||
let agentErrorEvent: OpenCodeErrorEvent | null = null;
|
||||
|
||||
let output = "";
|
||||
// capped accumulator for the agent's narration. used as a post-run fallback
|
||||
// when `finalOutput` (the orchestrator's final assistant message) is empty.
|
||||
// unbounded `output += text` previously grew to ~1 GiB on multi-lens Reviews
|
||||
// and contributed to the wrapper-level RangeError. retain:"none" on spawn
|
||||
// skips the duplicate buffer there; this TailBuffer caps the agent layer.
|
||||
const output = new TailBuffer(DEFAULT_MAX_RETAINED_BYTES);
|
||||
let stdoutBuffer = "";
|
||||
|
||||
try {
|
||||
@@ -907,6 +918,11 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
// never fires — producing zombie runs. detached + killGroup nukes the
|
||||
// whole tree.
|
||||
killGroup: true,
|
||||
// we already drain every chunk via onStdout/onStderr (NDJSON parsing
|
||||
// + recentStderr ring buffer). retaining a second copy in the spawn
|
||||
// wrapper would grow unbounded for multi-lens Reviews and previously
|
||||
// crashed the wrapper with RangeError at ~1 GiB. see issue #680.
|
||||
retain: "none",
|
||||
// NB: we used to pass `isPausedExternally: isSubagentInFlight` to suspend
|
||||
// the activity timer during subagent dispatches. unnecessary now that
|
||||
// our injected plugin (action/agents/opencodePlugin.ts) re-emits
|
||||
@@ -916,7 +932,7 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
// (~3.3 plugin events/sec during a typical subagent run).
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
output += text;
|
||||
output.append(text);
|
||||
markActivity();
|
||||
|
||||
stdoutBuffer += text;
|
||||
@@ -1041,22 +1057,31 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
|
||||
// result.stdout / result.stderr are empty because we pass retain:"none"
|
||||
// to spawn (see issue #680); use the agent's bounded mirrors instead.
|
||||
const stdoutSnapshot = output.toString();
|
||||
const stderrSnapshot = recentStderr.join("\n");
|
||||
const errorMessage =
|
||||
result.stderr ||
|
||||
result.stdout ||
|
||||
stderrSnapshot ||
|
||||
stdoutSnapshot ||
|
||||
`unknown error - no output from OpenCode CLI${errorContext}`;
|
||||
log.error(
|
||||
`${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`
|
||||
);
|
||||
log.debug(`stdout: ${result.stdout?.substring(0, 500)}`);
|
||||
log.debug(`stderr: ${result.stderr?.substring(0, 500)}`);
|
||||
return { success: false, output: finalOutput || output, error: errorMessage, usage };
|
||||
log.debug(`stdout: ${stdoutSnapshot.substring(0, 500)}`);
|
||||
log.debug(`stderr: ${stderrSnapshot.substring(0, 500)}`);
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || stdoutSnapshot,
|
||||
error: errorMessage,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
if (eventCount === 0 && lastProviderError) {
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
output: finalOutput || output.toString(),
|
||||
error: `provider error: ${lastProviderError}`,
|
||||
usage,
|
||||
};
|
||||
@@ -1069,13 +1094,13 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
errorEvent.error?.data?.message || errorEvent.error?.name || JSON.stringify(errorEvent);
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
output: finalOutput || output.toString(),
|
||||
error: `${errorName}: ${errorMessage}`,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, output: finalOutput || output, usage };
|
||||
return { success: true, output: finalOutput || output.toString(), usage };
|
||||
} catch (error) {
|
||||
params.todoTracker?.cancel();
|
||||
const duration = performance.now() - startTime;
|
||||
@@ -1101,7 +1126,7 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
output: finalOutput || output.toString(),
|
||||
error: `${errorMessage} [${diagnosis}]`,
|
||||
usage: buildUsage(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user