start-command 0.35.2 → 0.35.4

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.
@@ -31,6 +31,12 @@ const {
31
31
  readDockerResourceLimits,
32
32
  } = require('./docker-resource-limits');
33
33
  const { appendLogFile } = require('./isolation-log-utils');
34
+ const {
35
+ appendLifecycle,
36
+ archiveAttempt,
37
+ createAttempt,
38
+ patchAttempt,
39
+ } = require('./execution-attempt');
34
40
  const { runCommand } = require('./execution-control');
35
41
  const { SessionState, probeSession } = require('./session-probe');
36
42
  const { getDockerNetworks } = require('./docker-network-lifecycle');
@@ -105,6 +111,7 @@ function buildLaunchOptions(record) {
105
111
  resourceLimits: normalizeResourceLimits(opts.resourceLimits),
106
112
  // Append to the same log so one logical session keeps one gap-free record.
107
113
  logPath: record.logPath || null,
114
+ deferCompletionWatcher: true,
108
115
  };
109
116
  }
110
117
 
@@ -339,10 +346,17 @@ function formatResumeResult(result, outputFormat) {
339
346
  * @param {?string} containerId - New container id, when one was created
340
347
  * @returns {object} The updated record
341
348
  */
342
- function applyResumeToRecord(record, plan, containerId) {
349
+ function applyResumeToRecord(
350
+ record,
351
+ plan,
352
+ containerId,
353
+ attempt = createAttempt(record, plan)
354
+ ) {
355
+ archiveAttempt(record);
356
+ record.attempt = attempt;
343
357
  const options = { ...(record.options || {}) };
344
358
  options.resumeCount = plan.attempt;
345
- options.resumedAt = new Date().toISOString();
359
+ options.resumedAt = attempt.startedAt;
346
360
  // A resume is a new deliberate start: launch-time recovery applies again.
347
361
  delete options.stopRequestedAt;
348
362
  if (plan.resourceLimits && plan.resourceLimits.length > 0) {
@@ -423,6 +437,13 @@ async function resumeExecution(store, identifier, deps = {}) {
423
437
  }
424
438
 
425
439
  let containerId = null;
440
+ const attempt = createAttempt(record, plan);
441
+ const lifecycleRecord = {
442
+ uuid: record.uuid,
443
+ logPath: record.logPath,
444
+ attempt,
445
+ };
446
+ appendLifecycle(lifecycleRecord, 'resume-started');
426
447
 
427
448
  if (plan.mode === ResumeMode.RELAUNCH) {
428
449
  const runIsolated = deps.runIsolated || require('./isolation').runIsolated;
@@ -432,6 +453,9 @@ async function resumeExecution(store, identifier, deps = {}) {
432
453
  plan.launchOptions
433
454
  );
434
455
  if (!launchResult || !launchResult.success) {
456
+ appendLifecycle(lifecycleRecord, 'launch-failed', {
457
+ error: launchResult?.message || 'unknown error',
458
+ });
435
459
  return {
436
460
  success: false,
437
461
  error: `Failed to relaunch ${plan.backend} session "${plan.sessionName}": ${
@@ -448,6 +472,7 @@ async function resumeExecution(store, identifier, deps = {}) {
448
472
  (result.stderr || '').trim() ||
449
473
  result.error ||
450
474
  `exit code ${result.status}`;
475
+ appendLifecycle(lifecycleRecord, 'launch-failed', { error: detail });
451
476
  return {
452
477
  success: false,
453
478
  error: `Failed to resume ${plan.backend} session "${plan.sessionName}": ${detail}`,
@@ -460,28 +485,53 @@ async function resumeExecution(store, identifier, deps = {}) {
460
485
  if (limitsLine && record.logPath) {
461
486
  appendLogFile(record.logPath, `${limitsLine}\n`);
462
487
  }
463
-
464
- // The completion watcher died with the previous run (or with the
465
- // supervisor), so a new one must follow the resumed container.
466
- const startWatcher =
467
- deps.startWatcher || startDetachedDockerCompletionWatcher;
468
- startWatcher(
469
- activeSessionName(plan),
470
- getDockerContainerCleanupPolicy(record.options || {}),
471
- record.logPath || null,
472
- record.uuid || null,
473
- // `docker start` re-runs the launch-time selector, so the session keeps
474
- // its kill recovery (issue #176); a snapshot container has no selector.
475
- {
476
- recoverOnKill:
477
- plan.mode === ResumeMode.DOCKER_START && Boolean(opts.onKillResume),
478
- }
479
- );
480
488
  }
481
489
 
482
490
  const previousSessionName = plan.newSessionName ? plan.sessionName : null;
483
- const updated = applyResumeToRecord(record, plan, containerId);
491
+ attempt.launchAcceptedAt = new Date().toISOString();
492
+ const updated = applyResumeToRecord(record, plan, containerId, attempt);
484
493
  store.save(updated);
494
+ appendLifecycle(updated, 'launch-accepted');
495
+
496
+ if (plan.backend === 'docker') {
497
+ try {
498
+ const startWatcher =
499
+ deps.startWatcher || startDetachedDockerCompletionWatcher;
500
+ const watcher = startWatcher(
501
+ activeSessionName(plan),
502
+ getDockerContainerCleanupPolicy(updated.options),
503
+ updated.logPath || null,
504
+ updated.uuid || null,
505
+ {
506
+ since: attempt.startedAt,
507
+ attemptNumber: attempt.number,
508
+ recoverOnKill:
509
+ plan.mode !== ResumeMode.DOCKER_SNAPSHOT &&
510
+ Boolean(opts.onKillResume),
511
+ }
512
+ );
513
+ if (watcher === false) {
514
+ throw new Error('Watcher process could not be started');
515
+ }
516
+ const current = patchAttempt(store, updated, {
517
+ watcherAttachedAt: new Date().toISOString(),
518
+ });
519
+ if (current) {
520
+ appendLifecycle(current, 'watcher-attached');
521
+ }
522
+ } catch (error) {
523
+ const current = patchAttempt(store, updated, {
524
+ watcherError: error.message,
525
+ });
526
+ appendLifecycle(current || updated, 'watcher-attachment-failed', {
527
+ error: error.message,
528
+ });
529
+ return {
530
+ success: false,
531
+ error: `Launch accepted, but completion watcher attachment failed: ${error.message}`,
532
+ };
533
+ }
534
+ }
485
535
 
486
536
  return {
487
537
  success: true,
@@ -75,6 +75,8 @@ class ExecutionRecord {
75
75
  this.logPath = options.logPath || '';
76
76
  this.startTime = options.startTime || new Date().toISOString();
77
77
  this.endTime = options.endTime || null;
78
+ this.attempt = options.attempt;
79
+ this.attemptHistory = options.attemptHistory;
78
80
  // Provenance of `endTime` (issue #170.2). `--status` used to fabricate a
79
81
  // finish time with `new Date()` whenever the record had none, which made an
80
82
  // observation indistinguishable from a real finish time. One of
@@ -152,6 +154,12 @@ class ExecutionRecord {
152
154
  startTime: this.startTime,
153
155
  endTime: this.endTime,
154
156
  };
157
+ if (this.attempt) {
158
+ obj.attempt = this.attempt;
159
+ }
160
+ if (this.attemptHistory) {
161
+ obj.attemptHistory = this.attemptHistory;
162
+ }
155
163
  if (this.endTimeSource !== undefined && this.endTimeSource !== null) {
156
164
  obj.endTimeSource = this.endTimeSource;
157
165
  }
@@ -557,6 +565,29 @@ class ExecutionStore {
557
565
  }
558
566
  }
559
567
 
568
+ // Merge watcher metadata under the database lock without replacing terminal state.
569
+ patchAttempt(uuid, number, fields) {
570
+ const lock = new LockManager(this.lockFilePath);
571
+ if (!lock.acquire()) {
572
+ throw new Error('Failed to acquire lock for attempt metadata');
573
+ }
574
+ try {
575
+ const records = this.readLinoRecords();
576
+ const current = records.find((record) => record.uuid === uuid);
577
+ if (current?.attempt?.number !== number) {
578
+ return null;
579
+ }
580
+ current.attempt = { ...current.attempt, ...fields };
581
+ this.writeLinoRecords(records);
582
+ if (this.useLinks) {
583
+ this.writeLinksRecord(current);
584
+ }
585
+ return current;
586
+ } finally {
587
+ lock.release();
588
+ }
589
+ }
590
+
560
591
  /**
561
592
  * Get an execution record by UUID or session name
562
593
  * First tries exact UUID match, then the current session name, then any
@@ -240,16 +240,16 @@ const FATAL_MARKER_TAIL_BYTES = 64 * 1024;
240
240
  * @param {number} [bytes] - Maximum number of trailing bytes to read
241
241
  * @returns {string|null} Tail content, or null when the file cannot be read
242
242
  */
243
- function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
243
+ function readLogTail(logPath, bytes = LOG_TAIL_BYTES, offset = 0) {
244
244
  let fd;
245
245
  try {
246
246
  fd = fs.openSync(logPath, 'r');
247
247
  const size = fs.fstatSync(fd).size;
248
- const length = Math.min(size, bytes);
248
+ const length = Math.min(Math.max(0, size - offset), bytes);
249
249
  const buffer = Buffer.alloc(length);
250
250
  fs.readSync(fd, buffer, 0, length, size - length);
251
251
  const tail = buffer.toString('utf8');
252
- if (size <= bytes) {
252
+ if (size - length === offset || length === 0) {
253
253
  return tail;
254
254
  }
255
255
  const firstNewline = tail.indexOf('\n');
@@ -659,13 +659,15 @@ function runInDocker(command, options = {}) {
659
659
  }
660
660
  }
661
661
 
662
- startDetachedDockerCompletionWatcher(
663
- containerName,
664
- cleanupPolicy,
665
- options.logPath,
666
- options.executionId || null,
667
- { recoverOnKill: Boolean(options.onKillResume) }
668
- );
662
+ if (!options.deferCompletionWatcher) {
663
+ startDetachedDockerCompletionWatcher(
664
+ containerName,
665
+ cleanupPolicy,
666
+ options.logPath,
667
+ options.executionId || null,
668
+ { recoverOnKill: Boolean(options.onKillResume) }
669
+ );
670
+ }
669
671
 
670
672
  let message = `Command started in detached docker container: ${containerName}`;
671
673
  message += `\nContainer ID: ${containerId.substring(0, 12)}`;
@@ -94,7 +94,15 @@ function readDockerState(record) {
94
94
  if (opts.isolated !== 'docker' || !opts.sessionName) {
95
95
  return null;
96
96
  }
97
- return inspectDockerState(opts.sessionName);
97
+ const state = inspectDockerState(opts.sessionName);
98
+ if (
99
+ record.attempt &&
100
+ state?.startedAt &&
101
+ Date.parse(state.startedAt) < Date.parse(record.attempt.startedAt)
102
+ ) {
103
+ return null;
104
+ }
105
+ return state;
98
106
  }
99
107
 
100
108
  /**
@@ -506,12 +514,26 @@ function attachMemoryExhaustion(record, logTail) {
506
514
  * @returns {Object} Possibly updated execution record
507
515
  */
508
516
  function enrichDetachedStatus(record) {
509
- const logTail =
510
- record && record.logPath
511
- ? readLogTail(record.logPath, FATAL_MARKER_TAIL_BYTES)
512
- : null;
517
+ if (!record) {
518
+ return record;
519
+ }
520
+ const {
521
+ readAttemptActivity,
522
+ readAttemptLogTail,
523
+ } = require('./execution-attempt');
524
+ const logTail = record
525
+ ? readAttemptLogTail(record, FATAL_MARKER_TAIL_BYTES)
526
+ : null;
513
527
  const resolved = resolveDetachedStatus(record, logTail);
514
- return attachMemoryExhaustion(attachExitReason(resolved, logTail), logTail);
528
+ const enriched = attachMemoryExhaustion(
529
+ attachExitReason(resolved, logTail),
530
+ logTail
531
+ );
532
+ const copy = Object.assign(
533
+ Object.create(Object.getPrototypeOf(enriched)),
534
+ enriched
535
+ );
536
+ return readAttemptActivity(copy);
515
537
  }
516
538
 
517
539
  /**
@@ -693,6 +715,16 @@ function formatRecordAsText(record) {
693
715
  lines.push(`Stale Detected At: ${obj.staleDetectedAt}`);
694
716
  }
695
717
  lines.push(`Log Path: ${obj.logPath}`);
718
+ if (obj.attempt) {
719
+ lines.push(
720
+ `Attempt: ${obj.attempt.number}`,
721
+ `Attempt Started: ${obj.attempt.startedAt}`,
722
+ `Attempt Log Offset: ${obj.attempt.logOffset ?? 'N/A'}`,
723
+ `Launch Accepted: ${obj.attempt.launchAcceptedAt || 'N/A'}`,
724
+ `Watcher Attached: ${obj.attempt.watcherAttachedAt || 'N/A'}`,
725
+ `Last Output: ${obj.attempt.lastOutputAt || 'N/A'}`
726
+ );
727
+ }
696
728
 
697
729
  // Format options as nested list instead of JSON
698
730
  const optionEntries = Object.entries(obj.options || {}).filter(
@@ -274,7 +274,7 @@ describe('resumeExecution', () => {
274
274
  assert.strictEqual(watchers.length, 1);
275
275
  assert.strictEqual(watchers[0][0], 'box');
276
276
  assert.strictEqual(watchers[0][2], '/tmp/session.log');
277
- assert.strictEqual(store.saved.length, 1);
277
+ assert.strictEqual(store.saved.length, 2);
278
278
  assert.strictEqual(store.saved[0].uuid, record.uuid);
279
279
  assert.strictEqual(store.saved[0].status, 'executing');
280
280
  assert.strictEqual(store.saved[0].options.resumeCount, 1);
@@ -329,6 +329,7 @@ describe('resumeExecution', () => {
329
329
  const launches = [];
330
330
  const result = await resumeExecution(store, 'box', {
331
331
  probe: () => probeWith(SessionState.MISSING),
332
+ startWatcher: () => {},
332
333
  runIsolated: (backend, command, options) => {
333
334
  launches.push({ backend, command, options });
334
335
  return Promise.resolve({ success: true, containerId: 'newid' });
@@ -313,7 +313,11 @@ describe('issue #176: snapshot resume keeps the resource limits', () => {
313
313
  expect(saved[0].options.sessionName).toBe('box-resume-1');
314
314
  expect(result.output).toContain('--pids-limit=64');
315
315
  // A snapshot container has no recovery selector.
316
- expect(watchers[0][4]).toEqual({ recoverOnKill: false });
316
+ expect(watchers[0][4]).toEqual({
317
+ recoverOnKill: false,
318
+ since: saved[0].attempt.startedAt,
319
+ attemptNumber: saved[0].attempt.number,
320
+ });
317
321
  });
318
322
 
319
323
  it('shows the limits in [Isolation] lines and stores them as metadata', () => {
@@ -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
+ });