start-command 0.37.1 → 0.37.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 +14 -0
- package/package.json +1 -1
- package/src/lib/execution-attempt.js +27 -0
- package/src/lib/execution-recovery.js +13 -7
- package/src/lib/exit-evidence.js +62 -14
- package/src/lib/exit-reason.js +7 -0
- package/src/lib/isolation-log-utils.js +7 -2
- package/src/lib/status-formatter.js +10 -1
- package/test/publish-to-crates.mjs +6 -2
- package/test/regression-191.js +18 -1
- package/test/regression-209.js +240 -0
- package/test/shared-golden.js +25 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.37.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- CI/CD: publish start-command to crates.io through trusted publishing (OIDC, `rust-lang/crates-io-auth-action`) instead of the long-lived `CARGO_TOKEN` / `CARGO_REGISTRY_TOKEN` secrets.
|
|
8
|
+
|
|
9
|
+
CI/CD: pass the crates.io token through CARGO_REGISTRY_TOKEN instead of the deprecated `cargo publish --token` flag, and pin GitHub runner images (ubuntu-24.04, macos-15, windows-2025).
|
|
10
|
+
|
|
11
|
+
## 0.37.2
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 65314a3: Attribute exit 137 to recent Docker OOM events when the last cgroup sample misses the final kill. Preserve daemon restart and ordinary-exit safeguards, apply attributed evidence to recovery memory limits, and recompute each archived attempt's status diagnostics independently.
|
|
16
|
+
|
|
3
17
|
## 0.37.1
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -90,6 +90,9 @@ function archiveAttempt(record) {
|
|
|
90
90
|
status: record.status,
|
|
91
91
|
exitCode: record.exitCode,
|
|
92
92
|
endTime: record.endTime,
|
|
93
|
+
...(record.options?.exitEvidence
|
|
94
|
+
? { exitEvidence: { ...record.options.exitEvidence } }
|
|
95
|
+
: {}),
|
|
93
96
|
};
|
|
94
97
|
for (const field of TERMINAL_FIELDS) {
|
|
95
98
|
if (record[field] !== undefined) {
|
|
@@ -142,7 +145,31 @@ function readAttemptLogTail(record, bytes) {
|
|
|
142
145
|
return readLogTail(record.logPath, bytes, record.attempt?.logOffset || 0);
|
|
143
146
|
}
|
|
144
147
|
|
|
148
|
+
function enrichAttemptHistory(record, enrich, bytes) {
|
|
149
|
+
return (record.attemptHistory || []).map((attempt, index, history) => {
|
|
150
|
+
const next = history[index + 1];
|
|
151
|
+
const end = next ? next.logOffset : record.attempt?.logOffset;
|
|
152
|
+
const bounded =
|
|
153
|
+
Number.isSafeInteger(attempt.logOffset) &&
|
|
154
|
+
attempt.logOffset >= 0 &&
|
|
155
|
+
Number.isSafeInteger(end) &&
|
|
156
|
+
end >= attempt.logOffset;
|
|
157
|
+
const tail =
|
|
158
|
+
record.logPath && bounded
|
|
159
|
+
? readLogTail(record.logPath, bytes, attempt.logOffset, end)
|
|
160
|
+
: null;
|
|
161
|
+
const input = {
|
|
162
|
+
...attempt,
|
|
163
|
+
options: { exitEvidence: attempt.exitEvidence },
|
|
164
|
+
};
|
|
165
|
+
const output = enrich(input, tail);
|
|
166
|
+
delete output.options;
|
|
167
|
+
return output;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
145
171
|
module.exports = {
|
|
172
|
+
enrichAttemptHistory,
|
|
146
173
|
activityPath,
|
|
147
174
|
appendLifecycle,
|
|
148
175
|
archiveAttempt,
|
|
@@ -323,14 +323,20 @@ function recoverKilledExecution(params = {}) {
|
|
|
323
323
|
}
|
|
324
324
|
}
|
|
325
325
|
const evidence = require('./exit-evidence');
|
|
326
|
+
const logged = evidence.fromLog(
|
|
327
|
+
record.logPath
|
|
328
|
+
? require('./execution-attempt').readAttemptLogTail(record, 65536)
|
|
329
|
+
: null
|
|
330
|
+
);
|
|
326
331
|
const terminalOom =
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
332
|
+
!logged.daemonRestart &&
|
|
333
|
+
(logged.mainOom ||
|
|
334
|
+
((params.oomKilled === true || params.oomKilled === 'true') &&
|
|
335
|
+
evidence.recentOomDelta(params.cgroupMemory, params.finishedAt)));
|
|
336
|
+
record.options.exitEvidence = {
|
|
337
|
+
mainOom: terminalOom,
|
|
338
|
+
daemonRestart: logged.daemonRestart,
|
|
339
|
+
};
|
|
334
340
|
let recoveryLimits = null;
|
|
335
341
|
if (opts.onKillResumeMemory && terminalOom) {
|
|
336
342
|
try {
|
package/src/lib/exit-evidence.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const { spawnSync } = require('child_process');
|
|
3
3
|
const { shellQuote } = require('./isolation-log-utils');
|
|
4
4
|
const MAIN_OOM = 'Exit evidence: main-oom (recent cgroup oom_kill delta)';
|
|
5
|
+
const DOCKER_OOM = 'Exit evidence: main-oom (recent Docker oom event)';
|
|
5
6
|
const DAEMON_RESTART =
|
|
6
7
|
'Exit evidence: docker-daemon-restart (Docker service journal)';
|
|
7
8
|
|
|
@@ -38,6 +39,22 @@ function daemonRestartEvidence(journal, containerId) {
|
|
|
38
39
|
return Boolean(exited && forced);
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
function recentDockerOom(events, finishedAt) {
|
|
43
|
+
const finished = Date.parse(finishedAt) / 1000;
|
|
44
|
+
return (
|
|
45
|
+
Number.isFinite(finished) &&
|
|
46
|
+
String(events || '')
|
|
47
|
+
.split('\n')
|
|
48
|
+
.some((line) => {
|
|
49
|
+
if (!/^\d+$/.test(line.trim())) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const at = Number(line.trim());
|
|
53
|
+
return at > 0 && finished - at >= -1 && finished - at <= 3;
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
function collectExitEvidence(
|
|
42
59
|
{ containerName, sample, finishedAt, exitCode, oomKilled },
|
|
43
60
|
runner
|
|
@@ -52,31 +69,56 @@ function collectExitEvidence(
|
|
|
52
69
|
const local =
|
|
53
70
|
!process.env.DOCKER_HOST || process.env.DOCKER_HOST.startsWith('unix://');
|
|
54
71
|
if (
|
|
55
|
-
local &&
|
|
56
72
|
Number(exitCode) === 137 &&
|
|
73
|
+
containerName &&
|
|
57
74
|
Number.isFinite(Date.parse(finishedAt))
|
|
58
75
|
) {
|
|
59
76
|
const docker = require('./docker-cleanup').getDockerCommand();
|
|
60
77
|
const id = run(docker, ['inspect', '-f', '{{.Id}}', containerName]);
|
|
61
78
|
if (id.success && /^[a-f0-9]{64}$/.test(id.stdout.trim())) {
|
|
62
79
|
const end = Date.parse(finishedAt);
|
|
63
|
-
const journal =
|
|
64
|
-
'
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
const journal = local
|
|
81
|
+
? run('journalctl', [
|
|
82
|
+
'-u',
|
|
83
|
+
'docker.service',
|
|
84
|
+
'--since',
|
|
85
|
+
new Date(end - 60000).toISOString(),
|
|
86
|
+
'--until',
|
|
87
|
+
new Date(end + 1000).toISOString(),
|
|
88
|
+
'--no-pager',
|
|
89
|
+
'-o',
|
|
90
|
+
'cat',
|
|
91
|
+
])
|
|
92
|
+
: { success: false, stdout: '' };
|
|
74
93
|
if (
|
|
75
94
|
journal.success &&
|
|
76
95
|
daemonRestartEvidence(journal.stdout, id.stdout.trim())
|
|
77
96
|
) {
|
|
78
97
|
return DAEMON_RESTART;
|
|
79
98
|
}
|
|
99
|
+
// Query the daemon's bounded event history: works with DooD and remote
|
|
100
|
+
// sockets even after the final kill destroys the sampled cgroup.
|
|
101
|
+
const events = run(docker, [
|
|
102
|
+
'events',
|
|
103
|
+
'--since',
|
|
104
|
+
new Date(end - 60000).toISOString(),
|
|
105
|
+
'--until',
|
|
106
|
+
new Date(end + 1000).toISOString(),
|
|
107
|
+
'--filter',
|
|
108
|
+
`container=${id.stdout.trim()}`,
|
|
109
|
+
'--filter',
|
|
110
|
+
'event=oom',
|
|
111
|
+
'--format',
|
|
112
|
+
'{{.Time}}',
|
|
113
|
+
]);
|
|
114
|
+
if (events.success && recentDockerOom(events.stdout, finishedAt)) {
|
|
115
|
+
return DOCKER_OOM;
|
|
116
|
+
}
|
|
117
|
+
if (process.env.START_DEBUG === '1') {
|
|
118
|
+
console.error(
|
|
119
|
+
`[DEBUG] Exit evidence: container=${id.stdout.trim()} finishedAt=${finishedAt} eventsAvailable=${events.success} oomEvents=${JSON.stringify(events.stdout)} sample=${JSON.stringify(sample || '')}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
80
122
|
}
|
|
81
123
|
}
|
|
82
124
|
if (
|
|
@@ -92,17 +134,23 @@ function collectExitEvidence(
|
|
|
92
134
|
function fromLog(log) {
|
|
93
135
|
const lines = String(log || '').split('\n');
|
|
94
136
|
const last = lines.filter((line) => line.startsWith('Exit evidence:')).at(-1);
|
|
95
|
-
return {
|
|
137
|
+
return {
|
|
138
|
+
mainOom: last === MAIN_OOM || last === DOCKER_OOM,
|
|
139
|
+
daemonRestart: last === DAEMON_RESTART,
|
|
140
|
+
};
|
|
96
141
|
}
|
|
97
142
|
|
|
98
143
|
function buildExitEvidenceSnippet(containerName, quotedLogPath) {
|
|
99
|
-
|
|
144
|
+
const stderr = process.env.START_DEBUG === '1' ? '2>&1' : '2>/dev/null';
|
|
145
|
+
return `${shellQuote(process.execPath)} ${shellQuote(__filename)} ${shellQuote(containerName)} "$__start_command_cgroup" "$__start_command_finished" "$__start_command_exit" "$__start_command_oom" >> ${quotedLogPath} ${stderr}`;
|
|
100
146
|
}
|
|
101
147
|
|
|
102
148
|
module.exports = {
|
|
103
149
|
MAIN_OOM,
|
|
150
|
+
DOCKER_OOM,
|
|
104
151
|
DAEMON_RESTART,
|
|
105
152
|
recentOomDelta,
|
|
153
|
+
recentDockerOom,
|
|
106
154
|
daemonRestartEvidence,
|
|
107
155
|
collectExitEvidence,
|
|
108
156
|
fromLog,
|
package/src/lib/exit-reason.js
CHANGED
|
@@ -206,6 +206,13 @@ function resolveMemoryExhaustion(input) {
|
|
|
206
206
|
memoryExhaustedReason: `cgroup memory.events reported oom_kill=${oomKills}`,
|
|
207
207
|
};
|
|
208
208
|
}
|
|
209
|
+
if (isOomKillOfCommand(input)) {
|
|
210
|
+
return {
|
|
211
|
+
memoryExhausted: true,
|
|
212
|
+
memoryExhaustedReason:
|
|
213
|
+
'Exit-time evidence reported a main-process OOM kill',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
209
216
|
return null;
|
|
210
217
|
}
|
|
211
218
|
|
|
@@ -240,11 +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(
|
|
243
|
+
function readLogTail(
|
|
244
|
+
logPath,
|
|
245
|
+
bytes = LOG_TAIL_BYTES,
|
|
246
|
+
offset = 0,
|
|
247
|
+
end = Infinity
|
|
248
|
+
) {
|
|
244
249
|
let fd;
|
|
245
250
|
try {
|
|
246
251
|
fd = fs.openSync(logPath, 'r');
|
|
247
|
-
const size = fs.fstatSync(fd).size;
|
|
252
|
+
const size = Math.min(fs.fstatSync(fd).size, end);
|
|
248
253
|
const length = Math.min(Math.max(0, size - offset), bytes);
|
|
249
254
|
const buffer = Buffer.alloc(length);
|
|
250
255
|
fs.readSync(fd, buffer, 0, length, size - length);
|
|
@@ -500,7 +500,8 @@ function attachMemoryExhaustion(record, logTail) {
|
|
|
500
500
|
if (!memory) {
|
|
501
501
|
if (
|
|
502
502
|
record.memoryExhaustedReason?.startsWith('Docker reported') ||
|
|
503
|
-
record.memoryExhaustedReason?.startsWith('cgroup memory.events')
|
|
503
|
+
record.memoryExhaustedReason?.startsWith('cgroup memory.events') ||
|
|
504
|
+
record.memoryExhaustedReason?.startsWith('Exit-time evidence')
|
|
504
505
|
) {
|
|
505
506
|
const cleared = Object.assign(
|
|
506
507
|
Object.create(Object.getPrototypeOf(record)),
|
|
@@ -558,6 +559,14 @@ function enrichDetachedStatus(record) {
|
|
|
558
559
|
Object.create(Object.getPrototypeOf(enriched)),
|
|
559
560
|
enriched
|
|
560
561
|
);
|
|
562
|
+
if (record.attemptHistory) {
|
|
563
|
+
copy.attemptHistory = require('./execution-attempt').enrichAttemptHistory(
|
|
564
|
+
record,
|
|
565
|
+
(attempt, tail) =>
|
|
566
|
+
attachMemoryExhaustion(attachExitReason(attempt, tail), tail),
|
|
567
|
+
FATAL_MARKER_TAIL_BYTES
|
|
568
|
+
);
|
|
569
|
+
}
|
|
561
570
|
return readAttemptActivity(copy);
|
|
562
571
|
}
|
|
563
572
|
|
|
@@ -34,6 +34,7 @@ function createFakeCargoBin(tempDir) {
|
|
|
34
34
|
`
|
|
35
35
|
const fs = require('node:fs');
|
|
36
36
|
fs.writeFileSync(process.env.FAKE_CARGO_ARGS_PATH, JSON.stringify(process.argv.slice(2)));
|
|
37
|
+
fs.writeFileSync(process.env.FAKE_CARGO_ARGS_PATH + '.token', process.env.CARGO_REGISTRY_TOKEN || '');
|
|
37
38
|
process.exit(Number(process.env.FAKE_CARGO_EXIT || 0));
|
|
38
39
|
`
|
|
39
40
|
);
|
|
@@ -183,9 +184,12 @@ describe('publish-to-crates script', () => {
|
|
|
183
184
|
'--allow-dirty',
|
|
184
185
|
'--manifest-path',
|
|
185
186
|
join(packageDir, 'Cargo.toml'),
|
|
186
|
-
'--token',
|
|
187
|
-
'test-token',
|
|
188
187
|
]);
|
|
188
|
+
// The deprecated `cargo publish --token` flag must not be used;
|
|
189
|
+
// the token is passed through Cargo's native environment variable.
|
|
190
|
+
expect(readFileSync(`${cargoArgsPath}.token`, 'utf8')).toBe(
|
|
191
|
+
'test-token'
|
|
192
|
+
);
|
|
189
193
|
} finally {
|
|
190
194
|
rmSync(tempDir, { force: true, recursive: true });
|
|
191
195
|
}
|
package/test/regression-191.js
CHANGED
|
@@ -97,15 +97,32 @@ it('manual snapshot override applies limits to the new container at creation', a
|
|
|
97
97
|
fs.rmSync(f.dir, { recursive: true, force: true });
|
|
98
98
|
}
|
|
99
99
|
});
|
|
100
|
-
for (const [exitCode, oomKilled, sample, change] of [
|
|
100
|
+
for (const [exitCode, oomKilled, sample, change, marker] of [
|
|
101
101
|
[137, true, '67108864 66000000 1 1 100 100', true],
|
|
102
102
|
[137, true, '67108864 66000000 1 1 0 100', false],
|
|
103
103
|
[137, false, '67108864 66000000 0 0 0 100', false],
|
|
104
104
|
[1, true, '67108864 66000000 1 1 100 100', false],
|
|
105
|
+
[
|
|
106
|
+
137,
|
|
107
|
+
true,
|
|
108
|
+
'67108864 66000000 1 1 62 62',
|
|
109
|
+
true,
|
|
110
|
+
'Exit evidence: main-oom (recent Docker oom event)',
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
137,
|
|
114
|
+
true,
|
|
115
|
+
'67108864 66000000 1 1 62 62',
|
|
116
|
+
false,
|
|
117
|
+
'Exit evidence: docker-daemon-restart (Docker service journal)',
|
|
118
|
+
],
|
|
105
119
|
]) {
|
|
106
120
|
it(`automatic memory change requires a qualifying OOM (exit=${exitCode}, fresh=${change})`, () => {
|
|
107
121
|
const f = fixture();
|
|
108
122
|
try {
|
|
123
|
+
if (marker) {
|
|
124
|
+
fs.writeFileSync(f.record.logPath, `${marker}\n`);
|
|
125
|
+
}
|
|
109
126
|
const result = recoverKilledExecution({
|
|
110
127
|
store: f.store,
|
|
111
128
|
executionId: f.record.uuid,
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
const { it, expect } = require('bun:test');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const evidence = require('../src/lib/exit-evidence');
|
|
6
|
+
const {
|
|
7
|
+
resolveExitReason,
|
|
8
|
+
resolveMemoryExhaustion,
|
|
9
|
+
} = require('../src/lib/exit-reason');
|
|
10
|
+
const { enrichDetachedStatus } = require('../src/lib/status-formatter');
|
|
11
|
+
const { archiveAttempt } = require('../src/lib/execution-attempt');
|
|
12
|
+
const { ExecutionRecord } = require('../src/lib/execution-store');
|
|
13
|
+
|
|
14
|
+
const finishedAt = '1970-01-01T00:01:40.592Z';
|
|
15
|
+
const id = 'a'.repeat(64);
|
|
16
|
+
const facts = {
|
|
17
|
+
containerName: 'task',
|
|
18
|
+
sample: '3135369216 3135373312 1 1 62 62',
|
|
19
|
+
finishedAt,
|
|
20
|
+
exitCode: 137,
|
|
21
|
+
oomKilled: true,
|
|
22
|
+
};
|
|
23
|
+
function collect(events, overrides = {}, journal = '') {
|
|
24
|
+
const calls = [];
|
|
25
|
+
const line = evidence.collectExitEvidence(
|
|
26
|
+
{ ...facts, ...overrides },
|
|
27
|
+
(cmd, args) => {
|
|
28
|
+
calls.push([cmd, args]);
|
|
29
|
+
if (args[0] === 'inspect') {
|
|
30
|
+
return { success: true, stdout: id };
|
|
31
|
+
}
|
|
32
|
+
if (args[0] === 'events') {
|
|
33
|
+
return { success: true, stdout: events };
|
|
34
|
+
}
|
|
35
|
+
return { success: Boolean(journal), stdout: journal };
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
return { line, calls };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
it('attributes the final Docker OOM event despite a 38-second-old child sample', () => {
|
|
42
|
+
const { line, calls } = collect('62\n99\n');
|
|
43
|
+
expect(line).toBe('Exit evidence: main-oom (recent Docker oom event)');
|
|
44
|
+
const args = calls.find(([, args]) => args[0] === 'events')[1];
|
|
45
|
+
expect(args).toContain(`container=${id}`);
|
|
46
|
+
expect(args).toContain('event=oom');
|
|
47
|
+
expect(args).toContain('1970-01-01T00:00:40.592Z');
|
|
48
|
+
expect(args).toContain('1970-01-01T00:01:41.592Z');
|
|
49
|
+
const input = { exitCode: 137, oomKilled: true, logTail: line };
|
|
50
|
+
expect(resolveExitReason(input)).toBe(
|
|
51
|
+
'memory-exhaustion (cgroup-oom-killer)'
|
|
52
|
+
);
|
|
53
|
+
expect(resolveMemoryExhaustion(input).memoryExhausted).toBe(true);
|
|
54
|
+
expect(
|
|
55
|
+
resolveMemoryExhaustion({ ...input, oomKilled: false }).memoryExhausted
|
|
56
|
+
).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('uses daemon events over remote Docker connections without a host journal', () => {
|
|
60
|
+
const previous = process.env.DOCKER_HOST;
|
|
61
|
+
process.env.DOCKER_HOST = 'tcp://remote:2376';
|
|
62
|
+
try {
|
|
63
|
+
const { line, calls } = collect('100');
|
|
64
|
+
expect(evidence.fromLog(line).mainOom).toBe(true);
|
|
65
|
+
expect(calls.some(([cmd]) => cmd === 'journalctl')).toBe(false);
|
|
66
|
+
} finally {
|
|
67
|
+
if (previous === undefined) {
|
|
68
|
+
delete process.env.DOCKER_HOST;
|
|
69
|
+
} else {
|
|
70
|
+
process.env.DOCKER_HOST = previous;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects old, future and malformed events and ordinary exits', () => {
|
|
76
|
+
for (const events of ['62', '96', '102', 'nope', '', '0', 'Infinity']) {
|
|
77
|
+
expect(evidence.fromLog(collect(events).line).mainOom).toBe(false);
|
|
78
|
+
}
|
|
79
|
+
for (const exitCode of [0, 1, 139, -1]) {
|
|
80
|
+
const { line, calls } = collect('99', { exitCode });
|
|
81
|
+
expect(evidence.fromLog(line).mainOom).toBe(false);
|
|
82
|
+
expect(calls.some(([, args]) => args[0] === 'events')).toBe(false);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('keeps daemon restart evidence ahead of even a recent OOM event', () => {
|
|
87
|
+
const journal = `Main process exited\nContainer failed to exit - using the force container=${id}`;
|
|
88
|
+
const { line } = collect('99', {}, journal);
|
|
89
|
+
expect(line).toBe(evidence.DAEMON_RESTART);
|
|
90
|
+
expect(
|
|
91
|
+
resolveMemoryExhaustion({ exitCode: 137, oomKilled: true, logTail: line })
|
|
92
|
+
).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('archives attributed evidence before a resume clears current options', () => {
|
|
96
|
+
const record = new ExecutionRecord({
|
|
97
|
+
command: 'work',
|
|
98
|
+
status: 'executed',
|
|
99
|
+
options: {
|
|
100
|
+
exitEvidence: { mainOom: true, daemonRestart: false },
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
archiveAttempt(record);
|
|
104
|
+
expect(record.attemptHistory[0].exitEvidence).toEqual(
|
|
105
|
+
record.options.exitEvidence
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('recomputes history independently while the current attempt is executing', () => {
|
|
110
|
+
const record = new ExecutionRecord({
|
|
111
|
+
command: 'work',
|
|
112
|
+
status: 'executing',
|
|
113
|
+
options: {},
|
|
114
|
+
});
|
|
115
|
+
record.attemptHistory = [
|
|
116
|
+
{
|
|
117
|
+
status: 'executed',
|
|
118
|
+
exitCode: 137,
|
|
119
|
+
oomKilled: true,
|
|
120
|
+
exitReason: 'signal (SIGKILL; cause unknown)',
|
|
121
|
+
exitEvidence: { mainOom: true, daemonRestart: false },
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
status: 'executed',
|
|
125
|
+
exitCode: 1,
|
|
126
|
+
oomKilled: true,
|
|
127
|
+
exitReason: 'memory-exhaustion (cgroup-oom-killer)',
|
|
128
|
+
memoryExhausted: true,
|
|
129
|
+
memoryExhaustedReason: 'Docker reported State.OOMKilled=true',
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
const result = enrichDetachedStatus(record);
|
|
133
|
+
expect(result.attemptHistory[0].exitReason).toBe(
|
|
134
|
+
'memory-exhaustion (cgroup-oom-killer)'
|
|
135
|
+
);
|
|
136
|
+
expect(result.attemptHistory[0].memoryExhausted).toBe(true);
|
|
137
|
+
expect(result.attemptHistory[1].exitReason).toBeUndefined();
|
|
138
|
+
expect(result.attemptHistory[1].memoryExhausted).toBeUndefined();
|
|
139
|
+
expect(record.attemptHistory[0].exitReason).toBe(
|
|
140
|
+
'signal (SIGKILL; cause unknown)'
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('reads only each archived attempt log range and never leaks current fatal output', () => {
|
|
145
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'issue-209-'));
|
|
146
|
+
try {
|
|
147
|
+
const first = 'Exit evidence: main-oom (recent Docker oom event)\n';
|
|
148
|
+
const second = 'ordinary exit\n';
|
|
149
|
+
const logPath = path.join(dir, 'run.log');
|
|
150
|
+
fs.writeFileSync(
|
|
151
|
+
logPath,
|
|
152
|
+
`${first + second}FATAL ERROR: JavaScript heap out of memory\n`
|
|
153
|
+
);
|
|
154
|
+
const record = new ExecutionRecord({
|
|
155
|
+
command: 'work',
|
|
156
|
+
status: 'executed',
|
|
157
|
+
logPath,
|
|
158
|
+
options: {},
|
|
159
|
+
});
|
|
160
|
+
record.exitCode = 1;
|
|
161
|
+
record.attempt = {
|
|
162
|
+
number: 3,
|
|
163
|
+
logOffset: Buffer.byteLength(first + second),
|
|
164
|
+
};
|
|
165
|
+
record.attemptHistory = [
|
|
166
|
+
{
|
|
167
|
+
status: 'executed',
|
|
168
|
+
number: 1,
|
|
169
|
+
logOffset: 0,
|
|
170
|
+
exitCode: 137,
|
|
171
|
+
oomKilled: true,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
status: 'executed',
|
|
175
|
+
number: 2,
|
|
176
|
+
logOffset: Buffer.byteLength(first),
|
|
177
|
+
exitCode: 1,
|
|
178
|
+
oomKilled: true,
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
const result = enrichDetachedStatus(record);
|
|
182
|
+
expect(result.attemptHistory[0].exitReason).toBe(
|
|
183
|
+
'memory-exhaustion (cgroup-oom-killer)'
|
|
184
|
+
);
|
|
185
|
+
expect(result.attemptHistory[1].memoryExhausted).toBeUndefined();
|
|
186
|
+
expect(result.exitReason).toBe('memory-exhaustion (v8-heap-limit)');
|
|
187
|
+
record.attemptHistory[1].logOffset = null;
|
|
188
|
+
expect(enrichDetachedStatus(record).attemptHistory[0].exitReason).toBe(
|
|
189
|
+
'signal (SIGKILL; cause unknown)'
|
|
190
|
+
);
|
|
191
|
+
} finally {
|
|
192
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('finalizes and preserves Docker event attribution after the log is removed', () => {
|
|
197
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'issue-209-finalize-'));
|
|
198
|
+
try {
|
|
199
|
+
const { ExecutionStore } = require('../src/lib/execution-store');
|
|
200
|
+
const {
|
|
201
|
+
finalizeDetachedExecution,
|
|
202
|
+
} = require('../src/lib/detached-finalize');
|
|
203
|
+
const store = new ExecutionStore({ appFolder: dir, useLinks: false });
|
|
204
|
+
const logPath = path.join(dir, 'run.log');
|
|
205
|
+
fs.writeFileSync(logPath, `${evidence.DOCKER_OOM}\n`);
|
|
206
|
+
const record = new ExecutionRecord({
|
|
207
|
+
command: 'work',
|
|
208
|
+
logPath,
|
|
209
|
+
options: {},
|
|
210
|
+
});
|
|
211
|
+
store.save(record);
|
|
212
|
+
finalizeDetachedExecution({
|
|
213
|
+
store,
|
|
214
|
+
executionId: record.uuid,
|
|
215
|
+
...facts,
|
|
216
|
+
running: false,
|
|
217
|
+
});
|
|
218
|
+
const current = store.get(record.uuid);
|
|
219
|
+
expect(current.options.exitEvidence.mainOom).toBe(true);
|
|
220
|
+
expect(current.exitReason).toBe('memory-exhaustion (cgroup-oom-killer)');
|
|
221
|
+
const stored = current.options.exitEvidence;
|
|
222
|
+
current.options.exitEvidence = null;
|
|
223
|
+
expect(enrichDetachedStatus(current).memoryExhausted).toBe(true);
|
|
224
|
+
current.options.exitEvidence = { mainOom: false, daemonRestart: false };
|
|
225
|
+
expect(enrichDetachedStatus(current).memoryExhausted).toBeUndefined();
|
|
226
|
+
current.options.exitEvidence = stored;
|
|
227
|
+
const message =
|
|
228
|
+
require('../src/lib/docker-cleanup').buildAttachedDockerKeptMessage({
|
|
229
|
+
containerName: 'task',
|
|
230
|
+
exitCode: 137,
|
|
231
|
+
oomKilled: true,
|
|
232
|
+
logPath,
|
|
233
|
+
});
|
|
234
|
+
expect(message).toContain('reports it was OOM-killed');
|
|
235
|
+
fs.unlinkSync(logPath);
|
|
236
|
+
expect(enrichDetachedStatus(current).memoryExhausted).toBe(true);
|
|
237
|
+
} finally {
|
|
238
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
});
|
package/test/shared-golden.js
CHANGED
|
@@ -5,12 +5,36 @@ const { resolve } = require('node:path');
|
|
|
5
5
|
const { parseArgs } = require('../src/lib/args-parser');
|
|
6
6
|
const { buildDockerRuntimeArgs } = require('../src/lib/docker-runtime-args');
|
|
7
7
|
const { ExecutionRecord } = require('../src/lib/execution-store');
|
|
8
|
-
const {
|
|
8
|
+
const {
|
|
9
|
+
formatRecord,
|
|
10
|
+
enrichDetachedStatus,
|
|
11
|
+
} = require('../src/lib/status-formatter');
|
|
9
12
|
const fixtures = JSON.parse(
|
|
10
13
|
readFileSync(resolve(__dirname, '../../parity/fixtures/contracts.json'))
|
|
11
14
|
);
|
|
12
15
|
|
|
13
16
|
describe('shared JavaScript and Rust golden contracts', () => {
|
|
17
|
+
for (const fixture of fixtures['attempt-status-output']) {
|
|
18
|
+
it(`attempt diagnostics: ${fixture.name}`, () => {
|
|
19
|
+
const record = ExecutionRecord.fromObject(fixture.record);
|
|
20
|
+
const output = JSON.parse(
|
|
21
|
+
formatRecord(enrichDetachedStatus(record), 'json')
|
|
22
|
+
);
|
|
23
|
+
assert.deepEqual(output.attemptHistory, fixture.attemptHistory);
|
|
24
|
+
assert.deepEqual(record.toObject(), fixture.record);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
for (const fixture of fixtures['docker-oom-event']) {
|
|
28
|
+
it(`Docker OOM event: ${fixture.name}`, () => {
|
|
29
|
+
assert.equal(
|
|
30
|
+
require('../src/lib/exit-evidence').recentDockerOom(
|
|
31
|
+
fixture.events,
|
|
32
|
+
fixture.finishedAt
|
|
33
|
+
),
|
|
34
|
+
fixture.recent
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
14
38
|
for (const fixture of fixtures['cli-parsing']) {
|
|
15
39
|
it(`CLI parsing: ${fixture.name}`, () => {
|
|
16
40
|
if (fixture.error) {
|