Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions actions/setup/js/claude_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,20 @@ const MAX_TURNS_EXIT_PATTERN = /"subtype"\s*:\s*"error_max_turns"/;
// this path must not be retried via --continue (fall back to a fresh run if budget remains).
const NO_DEFERRED_MARKER_PATTERN = /No deferred tool marker found/i;
const SIGNAL_TERMINATION_EXIT_CODES = new Set([137, 143]);
// Exit codes (128 + signal number) that indicate the Claude Code CLI subprocess was
// killed by a fatal OS-level signal rather than exiting normally or being cancelled.
// These signify a sandbox/runtime-level crash (e.g. a bad syscall trapped by seccomp,
// a segfault, or an illegal instruction) rather than an application-level error, so
// resuming the same on-disk session with --continue risks immediately reproducing the
// same crash. Map: SIGILL=4, SIGABRT=6, SIGBUS=7, SIGFPE=8, SIGSEGV=11, SIGSYS=31.
const CRASH_SIGNAL_EXIT_CODES = new Map([
[132, "SIGILL"],
[134, "SIGABRT"],
[135, "SIGBUS"],
[136, "SIGFPE"],
[139, "SIGSEGV"],
[159, "SIGSYS"],
]);
const MAX_STARTUP_RETRIES = 2;

/**
Expand Down Expand Up @@ -244,6 +258,28 @@ function isSignalTerminationExitCode(exitCode) {
return SIGNAL_TERMINATION_EXIT_CODES.has(exitCode);
}

/**
* Determines whether the exit code corresponds to a fatal-signal crash of the CLI
* subprocess (e.g. SIGSEGV=139, SIGSYS=159) as opposed to a normal application error
* or an expected timeout/cancellation signal (SIGKILL=137/SIGTERM=143).
* @param {number} exitCode
* @returns {boolean}
*/
function isCrashSignalExitCode(exitCode) {
return CRASH_SIGNAL_EXIT_CODES.has(exitCode);
}

/**
* Best-effort mapping of a fatal-signal exit code (128 + signal number) to its
* signal name, for diagnostic logging. Returns null when the exit code is not a
* recognized crash signal.
* @param {number} exitCode
* @returns {string | null}
*/
function crashSignalNameForExitCode(exitCode) {
return CRASH_SIGNAL_EXIT_CODES.get(exitCode) ?? null;
}

/**
* Decide whether the next retry should use --continue.
* @param {{
Expand All @@ -260,7 +296,7 @@ function shouldRetryWithContinue({ attempt, maxRetries, exitCode, hasOutput, isN
if (attempt >= maxRetries || !hasOutput || continueDisabledPermanently) {
return false;
}
if (isSignalTerminationExitCode(exitCode)) {
if (isSignalTerminationExitCode(exitCode) || isCrashSignalExitCode(exitCode)) {
return false;
}
if (isNoDeferredMarker) {
Expand Down Expand Up @@ -502,9 +538,11 @@ async function main() {
sessionHasProgress = sessionHasProgress || hasClaudeSessionProgress(result.output);
const permissionDeniedCount = countPermissionDeniedIssues(result.output);
const hasNumerousPermissionDenied = hasNumerousPermissionDeniedIssues(result.output);
const crashSignalName = crashSignalNameForExitCode(result.exitCode);
log(
`attempt ${attempt + 1} failed:` +
` exitCode=${result.exitCode}` +
(crashSignalName ? ` crashSignal=${crashSignalName}` : "") +
` isOverloadedError=${isOverloaded}` +
` isRateLimitError=${isRateLimit}` +
` isAuthenticationFailedError=${isAuthenticationFailed}` +
Expand Down Expand Up @@ -601,6 +639,8 @@ async function main() {

if (attempt < maxRetries && result.hasOutput) {
const isSignalTermination = isSignalTerminationExitCode(result.exitCode);
const isCrashSignal = isCrashSignalExitCode(result.exitCode);
const crashSignalName = crashSignalNameForExitCode(result.exitCode);
const retryWithContinue = shouldRetryWithContinue({
attempt,
maxRetries,
Expand All @@ -609,16 +649,18 @@ async function main() {
isNoDeferredMarker,
continueDisabledPermanently,
});
if (isSignalTermination) {
if (isSignalTermination || isCrashSignal) {
continueDisabledPermanently = true;
}
const reason = isSignalTermination
? `signal-style termination exitCode=${result.exitCode} (failure_reason=cancelled_or_timed_out)`
: isOverloaded
? "overloaded_error (transient)"
: isRateLimit
? "rate_limit_error (transient)"
: "partial execution";
const reason = isCrashSignal
? `fatal-signal crash exitCode=${result.exitCode} (signal=${crashSignalName}, failure_reason=sandbox_runtime_crash)`
: isSignalTermination
? `signal-style termination exitCode=${result.exitCode} (failure_reason=cancelled_or_timed_out)`
: isOverloaded
? "overloaded_error (transient)"
: isRateLimit
? "rate_limit_error (transient)"
: "partial execution";
useContinueOnRetry = retryWithContinue;
const retryMode = retryWithContinue ? "--continue" : "fresh run (--continue disabled permanently)";
log(`attempt ${attempt + 1}: ${reason} — will retry with ${retryMode} (attempt ${attempt + 2}/${maxRetries + 1})`);
Expand Down Expand Up @@ -666,6 +708,8 @@ if (typeof module !== "undefined" && module.exports) {
isConnectionRefusedError,
hasClaudeSessionProgress,
isSignalTerminationExitCode,
isCrashSignalExitCode,
crashSignalNameForExitCode,
shouldRetryWithContinue,
countPermissionDeniedIssues,
hasNumerousPermissionDeniedIssues,
Expand Down
41 changes: 41 additions & 0 deletions actions/setup/js/claude_harness.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const {
isConnectionRefusedError,
hasClaudeSessionProgress,
isSignalTerminationExitCode,
isCrashSignalExitCode,
crashSignalNameForExitCode,
shouldRetryWithContinue,
countPermissionDeniedIssues,
hasNumerousPermissionDeniedIssues,
Expand Down Expand Up @@ -336,6 +338,31 @@ describe("claude_harness.cjs", () => {
});
});

describe("isCrashSignalExitCode / crashSignalNameForExitCode", () => {
it("identifies known fatal-signal crash exit codes", () => {
expect(isCrashSignalExitCode(139)).toBe(true); // SIGSEGV
expect(isCrashSignalExitCode(159)).toBe(true); // SIGSYS
expect(isCrashSignalExitCode(134)).toBe(true); // SIGABRT
});

it("returns false for non-crash exit codes, including timeout/cancellation signals", () => {
expect(isCrashSignalExitCode(0)).toBe(false);
expect(isCrashSignalExitCode(1)).toBe(false);
expect(isCrashSignalExitCode(137)).toBe(false);
expect(isCrashSignalExitCode(143)).toBe(false);
});

it("maps known crash exit codes to their signal name", () => {
expect(crashSignalNameForExitCode(139)).toBe("SIGSEGV");
expect(crashSignalNameForExitCode(159)).toBe("SIGSYS");
});

it("returns null for exit codes that are not recognized crash signals", () => {
expect(crashSignalNameForExitCode(1)).toBeNull();
expect(crashSignalNameForExitCode(137)).toBeNull();
});
});

describe("permission-denied classification helpers", () => {
it("counts repeated permission-denied signals", () => {
const output = "permission denied\nEACCES: permission denied\npermissions denied";
Expand Down Expand Up @@ -421,6 +448,20 @@ describe("claude_harness.cjs", () => {
}
});

it("does not use --continue for fatal-signal crash exit codes", () => {
for (const exitCode of [134, 139, 159]) {
const result = shouldRetryWithContinue({
attempt: 0,
maxRetries: 3,
exitCode,
hasOutput: true,
isNoDeferredMarker: false,
continueDisabledPermanently: false,
});
expect(result).toBe(false);
}
});

it("uses a fresh retry after a --continue attempt hits no-deferred-marker", () => {
const stubScript = `
const fs = require("fs");
Expand Down
Loading