start-command 0.35.0 → 0.35.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # start-command
2
2
 
3
+ ## 0.35.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 27737e3: Do not resume `--on-kill-resume` executions whose main process exited 0–127 on its own while Docker's sticky `OOMKilled` flag was set. Docker sets `OOMKilled` when _any_ process in the container was OOM-killed (a compiler, a test runner, a child `node`) and keeps it until the next start, so a run that survived that and later exited 0 or 1 was resumed as if it had been killed. A run now counts as killed only on exit 137, or on `OOMKilled` without a usable exit code; `oomKilled: true` is still reported in `--status` and the post-mortem.
8
+
3
9
  ## 0.35.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.35.0",
3
+ "version": "0.35.1",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "homepage": "https://github.com/link-foundation/start#readme",
49
49
  "devDependencies": {
50
- "@changesets/cli": "^2.31.0",
50
+ "@changesets/cli": "^3.0.3",
51
51
  "@eslint/js": "^10.0.1",
52
52
  "eslint": "^10.3.0",
53
53
  "eslint-config-prettier": "^10.1.8",
@@ -320,11 +320,11 @@ function buildSuccessfulNonOomCondition() {
320
320
  * container that is somehow still running afterwards is never removed, gets no
321
321
  * `Exit Code:` footer and is never finalized — its record stays `executing`.
322
322
  *
323
- * With `recoverOnKill` (issue #176), a killed main process (exit 137 or
324
- * `OOMKilled`) is first handed to the recovery entry point. When it resumes
325
- * the container, this watcher stops here — a new one follows the resumed run —
326
- * and cleanup, footer and finalization are left to whichever watcher sees the
327
- * last run end.
323
+ * With `recoverOnKill` (issue #176), a killed main process (exit 137, or
324
+ * `OOMKilled` without a usable exit code, issue #178) is first handed to the
325
+ * recovery entry point. When it resumes the container, this watcher stops
326
+ * here — a new one follows the resumed run — and cleanup, footer and
327
+ * finalization are left to whichever watcher sees the last run end.
328
328
  *
329
329
  * @param {string} containerName - Docker container name
330
330
  * @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
@@ -2,11 +2,12 @@
2
2
  * Launch-time recovery for killed detached docker sessions (issue #176).
3
3
  *
4
4
  * `$ --isolated docker --detached --on-kill-resume 3 --recovery-command B -- A`
5
- * runs `A`; when the main process is killed (exit 137 / `OOMKilled`), the
6
- * detached completion watcher hands the inspected facts to this module, which
7
- * restarts the *same* container so `B` continues on the same filesystem, with
8
- * the same resource limits (they live in the container's HostConfig), under
9
- * the same execution UUID and appending to the same log file.
5
+ * runs `A`; when the main process is killed (exit 137, or `OOMKilled` with no
6
+ * exit status of its own), the detached completion watcher hands the inspected
7
+ * facts to this module, which restarts the *same* container so `B` continues
8
+ * on the same filesystem, with the same resource limits (they live in the
9
+ * container's HostConfig), under the same execution UUID and appending to the
10
+ * same log file.
10
11
  *
11
12
  * How `B` replaces `A` inside the same container: a container launched with a
12
13
  * recovery command runs a tiny selector as its entrypoint command. The
@@ -81,28 +82,41 @@ function buildRecoverySelectorArgs(mainArgs, params) {
81
82
  }
82
83
 
83
84
  /**
85
+ * Whether the main process was killed.
86
+ *
87
+ * Exit 137 (SIGKILL, the OOM killer's signal) always counts. `OOMKilled` alone
88
+ * does not: Docker sets it when *any* process in the container's cgroup was
89
+ * OOM-killed (a compiler, a test runner, a child `node`), and it stays set
90
+ * until the container is started again. A main process that survived that and
91
+ * then exited 0-127 on its own ran to completion, so the flag only counts when
92
+ * there is no usable exit status (the watcher's `-1`, or nothing at all)
93
+ * (issue #178).
94
+ *
84
95
  * @param {number|string|null} exitCode - `.State.ExitCode`
85
96
  * @param {boolean|string|null} oomKilled - `.State.OOMKilled`
86
97
  * @returns {boolean} Whether the main process was killed
87
98
  */
88
99
  function isKilledExit(exitCode, oomKilled) {
89
- return (
90
- describeExitCode(exitCode).code === KILLED_EXIT_CODE ||
91
- oomKilled === true ||
92
- oomKilled === 'true'
93
- );
100
+ const { code } = describeExitCode(exitCode);
101
+ if (code === KILLED_EXIT_CODE) {
102
+ return true;
103
+ }
104
+ const oom = oomKilled === true || oomKilled === 'true';
105
+ return oom && (code === null || code < 0);
94
106
  }
95
107
 
96
108
  /**
97
109
  * Shell condition run by the completion watcher: true when the container was
98
- * killed and the recovery entry point resumed it.
110
+ * killed and the recovery entry point resumed it. Mirrors `isKilledExit()`:
111
+ * `OOMKilled` only counts without a non-negative exit code (issue #178).
99
112
  * @param {string} executionId - Execution UUID
100
113
  * @returns {string} POSIX shell condition
101
114
  */
102
115
  function buildRecoverySnippet(executionId) {
103
116
  const v = SHELL_VARS;
104
117
  return (
105
- `{ [ "$${v.exit}" = ${KILLED_EXIT_CODE} ] || [ "$${v.oom}" = true ]; } && ` +
118
+ `{ [ "$${v.exit}" = ${KILLED_EXIT_CODE} ] || ` +
119
+ `{ [ "$${v.oom}" = true ] && ! [ "$${v.exit}" -ge 0 ] 2>/dev/null; }; } && ` +
106
120
  `${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
107
121
  `${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
108
122
  `"$${v.started}" "$${v.finished}" "$${v.error}" >/dev/null 2>&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
+ });