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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.37.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
## 0.37.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- c67c92f: Observe Docker CPU limits applied after launch, restore the remembered base on lift and resume, and recover CPU penalty monitors after crashes, zombie/reused-PID locks and transient tick failures.
|
|
14
|
+
|
|
3
15
|
## 0.37.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { spawnSync } = require('child_process');
|
|
5
|
-
const {
|
|
5
|
+
const {
|
|
6
|
+
initialState,
|
|
7
|
+
evaluate,
|
|
8
|
+
observeBase,
|
|
9
|
+
transitionLog,
|
|
10
|
+
} = require('./cpu-penalty');
|
|
6
11
|
const { LockManager } = require('./store-lock');
|
|
7
12
|
const { shellQuote } = require('./isolation-log-utils');
|
|
8
|
-
const {
|
|
13
|
+
const {
|
|
14
|
+
cpuCount,
|
|
15
|
+
cpuUpdateArgs,
|
|
16
|
+
normalizeResourceLimits,
|
|
17
|
+
readDockerResourceLimits,
|
|
18
|
+
} = require('./docker-resource-limits');
|
|
9
19
|
function statePath(record) {
|
|
10
20
|
return `${record.logPath}.cpu-penalty.json`;
|
|
11
21
|
}
|
|
@@ -40,9 +50,21 @@ function publicState(state) {
|
|
|
40
50
|
}
|
|
41
51
|
function run(command, args) {
|
|
42
52
|
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
43
|
-
return {
|
|
53
|
+
return {
|
|
54
|
+
success: result.status === 0,
|
|
55
|
+
stdout: result.stdout || '',
|
|
56
|
+
stderr: result.stderr || '',
|
|
57
|
+
};
|
|
44
58
|
}
|
|
45
|
-
async function monitor(
|
|
59
|
+
async function monitor(
|
|
60
|
+
uuid,
|
|
61
|
+
name,
|
|
62
|
+
attemptNumber,
|
|
63
|
+
standalone,
|
|
64
|
+
dependencies = {}
|
|
65
|
+
) {
|
|
66
|
+
const filesystem = dependencies.fs || fs;
|
|
67
|
+
const runner = dependencies.run || run;
|
|
46
68
|
const { ExecutionStore } = require('./execution-store');
|
|
47
69
|
const store = uuid
|
|
48
70
|
? new ExecutionStore(
|
|
@@ -60,36 +82,40 @@ async function monitor(uuid, name, attemptNumber, standalone) {
|
|
|
60
82
|
return;
|
|
61
83
|
}
|
|
62
84
|
const config = record.options.cpuPenaltyConfig;
|
|
63
|
-
|
|
85
|
+
if ((record.attempt?.number || 1) !== Number(attemptNumber)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const lock = new LockManager(`${statePath(record)}.lock`, filesystem, true);
|
|
64
89
|
if (!lock.acquire(100)) {
|
|
65
90
|
return;
|
|
66
91
|
}
|
|
67
92
|
const docker = require('./docker-cleanup').getDockerCommand();
|
|
68
|
-
|
|
69
|
-
record.options.baseResourceLimits || record.options.resourceLimits
|
|
93
|
+
let baseFlags = normalizeResourceLimits(
|
|
94
|
+
record.options.baseResourceLimits || record.options.resourceLimits
|
|
95
|
+
);
|
|
70
96
|
const baseCpus = cpuCount(baseFlags);
|
|
71
97
|
let state = initialState(baseCpus, Date.now(), readState(record));
|
|
72
98
|
const interval = Math.min(
|
|
73
99
|
30000,
|
|
74
100
|
Math.max(50, Math.min(config.triggerWindowMs, config.releaseWindowMs) / 8)
|
|
75
101
|
);
|
|
76
|
-
const publish = () => {
|
|
102
|
+
const publish = (value = state) => {
|
|
77
103
|
const file = statePath(record),
|
|
78
104
|
tmp = `${file}.${process.pid}.tmp`;
|
|
79
105
|
try {
|
|
80
|
-
|
|
106
|
+
filesystem.writeFileSync(
|
|
81
107
|
tmp,
|
|
82
108
|
JSON.stringify({
|
|
83
|
-
...publicState(
|
|
109
|
+
...publicState(value),
|
|
84
110
|
containerName: name,
|
|
85
111
|
attemptNumber: Number(attemptNumber),
|
|
86
112
|
updatedAt: Date.now(),
|
|
87
113
|
})
|
|
88
114
|
);
|
|
89
|
-
|
|
115
|
+
filesystem.renameSync(tmp, file);
|
|
90
116
|
} finally {
|
|
91
117
|
try {
|
|
92
|
-
|
|
118
|
+
filesystem.unlinkSync(tmp);
|
|
93
119
|
} catch {
|
|
94
120
|
/* Optional diagnostics are best effort. */
|
|
95
121
|
}
|
|
@@ -97,95 +123,188 @@ async function monitor(uuid, name, attemptNumber, standalone) {
|
|
|
97
123
|
};
|
|
98
124
|
const log = (text) => {
|
|
99
125
|
try {
|
|
100
|
-
|
|
126
|
+
filesystem.appendFileSync(record.logPath, `${text}\n`);
|
|
101
127
|
} catch {
|
|
102
128
|
/* Optional diagnostics are best effort. */
|
|
103
129
|
}
|
|
104
130
|
};
|
|
105
|
-
const
|
|
131
|
+
const debug = (error) => {
|
|
132
|
+
if (process.env.START_DEBUG === '1') {
|
|
133
|
+
const message = `[DEBUG] CPU monitor: ${error.stack || error}`;
|
|
134
|
+
console.error(message);
|
|
135
|
+
log(message);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
let stopping = false,
|
|
139
|
+
wake;
|
|
140
|
+
const stop = () => {
|
|
141
|
+
stopping = true;
|
|
142
|
+
wake?.();
|
|
143
|
+
};
|
|
144
|
+
process.on('SIGTERM', stop);
|
|
145
|
+
process.on('SIGINT', stop);
|
|
106
146
|
try {
|
|
107
|
-
|
|
108
|
-
while (true) {
|
|
109
|
-
const current = store ? store.get(uuid) : record;
|
|
110
|
-
if (
|
|
111
|
-
!current ||
|
|
112
|
-
current.options.sessionName !== name ||
|
|
113
|
-
(current.attempt?.number || 1) !== Number(attemptNumber)
|
|
114
|
-
) {
|
|
115
|
-
break;
|
|
116
|
-
}
|
|
117
|
-
const live = run(docker, ['inspect', '-f', '{{.State.Running}}', name]);
|
|
118
|
-
if (live.success && live.stdout.trim() === 'false') {
|
|
119
|
-
break;
|
|
120
|
-
}
|
|
121
|
-
if (!live.success && Date.now() - started > 30000) {
|
|
122
|
-
break;
|
|
123
|
-
}
|
|
124
|
-
const info = run(docker, ['info', '--format', '{{json .}}']);
|
|
125
|
-
const stats = run(docker, [
|
|
126
|
-
'stats',
|
|
127
|
-
'--no-stream',
|
|
128
|
-
'--format',
|
|
129
|
-
'{{json .}}',
|
|
130
|
-
]);
|
|
131
|
-
let daemonCpus = NaN,
|
|
132
|
-
cores = NaN;
|
|
147
|
+
while (!stopping) {
|
|
133
148
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
149
|
+
const current = store ? store.get(uuid) : record;
|
|
150
|
+
if (
|
|
151
|
+
!current ||
|
|
152
|
+
current.options.sessionName !== name ||
|
|
153
|
+
(current.attempt?.number || 1) !== Number(attemptNumber)
|
|
154
|
+
) {
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
const live = runner(docker, [
|
|
158
|
+
'inspect',
|
|
159
|
+
'-f',
|
|
160
|
+
'{{.State.Running}}',
|
|
161
|
+
name,
|
|
162
|
+
]);
|
|
163
|
+
if (live.success && live.stdout.trim() === 'false') {
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
if (
|
|
167
|
+
!live.success &&
|
|
168
|
+
/No such (object|container)/i.test(live.stderr || '')
|
|
169
|
+
) {
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
const observed = readDockerResourceLimits(name, runner);
|
|
173
|
+
if (observed !== null) {
|
|
174
|
+
baseFlags = observed;
|
|
175
|
+
state = observeBase(state, cpuCount(observed), Date.now());
|
|
136
176
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
177
|
+
const info = runner(docker, ['info', '--format', '{{json .}}']);
|
|
178
|
+
const stats = runner(docker, [
|
|
179
|
+
'stats',
|
|
180
|
+
'--no-stream',
|
|
181
|
+
'--format',
|
|
182
|
+
'{{json .}}',
|
|
183
|
+
]);
|
|
184
|
+
let daemonCpus = NaN,
|
|
185
|
+
cores = NaN;
|
|
186
|
+
try {
|
|
187
|
+
if (info.success) {
|
|
188
|
+
daemonCpus = JSON.parse(info.stdout).NCPU;
|
|
189
|
+
}
|
|
190
|
+
if (stats.success && live.success && observed !== null) {
|
|
191
|
+
const row = stats.stdout
|
|
192
|
+
.trim()
|
|
193
|
+
.split('\n')
|
|
194
|
+
.map((s) => JSON.parse(s))
|
|
195
|
+
.find((s) => s.Name === name);
|
|
196
|
+
if (row && /^\d+(?:\.\d+)?%$/.test(row.CPUPerc)) {
|
|
197
|
+
cores = Number(row.CPUPerc.slice(0, -1)) / 100;
|
|
198
|
+
}
|
|
145
199
|
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
debug(error);
|
|
146
202
|
}
|
|
147
|
-
|
|
148
|
-
|
|
203
|
+
debug(
|
|
204
|
+
`sample: phase=${state.phase} base=${state.baseCpus} limit=${state.limitCpus} cores=${cores} daemon=${daemonCpus}`
|
|
205
|
+
);
|
|
206
|
+
const decision = evaluate(
|
|
207
|
+
state,
|
|
208
|
+
{
|
|
209
|
+
now: Date.now(),
|
|
210
|
+
cores,
|
|
211
|
+
daemonCpus,
|
|
212
|
+
maxGapMs: Math.max(5000, interval * 3),
|
|
213
|
+
},
|
|
214
|
+
config
|
|
215
|
+
);
|
|
216
|
+
if (decision.action) {
|
|
217
|
+
const action = decision.action;
|
|
218
|
+
// Persist the base and intent before applying a cap. If this fails,
|
|
219
|
+
// leave Docker alone; a killed monitor can always recover its base.
|
|
220
|
+
if (action.kind === 'apply') {
|
|
221
|
+
publish(decision.state);
|
|
222
|
+
}
|
|
223
|
+
const update = runner(docker, [
|
|
224
|
+
'update',
|
|
225
|
+
...cpuUpdateArgs(baseFlags, action.cpus),
|
|
226
|
+
name,
|
|
227
|
+
]);
|
|
228
|
+
if (update.success) {
|
|
229
|
+
state = decision.state;
|
|
230
|
+
log(transitionLog(action, config));
|
|
231
|
+
} else {
|
|
232
|
+
debug(update.stderr || 'docker update failed');
|
|
233
|
+
state = {
|
|
234
|
+
...decision.state,
|
|
235
|
+
phase: state.phase,
|
|
236
|
+
since: state.since,
|
|
237
|
+
limitCpus: state.limitCpus,
|
|
238
|
+
penaltyCount: state.penaltyCount,
|
|
239
|
+
samples: [],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
state = decision.state;
|
|
244
|
+
}
|
|
245
|
+
publish();
|
|
246
|
+
} catch (error) {
|
|
247
|
+
state.samples = [];
|
|
248
|
+
debug(`tick failed: ${error.stack || error}`);
|
|
149
249
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
{
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
250
|
+
await new Promise((resolve) => {
|
|
251
|
+
const timer = globalThis.setTimeout(resolve, interval);
|
|
252
|
+
wake = () => {
|
|
253
|
+
globalThis.clearTimeout(timer);
|
|
254
|
+
resolve();
|
|
255
|
+
};
|
|
256
|
+
if (stopping) {
|
|
257
|
+
wake();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
try {
|
|
263
|
+
const current = store ? store.get(uuid) : record;
|
|
264
|
+
if (
|
|
265
|
+
state.phase === 'penalized' &&
|
|
266
|
+
current?.options.sessionName === name &&
|
|
267
|
+
(current.attempt?.number || 1) === Number(attemptNumber)
|
|
268
|
+
) {
|
|
269
|
+
const live = runner(docker, [
|
|
270
|
+
'inspect',
|
|
271
|
+
'-f',
|
|
272
|
+
'{{.State.Running}}',
|
|
165
273
|
name,
|
|
166
274
|
]);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
275
|
+
const observed = readDockerResourceLimits(name, runner);
|
|
276
|
+
const info = runner(docker, ['info', '--format', '{{json .}}']);
|
|
277
|
+
const capacity = info.success ? JSON.parse(info.stdout).NCPU : NaN;
|
|
278
|
+
if (
|
|
279
|
+
live.success &&
|
|
280
|
+
live.stdout.trim() === 'true' &&
|
|
281
|
+
observed !== null &&
|
|
282
|
+
cpuCount(observed) === state.limitCpus &&
|
|
283
|
+
capacity > 0
|
|
284
|
+
) {
|
|
285
|
+
const cpus = Math.min(state.baseCpus || capacity, capacity);
|
|
286
|
+
const update = runner(docker, [
|
|
287
|
+
'update',
|
|
288
|
+
...cpuUpdateArgs(observed, cpus),
|
|
289
|
+
name,
|
|
290
|
+
]);
|
|
291
|
+
if (update.success) {
|
|
292
|
+
state = {
|
|
293
|
+
...state,
|
|
294
|
+
phase: 'observing',
|
|
295
|
+
limitCpus: cpus,
|
|
296
|
+
since: Date.now(),
|
|
297
|
+
};
|
|
298
|
+
log(`[start] CPU penalty lifted: ${cpus} CPUs (monitor stopped)`);
|
|
299
|
+
publish();
|
|
300
|
+
}
|
|
181
301
|
}
|
|
182
|
-
} else {
|
|
183
|
-
state = decision.state;
|
|
184
302
|
}
|
|
185
|
-
|
|
186
|
-
|
|
303
|
+
} catch (error) {
|
|
304
|
+
debug(error);
|
|
187
305
|
}
|
|
188
|
-
|
|
306
|
+
process.off('SIGTERM', stop);
|
|
307
|
+
process.off('SIGINT', stop);
|
|
189
308
|
lock.release();
|
|
190
309
|
}
|
|
191
310
|
}
|
|
@@ -204,7 +323,9 @@ function startSnippet(uuid, name, attempt = 1, options = null) {
|
|
|
204
323
|
if (!uuid && !standalone) {
|
|
205
324
|
return '';
|
|
206
325
|
}
|
|
207
|
-
|
|
326
|
+
const command = `${shellQuote(process.execPath)} ${shellQuote(__filename)} ${shellQuote(uuid || '')} ${shellQuote(name)} ${Number(attempt)} ${shellQuote(standalone || '')}`;
|
|
327
|
+
// wait reaps a crashed child even in containers whose PID 1 never reaps.
|
|
328
|
+
return `( __start_cpu_child=; trap 'if [ -n "$__start_cpu_child" ]; then kill "$__start_cpu_child" 2>/dev/null; wait "$__start_cpu_child" 2>/dev/null; fi; exit 0' TERM INT HUP; while :; do ${command} >/dev/null 2>&1 & __start_cpu_child=$!; wait "$__start_cpu_child"; __start_cpu_code=$?; [ "$__start_cpu_code" -eq 0 ] && break; sleep 1 & __start_cpu_child=$!; wait "$__start_cpu_child"; done ) & __start_cpu_monitor=$!`;
|
|
208
329
|
}
|
|
209
330
|
function stopSnippet() {
|
|
210
331
|
return 'if [ -n "$__start_cpu_monitor" ]; then kill "$__start_cpu_monitor" 2>/dev/null; wait "$__start_cpu_monitor" 2>/dev/null; fi';
|
|
@@ -219,6 +340,7 @@ module.exports = {
|
|
|
219
340
|
};
|
|
220
341
|
if (require.main === module) {
|
|
221
342
|
monitor(...process.argv.slice(2)).catch((error) => {
|
|
343
|
+
process.exitCode = 1;
|
|
222
344
|
if (process.env.START_DEBUG === '1') {
|
|
223
345
|
console.error(`[DEBUG] CPU monitor: ${error.stack}`);
|
|
224
346
|
}
|
package/src/lib/cpu-penalty.js
CHANGED
|
@@ -70,10 +70,13 @@ function validate(options) {
|
|
|
70
70
|
options.cpuPenaltyConfig = { ...DEFAULTS, ...options.cpuPenaltyConfig };
|
|
71
71
|
}
|
|
72
72
|
function initialState(baseCpus = null, now = Date.now(), saved = null) {
|
|
73
|
+
baseCpus =
|
|
74
|
+
saved && Object.hasOwn(saved, 'baseCpus') ? saved.baseCpus : baseCpus;
|
|
73
75
|
return {
|
|
74
76
|
phase: saved?.phase === 'penalized' ? 'penalized' : 'observing',
|
|
75
77
|
since: saved?.phase === 'penalized' ? saved.since : now,
|
|
76
|
-
limitCpus:
|
|
78
|
+
limitCpus:
|
|
79
|
+
saved && Object.hasOwn(saved, 'limitCpus') ? saved.limitCpus : baseCpus,
|
|
77
80
|
baseCpus,
|
|
78
81
|
penaltyCount: saved?.penaltyCount || 0,
|
|
79
82
|
penalizedMs: saved?.penalizedMs || 0,
|
|
@@ -82,6 +85,30 @@ function initialState(baseCpus = null, now = Date.now(), saved = null) {
|
|
|
82
85
|
capacity: null,
|
|
83
86
|
};
|
|
84
87
|
}
|
|
88
|
+
// A quota different from our cap is a supervisor override, not our penalty.
|
|
89
|
+
function observeBase(state, baseCpus, now) {
|
|
90
|
+
if (baseCpus === state.limitCpus) {
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
...state,
|
|
95
|
+
phase: 'observing',
|
|
96
|
+
baseCpus,
|
|
97
|
+
limitCpus: baseCpus,
|
|
98
|
+
since: now,
|
|
99
|
+
samples: [],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function transitionLog(action, config) {
|
|
103
|
+
const applying = action.kind === 'apply';
|
|
104
|
+
const window = applying ? config.triggerWindowMs : config.releaseWindowMs;
|
|
105
|
+
const duration = window % 60000 === 0 ? `${window / 60000}m` : `${window}ms`;
|
|
106
|
+
const measured =
|
|
107
|
+
action.average === null || action.average === undefined
|
|
108
|
+
? 'capacity changed'
|
|
109
|
+
: `${action.average.toFixed(2)} of ${applying ? action.capacity : config.cpus} CPUs for ${duration}`;
|
|
110
|
+
return `[start] CPU penalty ${applying ? 'applied' : 'lifted'}: ${action.cpus} CPUs (${measured})`;
|
|
111
|
+
}
|
|
85
112
|
function average(samples, now, window) {
|
|
86
113
|
const start = now - window;
|
|
87
114
|
if (
|
|
@@ -170,6 +197,8 @@ module.exports = {
|
|
|
170
197
|
validate,
|
|
171
198
|
duration,
|
|
172
199
|
initialState,
|
|
200
|
+
observeBase,
|
|
201
|
+
transitionLog,
|
|
173
202
|
average,
|
|
174
203
|
evaluate,
|
|
175
204
|
};
|
|
@@ -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
|
|