From d669bf57653acbcffc84184d64ed3ae509bec312 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:17:03 +0000 Subject: [PATCH 1/5] Initial plan From 46b0fb105920a40a7413a0b8aa782828d7d285b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:32:16 +0000 Subject: [PATCH 2/5] Classify fatal-signal crash exit codes (SIGSEGV/SIGSYS/etc.) in Claude harness retry logic Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/claude_harness.cjs | 62 ++++++++++++++++++++---- actions/setup/js/claude_harness.test.cjs | 41 ++++++++++++++++ 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/actions/setup/js/claude_harness.cjs b/actions/setup/js/claude_harness.cjs index 28dce99a86f..4cdb257b40c 100644 --- a/actions/setup/js/claude_harness.cjs +++ b/actions/setup/js/claude_harness.cjs @@ -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; /** @@ -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 {{ @@ -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) { @@ -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}` + @@ -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, @@ -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})`); @@ -666,6 +708,8 @@ if (typeof module !== "undefined" && module.exports) { isConnectionRefusedError, hasClaudeSessionProgress, isSignalTerminationExitCode, + isCrashSignalExitCode, + crashSignalNameForExitCode, shouldRetryWithContinue, countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, diff --git a/actions/setup/js/claude_harness.test.cjs b/actions/setup/js/claude_harness.test.cjs index 0d4be75337f..d7e6a1e4022 100644 --- a/actions/setup/js/claude_harness.test.cjs +++ b/actions/setup/js/claude_harness.test.cjs @@ -18,6 +18,8 @@ const { isConnectionRefusedError, hasClaudeSessionProgress, isSignalTerminationExitCode, + isCrashSignalExitCode, + crashSignalNameForExitCode, shouldRetryWithContinue, countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, @@ -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"; @@ -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"); From 2070ca96615bb69921658d4df9bd4cf3e31acba1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:15:09 +0000 Subject: [PATCH 3/5] Generalize fatal-signal crash classification to Copilot harness Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/claude_harness.cjs | 37 +--------- actions/setup/js/copilot_harness.cjs | 16 ++++- actions/setup/js/copilot_harness.test.cjs | 72 +++++++++++++++++++ actions/setup/js/harness_crash_signals.cjs | 47 ++++++++++++ .../setup/js/harness_crash_signals.test.cjs | 40 +++++++++++ 5 files changed, 175 insertions(+), 37 deletions(-) create mode 100644 actions/setup/js/harness_crash_signals.cjs create mode 100644 actions/setup/js/harness_crash_signals.test.cjs diff --git a/actions/setup/js/claude_harness.cjs b/actions/setup/js/claude_harness.cjs index 4cdb257b40c..68106b040ce 100644 --- a/actions/setup/js/claude_harness.cjs +++ b/actions/setup/js/claude_harness.cjs @@ -58,6 +58,7 @@ const { const { emitMissingToolPermissionIssue, hasExpectedSafeOutputs, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs"); const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs"); const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSignal, isAuthenticationFailedError } = require("./harness_retry_guard.cjs"); +const { isCrashSignalExitCode, crashSignalNameForExitCode } = require("./harness_crash_signals.cjs"); const { MODEL_NOT_SUPPORTED_PATTERN: INVALID_MODEL_ERROR_PATTERN } = require("./detect_agent_errors.cjs"); const { applyModelFallback } = require("./model_fallback.cjs"); const { parseMaxAICreditsExceededFromAuditLog } = require("./ai_credits_context.cjs"); @@ -98,20 +99,6 @@ 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; /** @@ -258,28 +245,6 @@ 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 {{ diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index b485c7b3243..cbc3fd6e1f1 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -30,6 +30,11 @@ * history and permanently disables `--continue` for the remainder of the run so the corrupt * state can never be reloaded. Once `--continue` is disabled this way it is not re-enabled * even if later retries produce output. + * - Exit codes that indicate the CLI subprocess was killed by a fatal OS-level signal + * (SIGILL/SIGABRT/SIGBUS/SIGFPE/SIGSEGV/SIGSYS — see harness_crash_signals.cjs) are treated + * like the null-type tool_call case: `--continue` is permanently disabled and the next retry + * starts a fresh session, since resuming the exact on-disk session risks immediately + * reproducing the same crash. * - Retries use exponential backoff: 5s → 10s → 20s (capped at 60s) by default. * - Maximum 3 retry attempts after the initial run by default. * @@ -75,6 +80,7 @@ const { const { runSafeOutputsCLI, buildMissingToolAlternatives, emitMissingToolPermissionIssue, emitInfrastructureIncomplete, hasExpectedSafeOutputs, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs"); const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs"); const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSignal, isAuthenticationFailedError: isCommonAuthenticationFailedError } = require("./harness_retry_guard.cjs"); +const { isCrashSignalExitCode, crashSignalNameForExitCode } = require("./harness_crash_signals.cjs"); const { isCAPIQuotaExceededError } = require("./detect_agent_errors.cjs"); const { applyModelFallback } = require("./model_fallback.cjs"); const { loadModelsJson } = require("./model_costs.cjs"); @@ -1382,10 +1388,16 @@ async function main() { if (shouldRetryFailedExecution({ ...result, attempt, maxRetries })) { const reason = isCAPIError ? "CAPIError 400 (transient)" : "partial execution"; + const isCrashSignal = isCrashSignalExitCode(result.exitCode); + const crashSignalName = crashSignalNameForExitCode(result.exitCode); + if (isCrashSignal) { + continueDisabledPermanently = true; + } // --continue is only meaningful in CLI mode; SDK mode always restarts fresh. useContinueOnRetry = !copilotSDKMode && !continueDisabledPermanently; const retryMode = useContinueOnRetry ? "--continue" : copilotSDKMode ? "fresh run" : "fresh run (--continue permanently disabled)"; - log(`attempt ${attempt + 1}: ${reason} — will retry with ${retryMode} (attempt ${attempt + 2}/${maxRetries + 1})`); + const crashSuffix = isCrashSignal ? ` crashSignal=${crashSignalName}` : ""; + log(`attempt ${attempt + 1}: ${reason} — will retry with ${retryMode} (attempt ${attempt + 2}/${maxRetries + 1})${crashSuffix}`); return { action: "retry" }; } @@ -1461,6 +1473,8 @@ if (typeof module !== "undefined" && module.exports) { classifyCopilotFailure, extractTokenCountFromOutput, shouldRetryFailedExecution, + isCrashSignalExitCode, + crashSignalNameForExitCode, extractOutputTail, isRetryableProxyAuthenticationFailure, hasNumerousPermissionDeniedIssues, diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index dacbe470e76..8b19ac33e63 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -53,6 +53,8 @@ const { resolvePromptFileArgs, resolveRetryConfig, shouldRetryFailedExecution, + isCrashSignalExitCode, + crashSignalNameForExitCode, writeCopilotOutputs, parseCopilotSDKServerArgsFromEnv, applyCopilotWireAPI, @@ -1776,6 +1778,76 @@ describe("copilot_harness.cjs", () => { }); }); + describe("fatal-signal crash exit codes disable --continue", () => { + it("recognizes known fatal-signal exit codes", () => { + expect(isCrashSignalExitCode(134)).toBe(true); // SIGABRT + expect(isCrashSignalExitCode(139)).toBe(true); // SIGSEGV + expect(isCrashSignalExitCode(159)).toBe(true); // SIGSYS + expect(crashSignalNameForExitCode(134)).toBe("SIGABRT"); + expect(crashSignalNameForExitCode(139)).toBe("SIGSEGV"); + expect(crashSignalNameForExitCode(159)).toBe("SIGSYS"); + }); + + it("does not classify normal exit codes or timeout/cancellation signals as crashes", () => { + expect(isCrashSignalExitCode(1)).toBe(false); + expect(isCrashSignalExitCode(137)).toBe(false); // SIGKILL — timeout/cancellation, not a crash + expect(isCrashSignalExitCode(143)).toBe(false); // SIGTERM — timeout/cancellation, not a crash + expect(crashSignalNameForExitCode(1)).toBeNull(); + expect(crashSignalNameForExitCode(137)).toBeNull(); + }); + + // Inline the same retry logic as the driver's shouldRetryFailedExecution handler, + // including the crash-signal guard: a fatal-signal crash (SIGSEGV, SIGSYS, ...) + // must never be retried with --continue since resuming the on-disk session risks + // immediately reproducing the crash. + const MAX_RETRIES = 3; + + /** + * @param {{hasOutput: boolean, exitCode: number}} result + * @param {number} attempt + * @param {boolean} continueDisabledPermanently + * @returns {{ shouldRetry: boolean, useContinueOnRetry: boolean, continueDisabledPermanently: boolean }} + */ + function applyRetryPolicy(result, attempt, continueDisabledPermanently = false) { + if (result.exitCode === 0) return { shouldRetry: false, useContinueOnRetry: false, continueDisabledPermanently }; + if (!(attempt < MAX_RETRIES && result.hasOutput)) { + return { shouldRetry: false, useContinueOnRetry: false, continueDisabledPermanently }; + } + const isCrashSignal = isCrashSignalExitCode(result.exitCode); + const nextContinueDisabledPermanently = continueDisabledPermanently || isCrashSignal; + return { shouldRetry: true, useContinueOnRetry: !nextContinueDisabledPermanently, continueDisabledPermanently: nextContinueDisabledPermanently }; + } + + it("disables --continue and restarts fresh after a SIGSYS (159) crash", () => { + const result = { exitCode: 159, hasOutput: true }; + const { shouldRetry, useContinueOnRetry, continueDisabledPermanently } = applyRetryPolicy(result, 0, false); + expect(shouldRetry).toBe(true); + expect(useContinueOnRetry).toBe(false); + expect(continueDisabledPermanently).toBe(true); + }); + + it("disables --continue and restarts fresh after a SIGSEGV (139) crash", () => { + const result = { exitCode: 139, hasOutput: true }; + const { shouldRetry, useContinueOnRetry, continueDisabledPermanently } = applyRetryPolicy(result, 0, false); + expect(shouldRetry).toBe(true); + expect(useContinueOnRetry).toBe(false); + expect(continueDisabledPermanently).toBe(true); + }); + + it("keeps --continue disabled on subsequent retries after a crash-signal restart", () => { + const crashResult = { exitCode: 134, hasOutput: true }; + const after0 = applyRetryPolicy(crashResult, 0, false); + expect(after0.useContinueOnRetry).toBe(false); + expect(after0.continueDisabledPermanently).toBe(true); + + const nextResult = { exitCode: 1, hasOutput: true }; + const after1 = applyRetryPolicy(nextResult, 1, after0.continueDisabledPermanently); + expect(after1.shouldRetry).toBe(true); + expect(after1.useContinueOnRetry).toBe(false); // must not re-enable --continue + expect(after1.continueDisabledPermanently).toBe(true); + }); + }); + describe("permanent --continue disable guard", () => { // Inline retry logic to verify that once continueDisabledPermanently is set, // subsequent partial-execution retries never re-enable --continue. diff --git a/actions/setup/js/harness_crash_signals.cjs b/actions/setup/js/harness_crash_signals.cjs new file mode 100644 index 00000000000..b33f811594f --- /dev/null +++ b/actions/setup/js/harness_crash_signals.cjs @@ -0,0 +1,47 @@ +// @ts-check + +"use strict"; + +// Exit codes (128 + signal number) that indicate an agentic 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"], +]); + +/** + * Determines whether the exit code corresponds to a fatal-signal crash of a CLI + * subprocess (e.g. SIGSEGV=139, SIGSYS=159) as opposed to a normal application + * error or an expected timeout/cancellation signal (e.g. 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; +} + +module.exports = { + CRASH_SIGNAL_EXIT_CODES, + isCrashSignalExitCode, + crashSignalNameForExitCode, +}; diff --git a/actions/setup/js/harness_crash_signals.test.cjs b/actions/setup/js/harness_crash_signals.test.cjs new file mode 100644 index 00000000000..265ca811273 --- /dev/null +++ b/actions/setup/js/harness_crash_signals.test.cjs @@ -0,0 +1,40 @@ +// @ts-check + +import { describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { CRASH_SIGNAL_EXIT_CODES, isCrashSignalExitCode, crashSignalNameForExitCode } = require("./harness_crash_signals.cjs"); + +describe("harness_crash_signals.cjs", () => { + it("classifies known fatal-signal exit codes as crashes", () => { + for (const [exitCode, signalName] of CRASH_SIGNAL_EXIT_CODES) { + expect(isCrashSignalExitCode(exitCode)).toBe(true); + expect(crashSignalNameForExitCode(exitCode)).toBe(signalName); + } + }); + + it("does not classify normal exit codes as crashes", () => { + expect(isCrashSignalExitCode(0)).toBe(false); + expect(isCrashSignalExitCode(1)).toBe(false); + expect(isCrashSignalExitCode(2)).toBe(false); + expect(crashSignalNameForExitCode(0)).toBeNull(); + expect(crashSignalNameForExitCode(1)).toBeNull(); + }); + + it("does not classify expected timeout/cancellation signals (SIGKILL/SIGTERM) as crashes", () => { + expect(isCrashSignalExitCode(137)).toBe(false); // SIGKILL + expect(isCrashSignalExitCode(143)).toBe(false); // SIGTERM + expect(crashSignalNameForExitCode(137)).toBeNull(); + expect(crashSignalNameForExitCode(143)).toBeNull(); + }); + + it("maps exit codes to the expected signal names", () => { + expect(crashSignalNameForExitCode(132)).toBe("SIGILL"); + expect(crashSignalNameForExitCode(134)).toBe("SIGABRT"); + expect(crashSignalNameForExitCode(135)).toBe("SIGBUS"); + expect(crashSignalNameForExitCode(136)).toBe("SIGFPE"); + expect(crashSignalNameForExitCode(139)).toBe("SIGSEGV"); + expect(crashSignalNameForExitCode(159)).toBe("SIGSYS"); + }); +}); From b688609e83125b3ee50b493128e31915f4e93361 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:40:06 +0000 Subject: [PATCH 4/5] Preserve child signal in runProcess for crash-signal classification Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/process_runner.cjs | 26 ++++++++++++++++++++++-- actions/setup/js/process_runner.test.cjs | 15 ++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/process_runner.cjs b/actions/setup/js/process_runner.cjs index 018a75a2afa..c5fafabac3f 100644 --- a/actions/setup/js/process_runner.cjs +++ b/actions/setup/js/process_runner.cjs @@ -15,6 +15,24 @@ "use strict"; const { spawn } = require("child_process"); +const os = require("os"); + +/** + * Convert a Node.js child-process termination signal (e.g. "SIGSYS") into the + * conventional shell-style exit code (128 + signal number), matching the value + * the OS would report if the process had exited with that status directly. + * Node only reports a non-null `signal` when the process was killed by a signal + * (in which case `code` is null), so the caller must synthesize an exit code to + * preserve the fatal-signal information for downstream classification (see + * harness_crash_signals.cjs). Returns null when the signal name is unrecognized. + * @param {NodeJS.Signals | null} signal + * @returns {number | null} + */ +function exitCodeForSignal(signal) { + if (!signal) return null; + const signalNumber = os.constants.signals[signal]; + return typeof signalNumber === "number" ? 128 + signalNumber : null; +} /** * Format elapsed milliseconds as a human-readable string (e.g. "3m 12s"). @@ -165,13 +183,17 @@ function runProcess({ command, args, attempt, log, logArgs, env, postResultWatch } child.on("exit", (code, signal) => { - log(`attempt ${attempt + 1}: process exit event` + ` exitCode=${code ?? 1}` + (signal ? ` signal=${signal}` : "")); + log(`attempt ${attempt + 1}: process exit event` + ` exitCode=${code ?? exitCodeForSignal(signal) ?? 1}` + (signal ? ` signal=${signal}` : "")); }); // Resolve on 'close', not 'exit', to ensure stdio streams are fully drained. child.on("close", (code, signal) => { const durationMs = Date.now() - startTime; - const exitCode = code ?? 1; + // When the process is killed by a signal, Node reports code=null and a signal + // name instead. Synthesize the conventional 128+signal exit code so fatal-signal + // crashes (e.g. SIGSYS) are visible to exit-code-based retry classification even + // when the shell/runtime never reports a raw numeric exit status. + const exitCode = code ?? exitCodeForSignal(signal) ?? 1; const watchdogFired = sentSigtermAt > 0; log( `attempt ${attempt + 1}: process closed` + diff --git a/actions/setup/js/process_runner.test.cjs b/actions/setup/js/process_runner.test.cjs index 82401d9b0f0..f7577497678 100644 --- a/actions/setup/js/process_runner.test.cjs +++ b/actions/setup/js/process_runner.test.cjs @@ -71,6 +71,21 @@ describe("process_runner.cjs", () => { expect(result.exitCode).toBe(42); }); + it("synthesizes the conventional 128+signal exit code when the child is killed by a fatal signal", async () => { + const logs = []; + const result = await runProcess({ + command: process.execPath, + // Self-signal rather than relying on the OS to deliver SIGSEGV so the test is + // deterministic across platforms; Node reports code=null, signal="SIGSEGV" here, + // exactly as it would for a real crash. + args: ["-e", "process.kill(process.pid, 'SIGSEGV')"], + attempt: 0, + log: msg => logs.push(msg), + }); + expect(result.exitCode).toBe(139); // 128 + SIGSEGV(11) + expect(logs.some(l => l.includes("signal=SIGSEGV"))).toBe(true); + }); + it("collects stdout output and sets hasOutput", async () => { const logs = []; const result = await runProcess({ From d27061a13ff14b1443f231f78a7a82a6f0a933c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:58:17 +0000 Subject: [PATCH 5/5] Fix prettier formatting in eslint-factory lastIndex-reset rule Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...uire-lastindex-reset-before-global-exec-loop.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/eslint-factory/src/rules/require-lastindex-reset-before-global-exec-loop.ts b/eslint-factory/src/rules/require-lastindex-reset-before-global-exec-loop.ts index 7284a2e891e..22107ab675a 100644 --- a/eslint-factory/src/rules/require-lastindex-reset-before-global-exec-loop.ts +++ b/eslint-factory/src/rules/require-lastindex-reset-before-global-exec-loop.ts @@ -39,11 +39,13 @@ export const requireLastIndexResetBeforeGlobalExecLoopRule = createRule({ meta: { type: "problem", docs: { - description: "Require resetting `.lastIndex = 0` on a module-scoped global/sticky regex before a `while ((match = RE.exec(str)))` loop, since the shared stateful regex resumes scanning from wherever the previous call left off across separate invocations.", + description: + "Require resetting `.lastIndex = 0` on a module-scoped global/sticky regex before a `while ((match = RE.exec(str)))` loop, since the shared stateful regex resumes scanning from wherever the previous call left off across separate invocations.", }, schema: [], messages: { - requireLastIndexReset: "Regex '{{name}}' has the 'g' or 'y' flag and is reused across calls, but its 'lastIndex' is never reset before this exec loop. If a prior call ended mid-string (e.g. threw, returned early, or ran out of matches on shorter input), this loop can silently skip matches or miss content entirely. Add '{{name}}.lastIndex = 0;' before the loop.", + requireLastIndexReset: + "Regex '{{name}}' has the 'g' or 'y' flag and is reused across calls, but its 'lastIndex' is never reset before this exec loop. If a prior call ended mid-string (e.g. threw, returned early, or ran out of matches on shorter input), this loop can silently skip matches or miss content entirely. Add '{{name}}.lastIndex = 0;' before the loop.", }, }, defaultOptions: [], @@ -77,7 +79,13 @@ export const requireLastIndexResetBeforeGlobalExecLoopRule = createRule({ // Only look within the nearest enclosing function to avoid false negatives from // resets that belong to an unrelated, earlier function using the same regex. let enclosing: TSESTree.Node | undefined = node.parent; - while (enclosing && enclosing.type !== AST_NODE_TYPES.FunctionDeclaration && enclosing.type !== AST_NODE_TYPES.FunctionExpression && enclosing.type !== AST_NODE_TYPES.ArrowFunctionExpression && enclosing.type !== AST_NODE_TYPES.Program) { + while ( + enclosing && + enclosing.type !== AST_NODE_TYPES.FunctionDeclaration && + enclosing.type !== AST_NODE_TYPES.FunctionExpression && + enclosing.type !== AST_NODE_TYPES.ArrowFunctionExpression && + enclosing.type !== AST_NODE_TYPES.Program + ) { enclosing = enclosing.parent; } const scanStart = enclosing ? enclosing.range[0] : 0;