Skip to content

Commit 22bf85a

Browse files
authored
fix(execution): name the signal exit code of a failed sandbox process (#9523)
* fix(execution): name the signal that killed a sandbox process in its error * fix(execution): describe a signal's exit code without claiming a kill
1 parent d3a81eb commit 22bf85a

4 files changed

Lines changed: 82 additions & 5 deletions

File tree

‎apps/sim/lib/execution/remote-sandbox/code-failure.test.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, it } from 'vitest'
2-
import { processCodeFailure } from '@/lib/execution/remote-sandbox/code-failure'
2+
import {
3+
describeProcessExit,
4+
processCodeFailure,
5+
} from '@/lib/execution/remote-sandbox/code-failure'
36

47
/** Node 22's crash report when `fetch` loses its socket before any response bytes. */
58
const FETCH_SOCKET_CLOSED = `node:internal/deps/undici/undici:13510
@@ -196,6 +199,34 @@ describe('processCodeFailure', () => {
196199
expect(error).toEqual({ name, value, traceback: stderr })
197200
})
198201

202+
it('keeps the printed headline and adds the signal exit code beside it', () => {
203+
const warning = 'Warning: You are sending unauthenticated requests to the HF Hub.'
204+
const killed = processCodeFailure({ stdout: '', stderr: warning, exitCode: 137 })
205+
const exited = processCodeFailure({ stdout: '', stderr: warning, exitCode: 1 })
206+
207+
expect(killed.error).toEqual({
208+
name: 'Warning',
209+
value:
210+
'You are sending unauthenticated requests to the HF Hub. [Process exited with code 137 (the exit code of SIGKILL, usually out of memory)]',
211+
traceback: warning,
212+
})
213+
expect(exited.error).toEqual({
214+
name: 'Warning',
215+
value: 'You are sending unauthenticated requests to the HF Hub.',
216+
traceback: warning,
217+
})
218+
})
219+
220+
it.each([
221+
[1, 'Process exited with code 1'],
222+
[128, 'Process exited with code 128'],
223+
[129, 'Process exited with code 129 (the exit code of signal 1)'],
224+
[143, 'Process exited with code 143 (the exit code of SIGTERM)'],
225+
[193, 'Process exited with code 193'],
226+
])('describes exit code %i', (exitCode, description) => {
227+
expect(describeProcessExit(exitCode)).toBe(description)
228+
})
229+
199230
it('reads the traceback from stdout when stderr is empty', () => {
200231
const { error } = processCodeFailure({ stdout: PYTHON_TRACEBACK, stderr: '', exitCode: 1 })
201232

‎apps/sim/lib/execution/remote-sandbox/code-failure.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,37 @@ import type { SandboxCodeResult, SandboxCommandResult } from '@/lib/execution/re
1414
const ERROR_HEADLINE =
1515
/^(?:\[(?:[A-Za-z_$][\w.$]*)?(?:Error|Exception)|\[?[A-Za-z_$][\w.$]* \[[\w.$-]+\]|[A-Za-z_$][\w.$]*):(?:\s|$)/
1616

17+
const SIGNAL_NAMES: Readonly<Record<number, string>> = {
18+
6: 'SIGABRT',
19+
9: 'SIGKILL',
20+
11: 'SIGSEGV',
21+
15: 'SIGTERM',
22+
}
23+
24+
/**
25+
* `Process exited with code N`, naming the signal a code above 128 stands for. A process a signal
26+
* killed often printed only a warning first, so the code must be visible beside whatever it
27+
* printed. The code alone cannot prove a kill (a program may exit 137 itself), so it is described
28+
* as the signal's code; SIGKILL's (137) usually means the out-of-memory killer.
29+
*/
30+
export function describeProcessExit(exitCode: number): string {
31+
const note = signalCodeNote(exitCode)
32+
return `Process exited with code ${exitCode}${note ? ` (${note})` : ''}`
33+
}
34+
35+
/** What a shell's `128 + n` exit code stands for, if the code is one. */
36+
function signalCodeNote(exitCode: number): string | undefined {
37+
if (exitCode <= 128 || exitCode > 128 + 64) return undefined
38+
const signal = exitCode - 128
39+
const name = SIGNAL_NAMES[signal] ?? `signal ${signal}`
40+
return `the exit code of ${name}${signal === 9 ? ', usually out of memory' : ''}`
41+
}
42+
1743
/**
1844
* Converts a failed code command into a code result, naming the error from the
19-
* last error headline of the traceback (stderr, else stdout).
45+
* last error headline of the traceback (stderr, else stdout). A signal's exit code
46+
* is appended to the value, since a killed process's last printed line (often a
47+
* warning) is not necessarily why it stopped.
2048
*/
2149
export function processCodeFailure(result: SandboxCommandResult): SandboxCodeResult {
2250
const traceback = result.stderr || result.stdout
@@ -31,10 +59,13 @@ export function processCodeFailure(result: SandboxCommandResult): SandboxCodeRes
3159
const separator = errorLine?.indexOf(':') ?? -1
3260
const parsedErrorLine = errorLine ?? ''
3361
const name = separator > 0 ? parsedErrorLine.slice(0, separator) : 'Error'
34-
const value =
62+
const headlineValue =
3563
separator > 0
3664
? parsedErrorLine.slice(separator + 1).trim()
3765
: parsedErrorLine || 'Execution failed'
66+
const value = signalCodeNote(result.exitCode)
67+
? `${headlineValue} [${describeProcessExit(result.exitCode)}]`
68+
: headlineValue
3869
return {
3970
text: '',
4071
stdout: result.stdout,

‎apps/sim/lib/execution/remote-sandbox/conformance.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,18 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => {
12611261
})
12621262
})
12631263

1264+
it("leads a failed command's error with its exit code and the signal it stands for", async () => {
1265+
const warning = 'Warning: You are sending unauthenticated requests to the HF Hub.'
1266+
stubShellCommand(provider, provider === 'daytona' ? warning : '', warning, 137)
1267+
1268+
const res = await executeShellInSandbox({ code: 'python train.py', envs: {}, timeoutMs: 1000 })
1269+
1270+
expect(res.error).toBe(
1271+
`Process exited with code 137 (the exit code of SIGKILL, usually out of memory): ${warning}`
1272+
)
1273+
expect(res.stdout).toContain(warning)
1274+
})
1275+
12641276
it('terminates shell execution when streamed process output exceeds the byte budget', async () => {
12651277
const oversized = 'x'.repeat(MAX_SANDBOX_PROCESS_OUTPUT_BYTES + 1)
12661278
if (provider === 'e2b') {

‎apps/sim/lib/execution/remote-sandbox/index.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { recordSandboxTeardownFailure } from '@/lib/core/execution-limits/metrics'
1212
import { buildJavaScriptRuntimeBindingsSource } from '@/lib/execution/code-placeholders/javascript-runtime'
1313
import { SANDBOX_SYSTEM_PATH } from '@/lib/execution/remote-sandbox/cli-tools.server'
14+
import { describeProcessExit } from '@/lib/execution/remote-sandbox/code-failure'
1415
import {
1516
type CreatedSandbox,
1617
createSandbox,
@@ -1152,8 +1153,10 @@ async function executeShellInSandboxWithinBudget(
11521153
if (result.exitCode !== 0) {
11531154
// Daytona merges both streams into stdout (stderr is always empty), so fall
11541155
// back to stdout for the real command output before the generic message.
1155-
const errorMessage =
1156-
result.stderr || result.stdout || `Process exited with code ${result.exitCode}`
1156+
const output = result.stderr || result.stdout
1157+
const errorMessage = output
1158+
? `${describeProcessExit(result.exitCode)}: ${output}`
1159+
: describeProcessExit(result.exitCode)
11571160
logger.error('Sandbox shell execution error', {
11581161
sandboxId,
11591162
exitCode: result.exitCode,

0 commit comments

Comments
 (0)