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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.35.2",
3
+ "version": "0.35.3",
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": {
@@ -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 keeps all three per cgroup
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` the cgroup hit its own limit
11
- * memory.events `oom_kill` processes killed here by *any* OOM killer, so
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 if [ "$4" -gt "$3" ] 2>/dev/null; then ` +
126
- `__scm_note=' (${OOM_SCOPE_NOTES[OOM_SCOPE.HOST_OR_PARENT]})'; ` +
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
- 'oom_kill > oom: a host-wide or parent cgroup OOM killed processes here',
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
- * @returns {?string} One of OOM_SCOPE, or null when nothing was OOM-killed
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 counters.oomKills > (counters.oomEvents || 0)
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) {
@@ -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 counts all of that per cgroup
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 HOST_OOM_SAMPLE = '268435456 268300000 0 3';
77
- const HOST_OOM = {
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(HOST_OOM_SAMPLE)).toEqual(HOST_OOM);
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('tells a container limit from a host-wide or parent OOM', () => {
212
- expect(describeCgroupOomScope(HOST_OOM)).toBe(OOM_SCOPE.HOST_OR_PARENT);
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.CONTAINER_LIMIT
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(HOST_OOM)).toBe(
221
+ expect(formatCgroupMemory(OOM_COUNTERS)).toBe(
222
222
  'peak 255.9 MiB of 256.0 MiB limit, oom 0, oom_kill 3 ' +
223
- '(oom_kill > oom: a host-wide or parent cgroup OOM killed processes here)'
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 (the container hit its own memory limit)'
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
- HOST_OOM_SAMPLE
289
+ OOM_SAMPLE
290
290
  );
291
291
  expect(fs.readFileSync(logPath, 'utf8')).toBe(
292
- `${formatCgroupMemoryLogLine(HOST_OOM_SAMPLE)}\n`
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
- '(oom_kill > oom: a host-wide or parent cgroup OOM killed processes here)'
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: HOST_OOM_SAMPLE,
381
+ cgroupMemory: OOM_SAMPLE,
382
382
  });
383
- expect(finalized.cgroupMemory).toEqual(HOST_OOM);
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: HOST_OOM,
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: HOST_OOM_SAMPLE,
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(HOST_OOM_SAMPLE)}\n\n[Recovery 1/2]`
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
+ });