start-command 0.35.0 → 0.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/usage.js CHANGED
@@ -18,6 +18,7 @@ Options:
18
18
  --network-alias <alias> Add alias to the first network (repeatable, docker only)
19
19
  --on-kill-resume <N> Resume a killed (exit 137 / OOM) detached docker session up to N times
20
20
  --recovery-command <cmd> Command to run in the same container on such a resume
21
+ --on-kill-resume-delay <min[-max]> Wait a random number of seconds before each such resume (default 0)
21
22
  --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
22
23
  --isolated-user, -u [name] Create isolated user with same permissions
23
24
  --keep-user Keep isolated user after command completes
@@ -69,6 +70,7 @@ Examples:
69
70
  $ --resume my-docker-session -- bash # Run a different command in the same container
70
71
  $ --resume-all # Re-attach every still-running execution
71
72
  $ -i docker -d --on-kill-resume 3 --recovery-command 'solve --resume' -- solve
73
+ $ -i docker -d --on-kill-resume 3 --on-kill-resume-delay 30-90 -- cargo test
72
74
  $ --use-command-stream echo "Hello" # Use command-stream library`);
73
75
  console.log('');
74
76
  console.log('Piping with $:');
@@ -307,6 +307,7 @@ describe('buildDockerRuntimeMetadata', () => {
307
307
  resourceLimits: null,
308
308
  onKillResume: null,
309
309
  recoveryCommand: null,
310
+ onKillResumeDelay: null,
310
311
  });
311
312
  });
312
313
 
@@ -332,6 +333,7 @@ describe('buildDockerRuntimeMetadata', () => {
332
333
  resourceLimits: null,
333
334
  onKillResume: null,
334
335
  recoveryCommand: null,
336
+ onKillResumeDelay: null,
335
337
  }
336
338
  );
337
339
  });
@@ -113,6 +113,8 @@ function runWatcher({ policy, running, state, withLog = true }) {
113
113
  ' echo "$answer" > "$dir/current"',
114
114
  ' echo "$answer" ;;',
115
115
  ' *State.Error*) echo "" ;;',
116
+ // The cgroup sampler (issue #182): no cgroup to find here.
117
+ " *'{{.Id}}'*|*State.Pid*) echo 0 ;;",
116
118
  ' *) echo "inspect-state" >> "$dir/calls"; cat "$dir/state" ;;',
117
119
  ' esac ;;',
118
120
  ' logs) echo logs >> "$dir/calls"; echo work; exit 1 ;;',
@@ -490,10 +490,10 @@ describe('issue #176: resuming a killed session', () => {
490
490
  return { outcome, runner, watchers };
491
491
  }
492
492
 
493
- it('detects a kill by exit 137 or OOMKilled', () => {
493
+ it('detects a kill by exit 137, or OOMKilled without an exit code', () => {
494
494
  expect(isKilledExit(137, false)).toBe(true);
495
495
  expect(isKilledExit('137', 'false')).toBe(true);
496
- expect(isKilledExit('0', 'true')).toBe(true);
496
+ expect(isKilledExit('-1', 'true')).toBe(true);
497
497
  expect(isKilledExit('1', 'false')).toBe(false);
498
498
  });
499
499
 
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Regression tests for issue #178:
3
+ *
4
+ * `--on-kill-resume N` resumed an execution whenever Docker reported
5
+ * `State.OOMKilled=true`, whatever the exit code. Docker sets that flag when
6
+ * *any* process in the container's cgroup is OOM-killed (a compiler, a test
7
+ * runner, a child `node`) and keeps it set until the container is started
8
+ * again. A main process that survived that and then exited 0 (success) or 1
9
+ * (a deliberate failure) on its own was resumed as if it had been killed.
10
+ *
11
+ * A container counts as killed only when the main process was: exit 137,
12
+ * or `OOMKilled=true` with no usable exit code (the watcher's `-1`).
13
+ */
14
+
15
+ const { describe, it, expect, afterEach } = require('bun:test');
16
+ const { spawnSync } = require('child_process');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+
21
+ const {
22
+ ExecutionStore,
23
+ ExecutionRecord,
24
+ ExecutionStatus,
25
+ } = require('../src/lib/execution-store');
26
+ const {
27
+ buildDetachedDockerCompletionScript,
28
+ DOCKER_CONTAINER_CLEANUP_POLICY,
29
+ } = require('../src/lib/docker-cleanup');
30
+ const { SHELL_VARS } = require('../src/lib/docker-post-mortem');
31
+ const {
32
+ buildRecoverySnippet,
33
+ isKilledExit,
34
+ recoverKilledExecution,
35
+ } = require('../src/lib/execution-recovery');
36
+
37
+ const tempDirs = [];
38
+
39
+ function makeTempDir(prefix) {
40
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
41
+ tempDirs.push(dir);
42
+ return dir;
43
+ }
44
+
45
+ afterEach(() => {
46
+ while (tempDirs.length > 0) {
47
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ const needsPosixShell = () => {
52
+ if (process.platform !== 'win32') {
53
+ return false;
54
+ }
55
+ console.log(' Skipping: the test needs a POSIX /bin/sh');
56
+ return true;
57
+ };
58
+
59
+ /** `(exitCode, oomKilled)` pairs from the issue and whether they are kills. */
60
+ const CASES = [
61
+ ['137', 'false', true],
62
+ ['137', 'true', true],
63
+ ['0', 'true', false],
64
+ ['1', 'true', false],
65
+ ['1', 'false', false],
66
+ ['0', 'false', false],
67
+ ['-1', 'true', true],
68
+ ['-1', 'false', false],
69
+ ];
70
+
71
+ describe('issue #178: sticky OOMKilled is not a kill of the main process', () => {
72
+ it('isKilledExit only trusts OOMKilled without an exit code', () => {
73
+ for (const [exitCode, oomKilled, killed] of CASES) {
74
+ expect([exitCode, oomKilled, isKilledExit(exitCode, oomKilled)]).toEqual([
75
+ exitCode,
76
+ oomKilled,
77
+ killed,
78
+ ]);
79
+ }
80
+ expect(isKilledExit(137, false)).toBe(true);
81
+ expect(isKilledExit(0, true)).toBe(false);
82
+ expect(isKilledExit(1, true)).toBe(false);
83
+ expect(isKilledExit(null, true)).toBe(true);
84
+ expect(isKilledExit(undefined, 'true')).toBe(true);
85
+ expect(isKilledExit('', 'true')).toBe(true);
86
+ expect(isKilledExit(null, false)).toBe(false);
87
+ });
88
+
89
+ it('the watcher shell condition agrees with isKilledExit', () => {
90
+ if (needsPosixShell()) {
91
+ return;
92
+ }
93
+ // Only the condition: the command after it would start a real recovery.
94
+ const snippet = buildRecoverySnippet('uuid');
95
+ const condition = snippet.slice(0, snippet.indexOf('; }; } && ') + 6);
96
+ expect(condition.endsWith('; }; }')).toBe(true);
97
+ for (const [exitCode, oomKilled, killed] of [
98
+ ...CASES,
99
+ ['', 'true', true],
100
+ ['unknown', 'true', true],
101
+ ]) {
102
+ const result = spawnSync(
103
+ '/bin/sh',
104
+ [
105
+ '-c',
106
+ `${SHELL_VARS.exit}='${exitCode}'; ${SHELL_VARS.oom}='${oomKilled}'; ` +
107
+ `if ${condition}; then echo recover; else echo keep; fi`,
108
+ ],
109
+ { encoding: 'utf8' }
110
+ );
111
+ expect([exitCode, oomKilled, result.stdout.trim()]).toEqual([
112
+ exitCode,
113
+ oomKilled,
114
+ killed ? 'recover' : 'keep',
115
+ ]);
116
+ expect(result.stderr).toBe('');
117
+ }
118
+ });
119
+
120
+ function makeRecord(dir) {
121
+ const appFolder = path.join(dir, 'app');
122
+ const logPath = path.join(dir, 'run.log');
123
+ fs.writeFileSync(logPath, 'main output\n');
124
+ const store = new ExecutionStore({ appFolder, useLinks: false });
125
+ const record = new ExecutionRecord({
126
+ status: ExecutionStatus.EXECUTING,
127
+ command: 'solve',
128
+ logPath,
129
+ options: {
130
+ isolated: 'docker',
131
+ isolationMode: 'detached',
132
+ sessionName: 'box',
133
+ onKillResume: 1,
134
+ },
135
+ });
136
+ store.save(record);
137
+ return { appFolder, logPath, store, record };
138
+ }
139
+
140
+ it('recoverKilledExecution does not resume an exit 0/1 with OOMKilled', () => {
141
+ const { store, record } = makeRecord(makeTempDir('recovery-178-'));
142
+ for (const exitCode of ['0', '1']) {
143
+ const calls = [];
144
+ const outcome = recoverKilledExecution({
145
+ store,
146
+ executionId: record.uuid,
147
+ exitCode,
148
+ oomKilled: 'true',
149
+ runner: (...args) => {
150
+ calls.push(args);
151
+ return { success: true, stdout: '', stderr: '', status: 0 };
152
+ },
153
+ startWatcher: () => calls.push('watcher'),
154
+ });
155
+ expect(outcome).toEqual({ recovered: false, reason: 'not-killed' });
156
+ expect(calls).toEqual([]);
157
+ }
158
+ expect(store.get(record.uuid).options.recoveryAttempts).toBeUndefined();
159
+ });
160
+
161
+ /** Run the real watcher against a fake docker that reports `state`. */
162
+ function runWatcher(state) {
163
+ const dir = makeTempDir('watcher-178-');
164
+ const binDir = path.join(dir, 'bin');
165
+ fs.mkdirSync(binDir);
166
+ const dockerPath = path.join(binDir, 'docker');
167
+ fs.writeFileSync(
168
+ dockerPath,
169
+ [
170
+ '#!/bin/sh',
171
+ `dir='${dir}'`,
172
+ 'echo "$*" >> "$dir/calls"',
173
+ 'case "$1" in',
174
+ ' inspect)',
175
+ ' case "$3" in',
176
+ ' *State.Running*) echo false ;;',
177
+ ' *State.Error*) echo "" ;;',
178
+ ' *HostConfig*) echo "{}" ;;',
179
+ ` *) echo '${state}' ;;`,
180
+ ' esac ;;',
181
+ ' logs) echo work ;;',
182
+ 'esac',
183
+ '',
184
+ ].join('\n')
185
+ );
186
+ fs.chmodSync(dockerPath, 0o755);
187
+ const { appFolder, logPath, record } = makeRecord(dir);
188
+ const script = buildDetachedDockerCompletionScript(
189
+ 'box',
190
+ DOCKER_CONTAINER_CLEANUP_POLICY.KEEP,
191
+ logPath,
192
+ record.uuid,
193
+ { recoverOnKill: true }
194
+ );
195
+ spawnSync('/bin/sh', ['-c', script], {
196
+ encoding: 'utf8',
197
+ env: {
198
+ ...process.env,
199
+ PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
200
+ START_APP_FOLDER: appFolder,
201
+ START_DISABLE_TRACKING: '',
202
+ },
203
+ timeout: 20000,
204
+ });
205
+ return {
206
+ calls: fs.readFileSync(path.join(dir, 'calls'), 'utf8'),
207
+ log: fs.readFileSync(logPath, 'utf8'),
208
+ record: new ExecutionStore({ appFolder, useLinks: false }).get(
209
+ record.uuid
210
+ ),
211
+ };
212
+ }
213
+
214
+ const TIMES = '2026-10-03T12:00:00.000000000Z 2026-10-03T16:55:20.000000000Z';
215
+
216
+ for (const exitCode of [0, 1]) {
217
+ it(`the watcher finalizes exit ${exitCode} with OOMKilled=true without resuming`, () => {
218
+ if (needsPosixShell()) {
219
+ return;
220
+ }
221
+ const { calls, log, record } = runWatcher(`${exitCode} true ${TIMES}`);
222
+ expect(calls).not.toContain('start box');
223
+ expect(log).not.toContain('[Recovery');
224
+ // Still reported as an OOM event in the post-mortem and the record.
225
+ expect(log).toContain('OOMKilled: true');
226
+ expect(record.status).toBe(ExecutionStatus.EXECUTED);
227
+ expect(record.exitCode).toBe(exitCode);
228
+ expect(record.oomKilled).toBe(true);
229
+ expect(record.options.recoveryAttempts).toBeUndefined();
230
+ }, 30000);
231
+ }
232
+ });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Regression tests for issue #180:
3
+ *
4
+ * `$ --status` reported `exitReason memory-exhaustion (cgroup-oom-killer)`
5
+ * and `memoryExhausted true` for an ordinary exit 0/1 whenever Docker's
6
+ * `State.OOMKilled` flag was set. Since moby/moby#43564 that flag is
7
+ * container-wide and sticky: it turns on as soon as *any* process in the
8
+ * container is OOM-killed (a `rustc` child under `cargo test`) and stays on
9
+ * until the container starts again. A command that survived that and later
10
+ * exited 1 on its own was reported as having run out of memory.
11
+ *
12
+ * The flag only explains the command's exit when that exit is 137
13
+ * (`128 + SIGKILL`) or unknown: the rule `isKilledExit()` applies since #178.
14
+ */
15
+
16
+ const { describe, it, expect, afterEach } = require('bun:test');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+
21
+ const {
22
+ CGROUP_OOM_EXIT_REASON,
23
+ isOomKillOfCommand,
24
+ resolveExitReason,
25
+ resolveMemoryExhaustion,
26
+ } = require('../src/lib/exit-reason');
27
+ const {
28
+ attachExitReason,
29
+ attachMemoryExhaustion,
30
+ } = require('../src/lib/status-formatter');
31
+ const { finalizeDetachedExecution } = require('../src/lib/detached-finalize');
32
+ const { buildAttachedDockerKeptMessage } = require('../src/lib/docker-cleanup');
33
+ const {
34
+ ExecutionStore,
35
+ ExecutionRecord,
36
+ ExecutionStatus,
37
+ } = require('../src/lib/execution-store');
38
+
39
+ const OOM_REASON = 'Docker reported State.OOMKilled=true';
40
+
41
+ const tempDirs = [];
42
+
43
+ function makeTempDir(prefix) {
44
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
45
+ tempDirs.push(dir);
46
+ return dir;
47
+ }
48
+
49
+ afterEach(() => {
50
+ while (tempDirs.length > 0) {
51
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ function finishedRecord(fields) {
56
+ const record = new ExecutionRecord({
57
+ status: ExecutionStatus.EXECUTED,
58
+ command: 'cargo test',
59
+ logPath: null,
60
+ });
61
+ return Object.assign(record, fields);
62
+ }
63
+
64
+ describe('issue #180: the sticky OOMKilled flag is not an exit reason', () => {
65
+ // The reproduction table from the issue, plus the watcher's unknown exit.
66
+ const CASES = [
67
+ { exitCode: 0, oomKilled: true, reason: null, memory: false },
68
+ { exitCode: 1, oomKilled: true, reason: null, memory: false },
69
+ { exitCode: 1, oomKilled: false, reason: null, memory: false },
70
+ {
71
+ exitCode: 137,
72
+ oomKilled: true,
73
+ reason: CGROUP_OOM_EXIT_REASON,
74
+ memory: true,
75
+ },
76
+ {
77
+ exitCode: 137,
78
+ oomKilled: false,
79
+ reason: 'signal (SIGKILL)',
80
+ memory: false,
81
+ },
82
+ {
83
+ exitCode: -1,
84
+ oomKilled: true,
85
+ reason: CGROUP_OOM_EXIT_REASON,
86
+ memory: true,
87
+ },
88
+ {
89
+ exitCode: 139,
90
+ oomKilled: true,
91
+ reason: 'signal (SIGSEGV)',
92
+ memory: false,
93
+ },
94
+ ];
95
+
96
+ for (const { exitCode, oomKilled, reason, memory } of CASES) {
97
+ it(`exit ${exitCode} with oomKilled=${oomKilled}`, () => {
98
+ expect(resolveExitReason({ exitCode, oomKilled })).toBe(reason);
99
+ expect(resolveMemoryExhaustion({ exitCode, oomKilled })).toEqual(
100
+ memory
101
+ ? { memoryExhausted: true, memoryExhaustedReason: OOM_REASON }
102
+ : null
103
+ );
104
+ });
105
+ }
106
+
107
+ it('blames the flag on the command only for SIGKILL or an unknown exit', () => {
108
+ expect(isOomKillOfCommand({ exitCode: 137, oomKilled: true })).toBe(true);
109
+ expect(isOomKillOfCommand({ exitCode: null, oomKilled: true })).toBe(true);
110
+ expect(isOomKillOfCommand({ exitCode: -1, oomKilled: true })).toBe(true);
111
+ expect(isOomKillOfCommand({ exitCode: 0, oomKilled: true })).toBe(false);
112
+ expect(isOomKillOfCommand({ exitCode: 1, oomKilled: true })).toBe(false);
113
+ expect(isOomKillOfCommand({ exitCode: 137, oomKilled: false })).toBe(false);
114
+ expect(isOomKillOfCommand(null)).toBe(false);
115
+ });
116
+
117
+ it('still trusts a memory marker the command printed itself', () => {
118
+ const logTail =
119
+ 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory';
120
+ expect(resolveExitReason({ exitCode: 1, oomKilled: true, logTail })).toBe(
121
+ 'memory-exhaustion (v8-heap-limit)'
122
+ );
123
+ expect(
124
+ resolveMemoryExhaustion({ exitCode: 1, oomKilled: true, logTail })
125
+ .memoryExhausted
126
+ ).toBe(true);
127
+ });
128
+
129
+ it('reports the incident record without a memory verdict', () => {
130
+ const record = finishedRecord({ exitCode: 1, oomKilled: true });
131
+ const enriched = attachMemoryExhaustion(
132
+ attachExitReason(record, 'Failed to authenticate: OAuth session expired'),
133
+ 'Failed to authenticate: OAuth session expired'
134
+ );
135
+ expect(enriched.oomKilled).toBe(true);
136
+ expect(enriched.exitReason).toBeUndefined();
137
+ expect(enriched.memoryExhausted).toBeUndefined();
138
+ });
139
+
140
+ it('drops a stale cgroup reason stored before the fix', () => {
141
+ const record = finishedRecord({
142
+ exitCode: 1,
143
+ oomKilled: true,
144
+ exitReason: CGROUP_OOM_EXIT_REASON,
145
+ });
146
+ const enriched = attachExitReason(record, null);
147
+ expect(enriched.exitReason).toBeUndefined();
148
+ expect(record.exitReason).toBe(CGROUP_OOM_EXIT_REASON);
149
+ });
150
+
151
+ it('the finalizer does not record the flag as the reason for exit 1', () => {
152
+ const appFolder = makeTempDir('finalize-180-');
153
+ const store = new ExecutionStore({ appFolder, useLinks: false });
154
+ const record = new ExecutionRecord({
155
+ status: ExecutionStatus.EXECUTING,
156
+ command: 'cargo test',
157
+ logPath: null,
158
+ options: { isolated: 'docker', isolationMode: 'detached' },
159
+ });
160
+ store.save(record);
161
+
162
+ finalizeDetachedExecution({
163
+ store,
164
+ executionId: record.uuid,
165
+ exitCode: '1',
166
+ oomKilled: 'true',
167
+ startedAt: '2026-10-01T19:00:00Z',
168
+ finishedAt: '2026-10-01T19:41:00Z',
169
+ running: 'false',
170
+ });
171
+
172
+ const stored = store.get(record.uuid);
173
+ expect(stored.exitCode).toBe(1);
174
+ expect(stored.oomKilled).toBe(true);
175
+ expect(stored.exitReason).toBeUndefined();
176
+ });
177
+
178
+ it('the attached kept message names a child OOM kill, not the command', () => {
179
+ const child = buildAttachedDockerKeptMessage({
180
+ containerName: 'c',
181
+ exitCode: 1,
182
+ oomKilled: true,
183
+ logPath: null,
184
+ });
185
+ expect(child).toContain('a process in it was OOM-killed');
186
+ expect(child).not.toContain('Memory exhaustion detected');
187
+
188
+ const main = buildAttachedDockerKeptMessage({
189
+ containerName: 'c',
190
+ exitCode: 137,
191
+ oomKilled: true,
192
+ logPath: null,
193
+ });
194
+ expect(main).toContain('Docker reports it was OOM-killed.');
195
+ });
196
+ });