start-command 0.37.0 → 0.37.2
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/cpu-penalty-monitor.js +209 -87
- package/src/lib/cpu-penalty.js +30 -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/resume-resources.js +15 -3
- package/src/lib/status-formatter.js +10 -1
- package/src/lib/store-lock.js +54 -3
- package/test/regression-191.js +18 -1
- package/test/regression-207.js +249 -0
- package/test/regression-209.js +240 -0
- package/test/shared-golden.js +48 -1
|
@@ -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) {
|
|
@@ -51,4 +75,27 @@ describe('shared JavaScript and Rust golden contracts', () => {
|
|
|
51
75
|
);
|
|
52
76
|
}
|
|
53
77
|
});
|
|
78
|
+
for (const fixture of fixtures['cpu-penalty-state']) {
|
|
79
|
+
it(`CPU state: ${fixture.name}`, () => {
|
|
80
|
+
const {
|
|
81
|
+
initialState,
|
|
82
|
+
observeBase,
|
|
83
|
+
evaluate,
|
|
84
|
+
} = require('../src/lib/cpu-penalty');
|
|
85
|
+
let state = initialState(fixture.baseCpus, 0, fixture.saved);
|
|
86
|
+
for (const step of fixture.steps) {
|
|
87
|
+
state = observeBase(state, step.baseCpus, step.now);
|
|
88
|
+
const decision = evaluate(
|
|
89
|
+
state,
|
|
90
|
+
{ now: step.now, cores: step.cores, daemonCpus: 6, maxGapMs: 50 },
|
|
91
|
+
fixture.config
|
|
92
|
+
);
|
|
93
|
+
assert.equal(decision.action?.kind || null, step.action);
|
|
94
|
+
state = decision.state;
|
|
95
|
+
}
|
|
96
|
+
for (const [key, value] of Object.entries(fixture.expected)) {
|
|
97
|
+
assert.deepEqual(state[key], value, key);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
54
101
|
});
|