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 +6 -0
- package/package.json +2 -2
- package/src/lib/docker-cleanup.js +5 -5
- package/src/lib/execution-recovery.js +26 -12
- package/test/regression-176.js +2 -2
- package/test/regression-178.js +232 -0
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.
|
|
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": "^
|
|
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
|
|
325
|
-
* the container, this watcher stops
|
|
326
|
-
*
|
|
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
|
|
6
|
-
* detached completion watcher hands the inspected
|
|
7
|
-
* restarts the *same* container so `B` continues
|
|
8
|
-
* the same resource limits (they live in the
|
|
9
|
-
* the same execution UUID and appending to the
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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} ] ||
|
|
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`
|
package/test/regression-176.js
CHANGED
|
@@ -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('
|
|
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
|
+
});
|