start-command 0.35.2 → 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 +6 -0
- package/package.json +1 -1
- package/src/lib/cgroup-memory.js +18 -13
- package/test/regression-182.js +18 -18
- package/test/regression-185.js +111 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.35.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ec0a9cf: Report OOM kill scope as unknown when only cgroup memory counters are available. Allocation events and killed processes have different units, so their comparison cannot establish container, parent or host scope. Preserve raw counters and the exit-code guard for earlier child OOM kills.
|
|
8
|
+
|
|
3
9
|
## 0.35.2
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
package/src/lib/cgroup-memory.js
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* it says that *some* process in the container was OOM-killed at some point,
|
|
6
6
|
* but not how many, not whether the container hit its own `--memory` limit or
|
|
7
7
|
* the whole host ran out, and not how close the execution came to its limit.
|
|
8
|
-
* The kernel
|
|
8
|
+
* The kernel provides raw counters per cgroup
|
|
9
9
|
* (https://docs.kernel.org/admin-guide/cgroup-v2.html#memory-interface-files):
|
|
10
|
-
* memory.events `oom`
|
|
11
|
-
* memory.events `oom_kill` processes killed here by *any* OOM killer
|
|
12
|
-
* `oom_kill > oom` points to a host-wide (or
|
|
13
|
-
* parent cgroup) OOM
|
|
10
|
+
* memory.events `oom` allocation events reaching the memory limit
|
|
11
|
+
* memory.events `oom_kill` processes killed here by *any* OOM killer
|
|
14
12
|
* memory.peak / memory.max peak usage (Linux 5.19+) and limit (`max` when
|
|
15
13
|
* unlimited)
|
|
14
|
+
* These counters have different units and are hierarchical. A group OOM can
|
|
15
|
+
* kill several processes for one allocation event; earlier allocation events
|
|
16
|
+
* can also coexist with a later host OOM. Their comparison cannot establish
|
|
17
|
+
* container, parent or host scope (issue #185).
|
|
16
18
|
*
|
|
17
19
|
* The cgroup is removed when the container stops, so the values cannot be read
|
|
18
20
|
* afterwards. The detached completion watcher therefore samples them while the
|
|
@@ -122,9 +124,8 @@ function buildCgroupMemoryLogSnippet(quotedLogPath) {
|
|
|
122
124
|
// A function, so `$1`.. of the watcher script itself stay untouched.
|
|
123
125
|
return (
|
|
124
126
|
`__start_command_cgroup_log() { __scm_note=''; ` +
|
|
125
|
-
`if [ "$4" -gt 0 ] 2>/dev/null; then
|
|
126
|
-
`__scm_note=' (${OOM_SCOPE_NOTES[OOM_SCOPE.
|
|
127
|
-
`else __scm_note=' (${OOM_SCOPE_NOTES[OOM_SCOPE.CONTAINER_LIMIT]})'; fi; fi; ` +
|
|
127
|
+
`if [ "$4" -gt 0 ] 2>/dev/null; then ` +
|
|
128
|
+
`__scm_note=' (${OOM_SCOPE_NOTES[OOM_SCOPE.UNKNOWN]})'; fi; ` +
|
|
128
129
|
`printf 'Memory: memory.max=%s memory.peak=%s oom=%s oom_kill=%s%s\\n' ` +
|
|
129
130
|
`"$1" "$2" "$3" "$4" "$__scm_note"; }; ` +
|
|
130
131
|
`if [ -n "$${v.sample}" ]; then __start_command_cgroup_log $${v.sample} >> ${quotedLogPath}; fi`
|
|
@@ -151,14 +152,17 @@ function formatCgroupMemoryLogLine(text) {
|
|
|
151
152
|
|
|
152
153
|
/** Where the OOM killer that killed processes in the container came from. */
|
|
153
154
|
const OOM_SCOPE = {
|
|
155
|
+
UNKNOWN: 'unknown',
|
|
156
|
+
// Retained scope names; raw counters alone cannot establish either one.
|
|
154
157
|
CONTAINER_LIMIT: 'container-limit',
|
|
155
158
|
HOST_OR_PARENT: 'host-or-parent',
|
|
156
159
|
};
|
|
157
160
|
|
|
158
161
|
const OOM_SCOPE_NOTES = {
|
|
162
|
+
[OOM_SCOPE.UNKNOWN]: 'OOM kill scope unknown',
|
|
159
163
|
[OOM_SCOPE.CONTAINER_LIMIT]: 'the container hit its own memory limit',
|
|
160
164
|
[OOM_SCOPE.HOST_OR_PARENT]:
|
|
161
|
-
'
|
|
165
|
+
'a host-wide or parent cgroup OOM killed processes here',
|
|
162
166
|
};
|
|
163
167
|
|
|
164
168
|
function parseCounter(text) {
|
|
@@ -216,16 +220,17 @@ function normalizeCgroupMemory(value) {
|
|
|
216
220
|
|
|
217
221
|
/**
|
|
218
222
|
* @param {?object} memory - Counters
|
|
219
|
-
*
|
|
223
|
+
* Raw counters do not attribute the OOM killer's scope. Separately attributed
|
|
224
|
+
* kernel/cgroup evidence would be needed to establish container, parent or
|
|
225
|
+
* host scope. An unknown oomEvents count remains unknown, never zero.
|
|
226
|
+
* @returns {?string} OOM_SCOPE.UNKNOWN for observed kills, otherwise null
|
|
220
227
|
*/
|
|
221
228
|
function describeCgroupOomScope(memory) {
|
|
222
229
|
const counters = normalizeCgroupMemory(memory);
|
|
223
230
|
if (!counters || !(counters.oomKills > 0)) {
|
|
224
231
|
return null;
|
|
225
232
|
}
|
|
226
|
-
return
|
|
227
|
-
? OOM_SCOPE.HOST_OR_PARENT
|
|
228
|
-
: OOM_SCOPE.CONTAINER_LIMIT;
|
|
233
|
+
return OOM_SCOPE.UNKNOWN;
|
|
229
234
|
}
|
|
230
235
|
|
|
231
236
|
function formatBytes(bytes) {
|
package/test/regression-182.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Docker's `State.OOMKilled` is container-wide and sticky (moby/moby#43564):
|
|
5
5
|
* it cannot say how many processes the OOM killer took, whether the
|
|
6
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
|
|
7
|
+
* close the run came to its limit. The kernel provides raw counters per cgroup
|
|
8
8
|
* (`memory.events` `oom`/`oom_kill`, `memory.peak`, `memory.max`), but the
|
|
9
9
|
* cgroup is gone once the container stops.
|
|
10
10
|
*
|
|
@@ -73,8 +73,8 @@ const needsPosixShell = () => {
|
|
|
73
73
|
};
|
|
74
74
|
|
|
75
75
|
const CONTAINER_ID = `182c${'0'.repeat(60)}`;
|
|
76
|
-
const
|
|
77
|
-
const
|
|
76
|
+
const OOM_SAMPLE = '268435456 268300000 0 3';
|
|
77
|
+
const OOM_COUNTERS = {
|
|
78
78
|
limitBytes: 268435456,
|
|
79
79
|
peakBytes: 268300000,
|
|
80
80
|
oomEvents: 0,
|
|
@@ -196,7 +196,7 @@ function runWatcher({ events, during, policy, state = KILLED_STATE, max }) {
|
|
|
196
196
|
|
|
197
197
|
describe('issue #182: reading cgroup v2 memory counters', () => {
|
|
198
198
|
it('parses the sample the watcher writes', () => {
|
|
199
|
-
expect(parseCgroupMemorySample(
|
|
199
|
+
expect(parseCgroupMemorySample(OOM_SAMPLE)).toEqual(OOM_COUNTERS);
|
|
200
200
|
expect(parseCgroupMemorySample('max - 1 1\n')).toEqual({
|
|
201
201
|
limitBytes: null,
|
|
202
202
|
peakBytes: null,
|
|
@@ -208,19 +208,19 @@ describe('issue #182: reading cgroup v2 memory counters', () => {
|
|
|
208
208
|
}
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
-
it('
|
|
212
|
-
expect(describeCgroupOomScope(
|
|
211
|
+
it('leaves OOM scope unknown with only raw counters', () => {
|
|
212
|
+
expect(describeCgroupOomScope(OOM_COUNTERS)).toBe(OOM_SCOPE.UNKNOWN);
|
|
213
213
|
expect(describeCgroupOomScope({ oomEvents: 2, oomKills: 2 })).toBe(
|
|
214
|
-
OOM_SCOPE.
|
|
214
|
+
OOM_SCOPE.UNKNOWN
|
|
215
215
|
);
|
|
216
216
|
expect(describeCgroupOomScope({ oomEvents: 4, oomKills: 0 })).toBeNull();
|
|
217
217
|
expect(describeCgroupOomScope(null)).toBeNull();
|
|
218
218
|
});
|
|
219
219
|
|
|
220
220
|
it('formats the counters for --status and for the log', () => {
|
|
221
|
-
expect(formatCgroupMemory(
|
|
221
|
+
expect(formatCgroupMemory(OOM_COUNTERS)).toBe(
|
|
222
222
|
'peak 255.9 MiB of 256.0 MiB limit, oom 0, oom_kill 3 ' +
|
|
223
|
-
'(
|
|
223
|
+
'(OOM kill scope unknown)'
|
|
224
224
|
);
|
|
225
225
|
expect(
|
|
226
226
|
formatCgroupMemory({
|
|
@@ -230,7 +230,7 @@ describe('issue #182: reading cgroup v2 memory counters', () => {
|
|
|
230
230
|
oomKills: 1,
|
|
231
231
|
})
|
|
232
232
|
).toBe(
|
|
233
|
-
'peak unknown of no limit, oom 1, oom_kill 1 (
|
|
233
|
+
'peak unknown of no limit, oom 1, oom_kill 1 (OOM kill scope unknown)'
|
|
234
234
|
);
|
|
235
235
|
expect(formatCgroupMemoryLogLine('max 1024 0 0')).toBe(
|
|
236
236
|
'Memory: memory.max=max memory.peak=1024 oom=0 oom_kill=0'
|
|
@@ -286,10 +286,10 @@ describe('issue #182: the watcher samples the cgroup while the container runs',
|
|
|
286
286
|
});
|
|
287
287
|
expect(result.status).toBe(0);
|
|
288
288
|
expect(fs.readFileSync(path.join(host.dir, 'sample'), 'utf8')).toBe(
|
|
289
|
-
|
|
289
|
+
OOM_SAMPLE
|
|
290
290
|
);
|
|
291
291
|
expect(fs.readFileSync(logPath, 'utf8')).toBe(
|
|
292
|
-
`${formatCgroupMemoryLogLine(
|
|
292
|
+
`${formatCgroupMemoryLogLine(OOM_SAMPLE)}\n`
|
|
293
293
|
);
|
|
294
294
|
});
|
|
295
295
|
|
|
@@ -309,7 +309,7 @@ describe('issue #182: the watcher samples the cgroup while the container runs',
|
|
|
309
309
|
expect(run.log).toContain('Exit Code: 137 (SIGKILL - 128+9)');
|
|
310
310
|
expect(run.log).toContain(
|
|
311
311
|
'Memory: memory.max=268435456 memory.peak=268300000 oom=1 oom_kill=3 ' +
|
|
312
|
-
'(
|
|
312
|
+
'(OOM kill scope unknown)'
|
|
313
313
|
);
|
|
314
314
|
expect(run.record.status).toBe(ExecutionStatus.EXECUTED);
|
|
315
315
|
expect(run.record.cgroupMemory).toEqual({
|
|
@@ -378,9 +378,9 @@ describe('issue #182: the watcher samples the cgroup while the container runs',
|
|
|
378
378
|
exitCode: '137',
|
|
379
379
|
oomKilled: 'false',
|
|
380
380
|
finishedAt: '2026-10-01T10:05:00Z',
|
|
381
|
-
cgroupMemory:
|
|
381
|
+
cgroupMemory: OOM_SAMPLE,
|
|
382
382
|
});
|
|
383
|
-
expect(finalized.cgroupMemory).toEqual(
|
|
383
|
+
expect(finalized.cgroupMemory).toEqual(OOM_COUNTERS);
|
|
384
384
|
expect(finalized.exitReason).toBeTruthy();
|
|
385
385
|
});
|
|
386
386
|
});
|
|
@@ -402,7 +402,7 @@ describe('issue #182: the kill recovery keeps the killed run counters', () => {
|
|
|
402
402
|
status: ExecutionStatus.EXECUTING,
|
|
403
403
|
command: 'cargo test',
|
|
404
404
|
logPath,
|
|
405
|
-
cgroupMemory:
|
|
405
|
+
cgroupMemory: OOM_COUNTERS,
|
|
406
406
|
options: {
|
|
407
407
|
isolated: 'docker',
|
|
408
408
|
isolationMode: 'detached',
|
|
@@ -419,14 +419,14 @@ describe('issue #182: the kill recovery keeps the killed run counters', () => {
|
|
|
419
419
|
startedAt: '2026-10-01T10:00:00Z',
|
|
420
420
|
finishedAt: '2026-10-01T10:05:00Z',
|
|
421
421
|
containerError: '',
|
|
422
|
-
cgroupMemory:
|
|
422
|
+
cgroupMemory: OOM_SAMPLE,
|
|
423
423
|
runner: (command, args) => ok(args[0] === 'inspect' ? '{}\n' : 'cid\n'),
|
|
424
424
|
startWatcher: () => {},
|
|
425
425
|
now: () => new Date('2026-10-01T10:06:00.000Z'),
|
|
426
426
|
});
|
|
427
427
|
expect(outcome.recovered).toBe(true);
|
|
428
428
|
expect(fs.readFileSync(logPath, 'utf8')).toContain(
|
|
429
|
-
`${formatCgroupMemoryLogLine(
|
|
429
|
+
`${formatCgroupMemoryLogLine(OOM_SAMPLE)}\n\n[Recovery 1/2]`
|
|
430
430
|
);
|
|
431
431
|
const stored = store.get(record.uuid);
|
|
432
432
|
expect(stored.options.recoveryHistory).toEqual([
|
|
@@ -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
|
+
});
|