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.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/lib/cgroup-memory.js +18 -13
- package/src/lib/detached-finalize.js +31 -7
- package/src/lib/detached-output.js +61 -0
- package/src/lib/docker-cleanup.js +20 -2
- package/src/lib/execution-attempt.js +153 -0
- package/src/lib/execution-recovery.js +61 -1
- package/src/lib/execution-resume-all.js +12 -1
- package/src/lib/execution-resume.js +70 -20
- package/src/lib/execution-store.js +31 -0
- package/src/lib/isolation-log-utils.js +3 -3
- package/src/lib/isolation.js +9 -7
- package/src/lib/status-formatter.js +38 -6
- package/test/execution-resume.js +2 -1
- package/test/regression-176.js +5 -1
- package/test/regression-182.js +18 -18
- package/test/regression-185.js +111 -0
- package/test/regression-187.js +546 -0
|
@@ -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(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
252
|
+
if (size - length === offset || length === 0) {
|
|
253
253
|
return tail;
|
|
254
254
|
}
|
|
255
255
|
const firstNewline = tail.indexOf('\n');
|
package/src/lib/isolation.js
CHANGED
|
@@ -659,13 +659,15 @@ function runInDocker(command, options = {}) {
|
|
|
659
659
|
}
|
|
660
660
|
}
|
|
661
661
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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(
|
package/test/execution-resume.js
CHANGED
|
@@ -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,
|
|
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' });
|
package/test/regression-176.js
CHANGED
|
@@ -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({
|
|
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', () => {
|
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
|
+
});
|