start-command 0.35.1 → 0.35.3
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 +16 -0
- package/package.json +1 -1
- package/src/lib/args-parser.js +1 -0
- package/src/lib/cgroup-memory.js +288 -0
- package/src/lib/detached-finalize.js +17 -2
- package/src/lib/docker-cleanup.js +27 -8
- package/src/lib/docker-recovery-options.js +31 -3
- package/src/lib/docker-runtime-args.js +10 -4
- package/src/lib/execution-control.js +36 -0
- package/src/lib/execution-recovery.js +90 -10
- package/src/lib/execution-store.js +9 -0
- package/src/lib/exit-reason.js +67 -6
- package/src/lib/recovery-delay.js +151 -0
- package/src/lib/status-formatter.js +16 -0
- package/src/lib/usage.js +2 -0
- package/test/docker-runtime-options.js +2 -0
- package/test/regression-174.js +2 -0
- package/test/regression-180.js +196 -0
- package/test/regression-181.js +338 -0
- package/test/regression-182.js +439 -0
- package/test/regression-185.js +111 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #182:
|
|
3
|
+
*
|
|
4
|
+
* Docker's `State.OOMKilled` is container-wide and sticky (moby/moby#43564):
|
|
5
|
+
* it cannot say how many processes the OOM killer took, whether the
|
|
6
|
+
* container hit its own `--memory` limit or the whole host ran out, or how
|
|
7
|
+
* close the run came to its limit. The kernel provides raw counters per cgroup
|
|
8
|
+
* (`memory.events` `oom`/`oom_kill`, `memory.peak`, `memory.max`), but the
|
|
9
|
+
* cgroup is gone once the container stops.
|
|
10
|
+
*
|
|
11
|
+
* The detached watcher now samples the container's cgroup v2 counters while
|
|
12
|
+
* it runs, writes a `Memory:` line into the post-mortem, stores them as
|
|
13
|
+
* `cgroupMemory` in the execution record (shown by `--status`), and hands
|
|
14
|
+
* them to the kill recovery, which notes them in `recoveryHistory`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { describe, it, expect, afterEach } = require('bun:test');
|
|
18
|
+
const { spawnSync } = require('child_process');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
OOM_SCOPE,
|
|
25
|
+
buildCgroupMemoryLogSnippet,
|
|
26
|
+
buildCgroupSamplerStartSnippet,
|
|
27
|
+
buildCgroupSamplerStopSnippet,
|
|
28
|
+
describeCgroupOomScope,
|
|
29
|
+
formatCgroupMemory,
|
|
30
|
+
formatCgroupMemoryLogLine,
|
|
31
|
+
parseCgroupMemorySample,
|
|
32
|
+
} = require('../src/lib/cgroup-memory');
|
|
33
|
+
const {
|
|
34
|
+
buildDetachedDockerCompletionScript,
|
|
35
|
+
DOCKER_CONTAINER_CLEANUP_POLICY,
|
|
36
|
+
} = require('../src/lib/docker-cleanup');
|
|
37
|
+
const {
|
|
38
|
+
buildDetachedFinalizeSnippet,
|
|
39
|
+
finalizeDetachedExecution,
|
|
40
|
+
} = require('../src/lib/detached-finalize');
|
|
41
|
+
const {
|
|
42
|
+
buildRecoverySnippet,
|
|
43
|
+
recoverKilledExecution,
|
|
44
|
+
} = require('../src/lib/execution-recovery');
|
|
45
|
+
const {
|
|
46
|
+
ExecutionStore,
|
|
47
|
+
ExecutionRecord,
|
|
48
|
+
ExecutionStatus,
|
|
49
|
+
} = require('../src/lib/execution-store');
|
|
50
|
+
const { formatRecordAsText } = require('../src/lib/status-formatter');
|
|
51
|
+
|
|
52
|
+
const tempDirs = [];
|
|
53
|
+
|
|
54
|
+
function makeTempDir(prefix) {
|
|
55
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
56
|
+
tempDirs.push(dir);
|
|
57
|
+
return dir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
while (tempDirs.length > 0) {
|
|
62
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// The watcher body is a POSIX `sh` script; Windows has no `/bin/sh`.
|
|
67
|
+
const needsPosixShell = () => {
|
|
68
|
+
if (process.platform !== 'win32') {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
console.log(' Skipping: the watcher needs a POSIX /bin/sh');
|
|
72
|
+
return true;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const CONTAINER_ID = `182c${'0'.repeat(60)}`;
|
|
76
|
+
const OOM_SAMPLE = '268435456 268300000 0 3';
|
|
77
|
+
const OOM_COUNTERS = {
|
|
78
|
+
limitBytes: 268435456,
|
|
79
|
+
peakBytes: 268300000,
|
|
80
|
+
oomEvents: 0,
|
|
81
|
+
oomKills: 3,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A fake cgroup v2 tree, `/proc` and `docker`. `docker logs` runs `during`
|
|
86
|
+
* (a shell snippet) after 1.5s, so the sampler has taken a sample by then.
|
|
87
|
+
*/
|
|
88
|
+
function makeFakeHost({ events, max = '268435456', peak = '268300000' }) {
|
|
89
|
+
const dir = makeTempDir('cgroup-182-');
|
|
90
|
+
const cgroupRoot = path.join(dir, 'cgroup');
|
|
91
|
+
const scope = path.join(
|
|
92
|
+
cgroupRoot,
|
|
93
|
+
'system.slice',
|
|
94
|
+
`docker-${CONTAINER_ID}.scope`
|
|
95
|
+
);
|
|
96
|
+
fs.mkdirSync(scope, { recursive: true });
|
|
97
|
+
fs.writeFileSync(path.join(scope, 'memory.events'), events);
|
|
98
|
+
fs.writeFileSync(path.join(scope, 'memory.max'), `${max}\n`);
|
|
99
|
+
if (peak !== null) {
|
|
100
|
+
fs.writeFileSync(path.join(scope, 'memory.peak'), `${peak}\n`);
|
|
101
|
+
}
|
|
102
|
+
const procRoot = path.join(dir, 'proc');
|
|
103
|
+
fs.mkdirSync(path.join(procRoot, '4242'), { recursive: true });
|
|
104
|
+
fs.writeFileSync(
|
|
105
|
+
path.join(procRoot, '4242', 'cgroup'),
|
|
106
|
+
`0::/system.slice/docker-${CONTAINER_ID}.scope\n`
|
|
107
|
+
);
|
|
108
|
+
const binDir = path.join(dir, 'bin');
|
|
109
|
+
fs.mkdirSync(binDir);
|
|
110
|
+
return {
|
|
111
|
+
dir,
|
|
112
|
+
scope,
|
|
113
|
+
binDir,
|
|
114
|
+
env: {
|
|
115
|
+
...process.env,
|
|
116
|
+
PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
|
|
117
|
+
START_COMMAND_CGROUP_ROOT: cgroupRoot,
|
|
118
|
+
START_COMMAND_PROC_ROOT: procRoot,
|
|
119
|
+
TMPDIR: dir,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function writeFakeDocker(host, { state, during }) {
|
|
125
|
+
fs.writeFileSync(path.join(host.dir, 'during'), `${during}\n`);
|
|
126
|
+
const dockerPath = path.join(host.binDir, 'docker');
|
|
127
|
+
fs.writeFileSync(
|
|
128
|
+
dockerPath,
|
|
129
|
+
[
|
|
130
|
+
'#!/bin/sh',
|
|
131
|
+
`dir='${host.dir}'`,
|
|
132
|
+
'case "$1" in',
|
|
133
|
+
' inspect)',
|
|
134
|
+
' case "$3" in',
|
|
135
|
+
` *'{{.Id}}'*) echo ${CONTAINER_ID} ;;`,
|
|
136
|
+
' *State.Pid*) echo 4242 ;;',
|
|
137
|
+
' *State.Running*) echo false ;;',
|
|
138
|
+
' *State.Error*) echo "" ;;',
|
|
139
|
+
` *) echo '${state}' ;;`,
|
|
140
|
+
' esac ;;',
|
|
141
|
+
' logs) echo work; sleep 1.5; sh "$dir/during" ;;',
|
|
142
|
+
' rm) echo removed ;;',
|
|
143
|
+
'esac',
|
|
144
|
+
'',
|
|
145
|
+
].join('\n'),
|
|
146
|
+
'utf8'
|
|
147
|
+
);
|
|
148
|
+
fs.chmodSync(dockerPath, 0o755);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const KILLED_STATE =
|
|
152
|
+
'137 true 2026-10-01T10:00:00.000000000Z 2026-10-01T10:05:00.000000000Z';
|
|
153
|
+
|
|
154
|
+
function runWatcher({ events, during, policy, state = KILLED_STATE, max }) {
|
|
155
|
+
const host = makeFakeHost({ events, max });
|
|
156
|
+
writeFakeDocker(host, { state, during: during(host) });
|
|
157
|
+
const appFolder = path.join(host.dir, 'app');
|
|
158
|
+
const store = new ExecutionStore({ appFolder, useLinks: false });
|
|
159
|
+
const logPath = path.join(host.dir, 'run.log');
|
|
160
|
+
const record = new ExecutionRecord({
|
|
161
|
+
status: ExecutionStatus.EXECUTING,
|
|
162
|
+
command: 'cargo test',
|
|
163
|
+
logPath,
|
|
164
|
+
options: {
|
|
165
|
+
isolated: 'docker',
|
|
166
|
+
isolationMode: 'detached',
|
|
167
|
+
sessionName: 'box',
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
store.save(record);
|
|
171
|
+
const result = spawnSync(
|
|
172
|
+
'/bin/sh',
|
|
173
|
+
[
|
|
174
|
+
'-c',
|
|
175
|
+
buildDetachedDockerCompletionScript('box', policy, logPath, record.uuid),
|
|
176
|
+
],
|
|
177
|
+
{
|
|
178
|
+
encoding: 'utf8',
|
|
179
|
+
env: {
|
|
180
|
+
...host.env,
|
|
181
|
+
START_APP_FOLDER: appFolder,
|
|
182
|
+
START_DISABLE_TRACKING: '',
|
|
183
|
+
},
|
|
184
|
+
timeout: 20000,
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
return {
|
|
188
|
+
status: result.status,
|
|
189
|
+
log: fs.readFileSync(logPath, 'utf8'),
|
|
190
|
+
record: new ExecutionStore({ appFolder, useLinks: false }).get(record.uuid),
|
|
191
|
+
leftovers: fs
|
|
192
|
+
.readdirSync(host.dir)
|
|
193
|
+
.filter((name) => name.startsWith('start-command-cgroup')),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
describe('issue #182: reading cgroup v2 memory counters', () => {
|
|
198
|
+
it('parses the sample the watcher writes', () => {
|
|
199
|
+
expect(parseCgroupMemorySample(OOM_SAMPLE)).toEqual(OOM_COUNTERS);
|
|
200
|
+
expect(parseCgroupMemorySample('max - 1 1\n')).toEqual({
|
|
201
|
+
limitBytes: null,
|
|
202
|
+
peakBytes: null,
|
|
203
|
+
oomEvents: 1,
|
|
204
|
+
oomKills: 1,
|
|
205
|
+
});
|
|
206
|
+
for (const empty of ['', null, undefined, '- - - -', '1 2 3']) {
|
|
207
|
+
expect(parseCgroupMemorySample(empty)).toBeNull();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('leaves OOM scope unknown with only raw counters', () => {
|
|
212
|
+
expect(describeCgroupOomScope(OOM_COUNTERS)).toBe(OOM_SCOPE.UNKNOWN);
|
|
213
|
+
expect(describeCgroupOomScope({ oomEvents: 2, oomKills: 2 })).toBe(
|
|
214
|
+
OOM_SCOPE.UNKNOWN
|
|
215
|
+
);
|
|
216
|
+
expect(describeCgroupOomScope({ oomEvents: 4, oomKills: 0 })).toBeNull();
|
|
217
|
+
expect(describeCgroupOomScope(null)).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('formats the counters for --status and for the log', () => {
|
|
221
|
+
expect(formatCgroupMemory(OOM_COUNTERS)).toBe(
|
|
222
|
+
'peak 255.9 MiB of 256.0 MiB limit, oom 0, oom_kill 3 ' +
|
|
223
|
+
'(OOM kill scope unknown)'
|
|
224
|
+
);
|
|
225
|
+
expect(
|
|
226
|
+
formatCgroupMemory({
|
|
227
|
+
limitBytes: null,
|
|
228
|
+
peakBytes: null,
|
|
229
|
+
oomEvents: 1,
|
|
230
|
+
oomKills: 1,
|
|
231
|
+
})
|
|
232
|
+
).toBe(
|
|
233
|
+
'peak unknown of no limit, oom 1, oom_kill 1 (OOM kill scope unknown)'
|
|
234
|
+
);
|
|
235
|
+
expect(formatCgroupMemoryLogLine('max 1024 0 0')).toBe(
|
|
236
|
+
'Memory: memory.max=max memory.peak=1024 oom=0 oom_kill=0'
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('issue #182: the watcher samples the cgroup while the container runs', () => {
|
|
242
|
+
it('starts the sampler first and stops it before reading the state', () => {
|
|
243
|
+
for (const logPath of ['/tmp/run.log', null]) {
|
|
244
|
+
const script = buildDetachedDockerCompletionScript(
|
|
245
|
+
'box',
|
|
246
|
+
DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
|
|
247
|
+
logPath,
|
|
248
|
+
'uuid-182',
|
|
249
|
+
{ recoverOnKill: true }
|
|
250
|
+
);
|
|
251
|
+
expect(script.startsWith(buildCgroupSamplerStartSnippet('box'))).toBe(
|
|
252
|
+
true
|
|
253
|
+
);
|
|
254
|
+
const stop = script.indexOf(buildCgroupSamplerStopSnippet());
|
|
255
|
+
expect(stop).toBeGreaterThan(script.indexOf('docker wait'));
|
|
256
|
+
expect(stop).toBeLessThan(script.indexOf('__start_command_state='));
|
|
257
|
+
expect(script).toContain(buildDetachedFinalizeSnippet('uuid-182'));
|
|
258
|
+
expect(script).toContain(buildRecoverySnippet('uuid-182'));
|
|
259
|
+
}
|
|
260
|
+
expect(buildDetachedFinalizeSnippet('u')).toContain(
|
|
261
|
+
'"$__start_command_cgroup" >/dev/null'
|
|
262
|
+
);
|
|
263
|
+
expect(buildRecoverySnippet('u')).toContain(
|
|
264
|
+
'"$__start_command_cgroup" >/dev/null'
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('keeps the last sample once the cgroup is gone', () => {
|
|
269
|
+
if (needsPosixShell()) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const host = makeFakeHost({ events: 'oom 0\noom_kill 3\n' });
|
|
273
|
+
writeFakeDocker(host, { state: KILLED_STATE, during: '' });
|
|
274
|
+
const logPath = path.join(host.dir, 'log');
|
|
275
|
+
const script = [
|
|
276
|
+
buildCgroupSamplerStartSnippet('box'),
|
|
277
|
+
'sleep 1.5',
|
|
278
|
+
`rm -rf '${host.scope}'`,
|
|
279
|
+
buildCgroupSamplerStopSnippet(),
|
|
280
|
+
`printf '%s' "$__start_command_cgroup" > '${host.dir}/sample'`,
|
|
281
|
+
buildCgroupMemoryLogSnippet(`'${logPath}'`),
|
|
282
|
+
].join('; ');
|
|
283
|
+
const result = spawnSync('/bin/sh', ['-c', script], {
|
|
284
|
+
env: host.env,
|
|
285
|
+
timeout: 20000,
|
|
286
|
+
});
|
|
287
|
+
expect(result.status).toBe(0);
|
|
288
|
+
expect(fs.readFileSync(path.join(host.dir, 'sample'), 'utf8')).toBe(
|
|
289
|
+
OOM_SAMPLE
|
|
290
|
+
);
|
|
291
|
+
expect(fs.readFileSync(logPath, 'utf8')).toBe(
|
|
292
|
+
`${formatCgroupMemoryLogLine(OOM_SAMPLE)}\n`
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('writes the counters into the post-mortem and the record', () => {
|
|
297
|
+
if (needsPosixShell()) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const run = runWatcher({
|
|
301
|
+
events: 'low 0\nhigh 0\nmax 9\noom 1\noom_kill 1\n',
|
|
302
|
+
// The cgroup outlives the main process for a moment: the final read
|
|
303
|
+
// still sees the kills of the last second.
|
|
304
|
+
during: (host) =>
|
|
305
|
+
`printf 'oom 1\\noom_kill 3\\n' > '${host.scope}/memory.events'`,
|
|
306
|
+
policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
|
|
307
|
+
});
|
|
308
|
+
expect(run.status).toBe(0);
|
|
309
|
+
expect(run.log).toContain('Exit Code: 137 (SIGKILL - 128+9)');
|
|
310
|
+
expect(run.log).toContain(
|
|
311
|
+
'Memory: memory.max=268435456 memory.peak=268300000 oom=1 oom_kill=3 ' +
|
|
312
|
+
'(OOM kill scope unknown)'
|
|
313
|
+
);
|
|
314
|
+
expect(run.record.status).toBe(ExecutionStatus.EXECUTED);
|
|
315
|
+
expect(run.record.cgroupMemory).toEqual({
|
|
316
|
+
limitBytes: 268435456,
|
|
317
|
+
peakBytes: 268300000,
|
|
318
|
+
oomEvents: 1,
|
|
319
|
+
oomKills: 3,
|
|
320
|
+
});
|
|
321
|
+
expect(run.leftovers).toEqual([]);
|
|
322
|
+
expect(formatRecordAsText(run.record)).toContain(
|
|
323
|
+
'Cgroup Memory: peak 255.9 MiB of 256.0 MiB limit, oom 1, oom_kill 3'
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('notes the counters of a removed container too', () => {
|
|
328
|
+
if (needsPosixShell()) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const run = runWatcher({
|
|
332
|
+
events: 'oom 0\noom_kill 0\n',
|
|
333
|
+
max: 'max',
|
|
334
|
+
during: (host) => `rm -rf '${host.scope}'`,
|
|
335
|
+
policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
|
|
336
|
+
state:
|
|
337
|
+
'0 false 2026-10-01T10:00:00.000000000Z 2026-10-01T10:05:00.000000000Z',
|
|
338
|
+
});
|
|
339
|
+
expect(run.log).toContain('Container removed: box');
|
|
340
|
+
expect(run.log).toContain(
|
|
341
|
+
'Memory: memory.max=max memory.peak=268300000 oom=0 oom_kill=0\n'
|
|
342
|
+
);
|
|
343
|
+
expect(run.record.cgroupMemory).toEqual({
|
|
344
|
+
limitBytes: null,
|
|
345
|
+
peakBytes: 268300000,
|
|
346
|
+
oomEvents: 0,
|
|
347
|
+
oomKills: 0,
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('records nothing without a cgroup v2 sample', () => {
|
|
352
|
+
const dir = makeTempDir('finalize-182-');
|
|
353
|
+
const store = new ExecutionStore({ appFolder: dir, useLinks: false });
|
|
354
|
+
const record = new ExecutionRecord({ command: 'x' });
|
|
355
|
+
store.save(record);
|
|
356
|
+
finalizeDetachedExecution({
|
|
357
|
+
store,
|
|
358
|
+
executionId: record.uuid,
|
|
359
|
+
exitCode: '0',
|
|
360
|
+
oomKilled: 'false',
|
|
361
|
+
finishedAt: '2026-10-01T10:05:00Z',
|
|
362
|
+
cgroupMemory: '',
|
|
363
|
+
});
|
|
364
|
+
const stored = store.get(record.uuid);
|
|
365
|
+
expect(stored.cgroupMemory).toBeUndefined();
|
|
366
|
+
expect(stored.toObject()).not.toHaveProperty('cgroupMemory');
|
|
367
|
+
expect(formatRecordAsText(stored)).not.toContain('Cgroup Memory');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('explains an unknown exit with the per-run oom_kill count', () => {
|
|
371
|
+
const dir = makeTempDir('finalize-182-');
|
|
372
|
+
const store = new ExecutionStore({ appFolder: dir, useLinks: false });
|
|
373
|
+
const record = new ExecutionRecord({ command: 'x' });
|
|
374
|
+
store.save(record);
|
|
375
|
+
const { record: finalized } = finalizeDetachedExecution({
|
|
376
|
+
store,
|
|
377
|
+
executionId: record.uuid,
|
|
378
|
+
exitCode: '137',
|
|
379
|
+
oomKilled: 'false',
|
|
380
|
+
finishedAt: '2026-10-01T10:05:00Z',
|
|
381
|
+
cgroupMemory: OOM_SAMPLE,
|
|
382
|
+
});
|
|
383
|
+
expect(finalized.cgroupMemory).toEqual(OOM_COUNTERS);
|
|
384
|
+
expect(finalized.exitReason).toBeTruthy();
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('issue #182: the kill recovery keeps the killed run counters', () => {
|
|
389
|
+
function ok(stdout = '') {
|
|
390
|
+
return { success: true, stdout, stderr: '', status: 0, error: null };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
it('logs them and notes them in recoveryHistory', () => {
|
|
394
|
+
const dir = makeTempDir('recovery-182-');
|
|
395
|
+
const store = new ExecutionStore({
|
|
396
|
+
appFolder: path.join(dir, 'app'),
|
|
397
|
+
useLinks: false,
|
|
398
|
+
});
|
|
399
|
+
const logPath = path.join(dir, 'run.log');
|
|
400
|
+
fs.writeFileSync(logPath, 'main output\n');
|
|
401
|
+
const record = new ExecutionRecord({
|
|
402
|
+
status: ExecutionStatus.EXECUTING,
|
|
403
|
+
command: 'cargo test',
|
|
404
|
+
logPath,
|
|
405
|
+
cgroupMemory: OOM_COUNTERS,
|
|
406
|
+
options: {
|
|
407
|
+
isolated: 'docker',
|
|
408
|
+
isolationMode: 'detached',
|
|
409
|
+
sessionName: 'box',
|
|
410
|
+
onKillResume: 2,
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
store.save(record);
|
|
414
|
+
const outcome = recoverKilledExecution({
|
|
415
|
+
store,
|
|
416
|
+
executionId: record.uuid,
|
|
417
|
+
exitCode: '137',
|
|
418
|
+
oomKilled: 'true',
|
|
419
|
+
startedAt: '2026-10-01T10:00:00Z',
|
|
420
|
+
finishedAt: '2026-10-01T10:05:00Z',
|
|
421
|
+
containerError: '',
|
|
422
|
+
cgroupMemory: OOM_SAMPLE,
|
|
423
|
+
runner: (command, args) => ok(args[0] === 'inspect' ? '{}\n' : 'cid\n'),
|
|
424
|
+
startWatcher: () => {},
|
|
425
|
+
now: () => new Date('2026-10-01T10:06:00.000Z'),
|
|
426
|
+
});
|
|
427
|
+
expect(outcome.recovered).toBe(true);
|
|
428
|
+
expect(fs.readFileSync(logPath, 'utf8')).toContain(
|
|
429
|
+
`${formatCgroupMemoryLogLine(OOM_SAMPLE)}\n\n[Recovery 1/2]`
|
|
430
|
+
);
|
|
431
|
+
const stored = store.get(record.uuid);
|
|
432
|
+
expect(stored.options.recoveryHistory).toEqual([
|
|
433
|
+
'1: exit 137, oomKilled=true, oomEvents=0, oomKills=3, resumed at 2026-10-01T10:06:00.000Z',
|
|
434
|
+
]);
|
|
435
|
+
// The resumed run gets a fresh cgroup: the old counters must not explain
|
|
436
|
+
// its exit.
|
|
437
|
+
expect(stored.cgroupMemory).toBeUndefined();
|
|
438
|
+
});
|
|
439
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** Issue #185: allocation events and killed processes cannot establish OOM scope. */
|
|
2
|
+
const { describe, it, expect } = require('bun:test');
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const {
|
|
8
|
+
buildCgroupMemoryLogSnippet,
|
|
9
|
+
describeCgroupOomScope,
|
|
10
|
+
formatCgroupMemory,
|
|
11
|
+
formatCgroupMemoryLogLine,
|
|
12
|
+
parseCgroupMemorySample,
|
|
13
|
+
} = require('../src/lib/cgroup-memory');
|
|
14
|
+
const {
|
|
15
|
+
CGROUP_OOM_EXIT_REASON,
|
|
16
|
+
resolveExitReason,
|
|
17
|
+
resolveMemoryExhaustion,
|
|
18
|
+
} = require('../src/lib/exit-reason');
|
|
19
|
+
|
|
20
|
+
// Group kills, equal counts, earlier allocation failures, zero and unknown oom.
|
|
21
|
+
const SAMPLES = [
|
|
22
|
+
'3135373312 3135373312 1 3',
|
|
23
|
+
'3135373312 3135373312 2 2',
|
|
24
|
+
'3135373312 3135373312 4 1',
|
|
25
|
+
'3135373312 3135373312 0 3',
|
|
26
|
+
'3135373312 3135373312 - 3',
|
|
27
|
+
];
|
|
28
|
+
const NOTE = 'OOM kill scope unknown';
|
|
29
|
+
|
|
30
|
+
describe('issue #185: counter-only OOM scope is unknown', () => {
|
|
31
|
+
for (const sample of SAMPLES) {
|
|
32
|
+
it(`preserves raw counters without attributing scope: ${sample}`, () => {
|
|
33
|
+
const memory = parseCgroupMemorySample(sample);
|
|
34
|
+
const [, , oom, kills] = sample.split(' ');
|
|
35
|
+
expect(memory).toEqual({
|
|
36
|
+
limitBytes: 3135373312,
|
|
37
|
+
peakBytes: 3135373312,
|
|
38
|
+
oomEvents: oom === '-' ? null : Number(oom),
|
|
39
|
+
oomKills: Number(kills),
|
|
40
|
+
});
|
|
41
|
+
expect(describeCgroupOomScope(memory)).toBe('unknown');
|
|
42
|
+
expect(formatCgroupMemory(memory)).toBe(
|
|
43
|
+
`peak 2.9 GiB of 2.9 GiB limit, oom ${oom === '-' ? 'unknown' : oom}, oom_kill ${kills} (${NOTE})`
|
|
44
|
+
);
|
|
45
|
+
expect(formatCgroupMemoryLogLine(sample)).toBe(
|
|
46
|
+
`Memory: memory.max=3135373312 memory.peak=3135373312 oom=${oom} oom_kill=${kills} (${NOTE})`
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
it('does not report an OOM scope when kills are zero or unknown', () => {
|
|
52
|
+
for (const memory of [null, {}, { oomKills: 0 }, { oomKills: null }]) {
|
|
53
|
+
expect(describeCgroupOomScope(memory)).toBeNull();
|
|
54
|
+
}
|
|
55
|
+
for (const sample of ['max - 4 0', 'max - 4 -']) {
|
|
56
|
+
expect(formatCgroupMemoryLogLine(sample)).not.toContain(NOTE);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('writes the same unknown scope from the POSIX watcher shell', () => {
|
|
61
|
+
if (process.platform === 'win32') {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cgroup-185-'));
|
|
65
|
+
try {
|
|
66
|
+
const logPath = path.join(dir, 'memory.log');
|
|
67
|
+
for (const sample of [...SAMPLES, 'max - 4 0', 'max - 4 -']) {
|
|
68
|
+
fs.writeFileSync(logPath, '');
|
|
69
|
+
const result = spawnSync(
|
|
70
|
+
'/bin/sh',
|
|
71
|
+
[
|
|
72
|
+
'-c',
|
|
73
|
+
`__start_command_cgroup='${sample}'; ${buildCgroupMemoryLogSnippet('"$1"')}`,
|
|
74
|
+
'watcher',
|
|
75
|
+
logPath,
|
|
76
|
+
],
|
|
77
|
+
{ encoding: 'utf8', timeout: 5000 }
|
|
78
|
+
);
|
|
79
|
+
expect(result.status).toBe(0);
|
|
80
|
+
const [max, peak, oom, kills] = sample.split(' ');
|
|
81
|
+
const note = Number(kills) > 0 ? ` (${NOTE})` : '';
|
|
82
|
+
expect(fs.readFileSync(logPath, 'utf8')).toBe(
|
|
83
|
+
`Memory: memory.max=${max} memory.peak=${peak} oom=${oom} oom_kill=${kills}${note}\n`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('retains the exit-code guard for earlier child OOM kills', () => {
|
|
92
|
+
const cgroupMemory = parseCgroupMemorySample(SAMPLES[0]);
|
|
93
|
+
for (const exitCode of [0, 1, 134, 139, 143]) {
|
|
94
|
+
const input = { exitCode, oomKilled: false, cgroupMemory };
|
|
95
|
+
expect(resolveExitReason(input)).not.toBe(CGROUP_OOM_EXIT_REASON);
|
|
96
|
+
expect(resolveMemoryExhaustion(input)).toBeNull();
|
|
97
|
+
}
|
|
98
|
+
for (const exitCode of [137, null, -1]) {
|
|
99
|
+
const input = { exitCode, oomKilled: false, cgroupMemory };
|
|
100
|
+
expect(resolveExitReason(input)).toBe(CGROUP_OOM_EXIT_REASON);
|
|
101
|
+
expect(resolveMemoryExhaustion(input)).toEqual(
|
|
102
|
+
exitCode === null
|
|
103
|
+
? null
|
|
104
|
+
: {
|
|
105
|
+
memoryExhausted: true,
|
|
106
|
+
memoryExhaustedReason: 'cgroup memory.events reported oom_kill=3',
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|