start-command 0.34.1 → 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 +12 -0
- package/package.json +3 -3
- package/src/lib/args-parser.js +10 -0
- package/src/lib/docker-cleanup.js +28 -5
- package/src/lib/docker-recovery-options.js +84 -0
- package/src/lib/docker-resource-limits.js +211 -0
- package/src/lib/docker-runtime-args.js +141 -0
- package/src/lib/execution-control.js +39 -0
- package/src/lib/execution-recovery.js +364 -0
- package/src/lib/execution-resume.js +68 -4
- package/src/lib/isolation.js +20 -92
- package/src/lib/usage.js +3 -0
- package/test/docker-runtime-options.js +6 -0
- package/test/regression-176.js +897 -0
- package/test/regression-178.js +232 -0
|
@@ -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
|
+
});
|