start-command 0.35.0 → 0.35.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 +16 -0
- package/package.json +2 -2
- package/src/lib/args-parser.js +1 -0
- package/src/lib/cgroup-memory.js +283 -0
- package/src/lib/detached-finalize.js +17 -2
- package/src/lib/docker-cleanup.js +32 -13
- package/src/lib/docker-recovery-options.js +31 -3
- package/src/lib/docker-runtime-args.js +10 -4
- package/src/lib/execution-control.js +36 -0
- package/src/lib/execution-recovery.js +116 -22
- package/src/lib/execution-store.js +9 -0
- package/src/lib/exit-reason.js +67 -6
- package/src/lib/recovery-delay.js +151 -0
- package/src/lib/status-formatter.js +16 -0
- package/src/lib/usage.js +2 -0
- package/test/docker-runtime-options.js +2 -0
- package/test/regression-174.js +2 -0
- package/test/regression-176.js +2 -2
- package/test/regression-178.js +232 -0
- package/test/regression-180.js +196 -0
- package/test/regression-181.js +338 -0
- package/test/regression-182.js +439 -0
|
@@ -302,6 +302,23 @@ function restoreStopRequested(store, record, previous) {
|
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Whether a failed docker control command hit a container that has already
|
|
307
|
+
* exited while launch-time recovery is configured: the stop marker alone then
|
|
308
|
+
* cancels the resume the watcher may still be waiting to make.
|
|
309
|
+
* @param {object} record - Execution record
|
|
310
|
+
* @param {object} result - Failed control command result
|
|
311
|
+
* @returns {boolean} Whether the stop marker should stand
|
|
312
|
+
*/
|
|
313
|
+
function isPendingRecoveryStop(record, result) {
|
|
314
|
+
const options = record.options || {};
|
|
315
|
+
if (options.isolated !== 'docker' || !options.onKillResume) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
const detail = `${result.stderr || ''} ${result.error || ''}`;
|
|
319
|
+
return /is not running/i.test(detail);
|
|
320
|
+
}
|
|
321
|
+
|
|
305
322
|
function controlExecution(store, identifier, action, runner = runCommand) {
|
|
306
323
|
if (!store) {
|
|
307
324
|
return { success: false, error: 'Execution tracking is disabled.' };
|
|
@@ -325,6 +342,25 @@ function controlExecution(store, identifier, action, runner = runCommand) {
|
|
|
325
342
|
// which exits 137 just like the OOM killer.
|
|
326
343
|
const previousStopRequestedAt = markStopRequested(store, record);
|
|
327
344
|
const result = runner(control.command, control.args);
|
|
345
|
+
if (!result.success && isPendingRecoveryStop(record, result)) {
|
|
346
|
+
// The container already exited and its watcher is waiting out
|
|
347
|
+
// `--on-kill-resume-delay` (issue #181): `docker kill` has nothing to
|
|
348
|
+
// signal, but the marker it left cancels the pending resume.
|
|
349
|
+
return {
|
|
350
|
+
success: true,
|
|
351
|
+
output: formatControlResultAsLinksNotation({
|
|
352
|
+
action,
|
|
353
|
+
identifier,
|
|
354
|
+
uuid: record.uuid,
|
|
355
|
+
status: 'recovery-cancelled',
|
|
356
|
+
backend: record.options.isolated,
|
|
357
|
+
sessionName: record.options.sessionName,
|
|
358
|
+
method: control.method,
|
|
359
|
+
processIds: null,
|
|
360
|
+
message: `Cancelled the pending recovery of detached docker container: ${record.options.sessionName}`,
|
|
361
|
+
}),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
328
364
|
if (!result.success) {
|
|
329
365
|
restoreStopRequested(store, record, previousStopRequestedAt);
|
|
330
366
|
const detail =
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Launch-time recovery for killed detached docker sessions (issue #176).
|
|
3
3
|
*
|
|
4
4
|
* `$ --isolated docker --detached --on-kill-resume 3 --recovery-command B -- A`
|
|
5
|
-
* runs `A`; when the main process is killed (exit 137
|
|
6
|
-
* detached completion watcher hands the inspected
|
|
7
|
-
* restarts the *same* container so `B` continues
|
|
8
|
-
* the same resource limits (they live in the
|
|
9
|
-
* the same execution UUID and appending to the
|
|
5
|
+
* runs `A`; when the main process is killed (exit 137, or `OOMKilled` with no
|
|
6
|
+
* exit status of its own), the detached completion watcher hands the inspected
|
|
7
|
+
* facts to this module, which restarts the *same* container so `B` continues
|
|
8
|
+
* on the same filesystem, with the same resource limits (they live in the
|
|
9
|
+
* container's HostConfig), under the same execution UUID and appending to the
|
|
10
|
+
* same log file.
|
|
10
11
|
*
|
|
11
12
|
* How `B` replaces `A` inside the same container: a container launched with a
|
|
12
13
|
* recovery command runs a tiny selector as its entrypoint command. The
|
|
@@ -38,6 +39,17 @@ const {
|
|
|
38
39
|
readDockerResourceLimits,
|
|
39
40
|
} = require('./docker-resource-limits');
|
|
40
41
|
const { shellQuote } = require('./isolation-log-utils');
|
|
42
|
+
const {
|
|
43
|
+
CGROUP_SHELL_VARS,
|
|
44
|
+
formatCgroupMemoryLogLine,
|
|
45
|
+
parseCgroupMemorySample,
|
|
46
|
+
} = require('./cgroup-memory');
|
|
47
|
+
const {
|
|
48
|
+
formatRecoveryDelay,
|
|
49
|
+
getOnKillResumeDelay,
|
|
50
|
+
pickRecoveryDelayMs,
|
|
51
|
+
waitForRecoveryDelay,
|
|
52
|
+
} = require('./recovery-delay');
|
|
41
53
|
|
|
42
54
|
/** File whose presence makes the selector run the recovery command. */
|
|
43
55
|
const RECOVERY_MARKER_PATH = '/.start-command-recovery';
|
|
@@ -81,37 +93,51 @@ function buildRecoverySelectorArgs(mainArgs, params) {
|
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
/**
|
|
96
|
+
* Whether the main process was killed.
|
|
97
|
+
*
|
|
98
|
+
* Exit 137 (SIGKILL, the OOM killer's signal) always counts. `OOMKilled` alone
|
|
99
|
+
* does not: Docker sets it when *any* process in the container's cgroup was
|
|
100
|
+
* OOM-killed (a compiler, a test runner, a child `node`), and it stays set
|
|
101
|
+
* until the container is started again. A main process that survived that and
|
|
102
|
+
* then exited 0-127 on its own ran to completion, so the flag only counts when
|
|
103
|
+
* there is no usable exit status (the watcher's `-1`, or nothing at all)
|
|
104
|
+
* (issue #178).
|
|
105
|
+
*
|
|
84
106
|
* @param {number|string|null} exitCode - `.State.ExitCode`
|
|
85
107
|
* @param {boolean|string|null} oomKilled - `.State.OOMKilled`
|
|
86
108
|
* @returns {boolean} Whether the main process was killed
|
|
87
109
|
*/
|
|
88
110
|
function isKilledExit(exitCode, oomKilled) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
111
|
+
const { code } = describeExitCode(exitCode);
|
|
112
|
+
if (code === KILLED_EXIT_CODE) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
const oom = oomKilled === true || oomKilled === 'true';
|
|
116
|
+
return oom && (code === null || code < 0);
|
|
94
117
|
}
|
|
95
118
|
|
|
96
119
|
/**
|
|
97
120
|
* Shell condition run by the completion watcher: true when the container was
|
|
98
|
-
* killed and the recovery entry point resumed it.
|
|
121
|
+
* killed and the recovery entry point resumed it. Mirrors `isKilledExit()`:
|
|
122
|
+
* `OOMKilled` only counts without a non-negative exit code (issue #178).
|
|
99
123
|
* @param {string} executionId - Execution UUID
|
|
100
124
|
* @returns {string} POSIX shell condition
|
|
101
125
|
*/
|
|
102
126
|
function buildRecoverySnippet(executionId) {
|
|
103
127
|
const v = SHELL_VARS;
|
|
104
128
|
return (
|
|
105
|
-
`{ [ "$${v.exit}" = ${KILLED_EXIT_CODE} ] ||
|
|
129
|
+
`{ [ "$${v.exit}" = ${KILLED_EXIT_CODE} ] || ` +
|
|
130
|
+
`{ [ "$${v.oom}" = true ] && ! [ "$${v.exit}" -ge 0 ] 2>/dev/null; }; } && ` +
|
|
106
131
|
`${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
|
|
107
132
|
`${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
|
|
108
|
-
`"$${v.started}" "$${v.finished}" "$${v.error}"
|
|
133
|
+
`"$${v.started}" "$${v.finished}" "$${v.error}" ` +
|
|
134
|
+
`"$${CGROUP_SHELL_VARS.sample}" >/dev/null 2>&1`
|
|
109
135
|
);
|
|
110
136
|
}
|
|
111
137
|
|
|
112
138
|
/**
|
|
113
139
|
* The `[Recovery k/N]` separator written into the session log.
|
|
114
|
-
* @param {object} params - {attempt, maxAttempts, exitCode, oomKilled, containerName, command}
|
|
140
|
+
* @param {object} params - {attempt, maxAttempts, exitCode, oomKilled, containerName, command, delayMs}
|
|
115
141
|
* @returns {string} Separator line, newline terminated
|
|
116
142
|
*/
|
|
117
143
|
function formatRecoverySeparator(params) {
|
|
@@ -120,14 +146,29 @@ function formatRecoverySeparator(params) {
|
|
|
120
146
|
const what = params.command
|
|
121
147
|
? `running recovery command: ${params.command}`
|
|
122
148
|
: 'running the original command again';
|
|
149
|
+
const delay =
|
|
150
|
+
params.delayMs > 0
|
|
151
|
+
? ` after a ${formatRecoveryDelay(params.delayMs)} delay`
|
|
152
|
+
: '';
|
|
123
153
|
return (
|
|
124
154
|
`\n[Recovery ${params.attempt}/${params.maxAttempts}] ` +
|
|
125
155
|
`Main process was killed (exit ${described.code}${signal}, ` +
|
|
126
156
|
`oomKilled=${params.oomKilled === true || params.oomKilled === 'true'}); ` +
|
|
127
|
-
`resuming container ${params.containerName}, ${what}\n`
|
|
157
|
+
`resuming container ${params.containerName}${delay}, ${what}\n`
|
|
128
158
|
);
|
|
129
159
|
}
|
|
130
160
|
|
|
161
|
+
function isStopRequested(store, executionId) {
|
|
162
|
+
try {
|
|
163
|
+
const current = store.get(executionId);
|
|
164
|
+
return Boolean(
|
|
165
|
+
current && current.options && current.options.stopRequestedAt
|
|
166
|
+
);
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
131
172
|
function appendToLog(logPath, text) {
|
|
132
173
|
if (!logPath) {
|
|
133
174
|
return;
|
|
@@ -162,10 +203,13 @@ function failureDetail(result) {
|
|
|
162
203
|
}
|
|
163
204
|
|
|
164
205
|
/**
|
|
165
|
-
* Resume a killed execution in its own container
|
|
206
|
+
* Resume a killed execution in its own container, after the random
|
|
207
|
+
* `--on-kill-resume-delay` wait (issue #181) when one was requested.
|
|
166
208
|
* @param {object} params - {store, executionId, exitCode, oomKilled, startedAt,
|
|
167
|
-
* finishedAt, containerError, runner, startWatcher, now
|
|
168
|
-
*
|
|
209
|
+
* finishedAt, containerError, cgroupMemory, runner, startWatcher, now,
|
|
210
|
+
* random, sleep}; `cgroupMemory` is the watcher's last cgroup v2 sample
|
|
211
|
+
* (issue #182)
|
|
212
|
+
* @returns {{recovered: boolean, reason: string, attempt?: number, delayMs?: number}} Outcome
|
|
169
213
|
*/
|
|
170
214
|
function recoverKilledExecution(params = {}) {
|
|
171
215
|
const { store, executionId } = params;
|
|
@@ -201,6 +245,8 @@ function recoverKilledExecution(params = {}) {
|
|
|
201
245
|
const runner = params.runner || require('./execution-control').runCommand;
|
|
202
246
|
const now = params.now || (() => new Date());
|
|
203
247
|
const attempt = used + 1;
|
|
248
|
+
const delayRange = getOnKillResumeDelay(opts);
|
|
249
|
+
const delayMs = pickRecoveryDelayMs(delayRange, params.random);
|
|
204
250
|
const giveUp = (reason) => {
|
|
205
251
|
log(`[Recovery ${attempt}/${maxAttempts}] Failed: ${reason}\n`);
|
|
206
252
|
return { recovered: false, reason: 'resume-failed', attempt };
|
|
@@ -216,6 +262,10 @@ function recoverKilledExecution(params = {}) {
|
|
|
216
262
|
error: params.containerError,
|
|
217
263
|
})}`
|
|
218
264
|
);
|
|
265
|
+
const memoryLine = formatCgroupMemoryLogLine(params.cgroupMemory);
|
|
266
|
+
if (memoryLine) {
|
|
267
|
+
log(`${memoryLine}\n`);
|
|
268
|
+
}
|
|
219
269
|
log(
|
|
220
270
|
formatRecoverySeparator({
|
|
221
271
|
attempt,
|
|
@@ -224,9 +274,28 @@ function recoverKilledExecution(params = {}) {
|
|
|
224
274
|
oomKilled: params.oomKilled,
|
|
225
275
|
containerName,
|
|
226
276
|
command: opts.recoveryCommand || null,
|
|
277
|
+
delayMs,
|
|
227
278
|
})
|
|
228
279
|
);
|
|
229
280
|
|
|
281
|
+
// Executions killed by one host-wide OOM event must not all come back in
|
|
282
|
+
// the same second (issue #181). `--stop` during the wait cancels the resume:
|
|
283
|
+
// the container has already exited, so `docker stop` only leaves the
|
|
284
|
+
// `stopRequestedAt` marker this loop polls for.
|
|
285
|
+
if (delayMs > 0) {
|
|
286
|
+
const cancelled = waitForRecoveryDelay({
|
|
287
|
+
delayMs,
|
|
288
|
+
sleep: params.sleep,
|
|
289
|
+
shouldCancel: () => isStopRequested(store, executionId),
|
|
290
|
+
});
|
|
291
|
+
if (cancelled) {
|
|
292
|
+
log(
|
|
293
|
+
`[Recovery ${attempt}/${maxAttempts}] Not resuming: the session was stopped on request during the delay.\n`
|
|
294
|
+
);
|
|
295
|
+
return { recovered: false, reason: 'stop-requested', attempt, delayMs };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
230
299
|
// Docker keeps `docker update` limits in the HostConfig across restarts;
|
|
231
300
|
// they are read back so the log and `--status` show what the resumed run
|
|
232
301
|
// is held to, and so a later relaunch can re-apply them.
|
|
@@ -255,14 +324,24 @@ function recoverKilledExecution(params = {}) {
|
|
|
255
324
|
}
|
|
256
325
|
|
|
257
326
|
const described = describeExitCode(params.exitCode);
|
|
327
|
+
const delayed = delayRange ? `, delayMs=${delayMs}` : '';
|
|
328
|
+
// The killed run's own counters: the resumed run starts a fresh cgroup.
|
|
329
|
+
const memory = parseCgroupMemorySample(params.cgroupMemory);
|
|
330
|
+
const counted = memory
|
|
331
|
+
? ['oomEvents', 'oomKills']
|
|
332
|
+
.filter((field) => memory[field] !== null)
|
|
333
|
+
.map((field) => `, ${field}=${memory[field]}`)
|
|
334
|
+
.join('')
|
|
335
|
+
: '';
|
|
258
336
|
record.options = {
|
|
259
337
|
...opts,
|
|
260
338
|
recoveryAttempts: attempt,
|
|
261
339
|
recoveryHistory: [
|
|
262
340
|
...(Array.isArray(opts.recoveryHistory) ? opts.recoveryHistory : []),
|
|
263
|
-
`${attempt}: exit ${described.code}, oomKilled=${params.oomKilled === true || params.oomKilled === 'true'}, resumed at ${since}`,
|
|
341
|
+
`${attempt}: exit ${described.code}, oomKilled=${params.oomKilled === true || params.oomKilled === 'true'}${counted}${delayed}, resumed at ${since}`,
|
|
264
342
|
],
|
|
265
343
|
lastRecoveryAt: since,
|
|
344
|
+
...(delayRange ? { lastRecoveryDelayMs: delayMs } : {}),
|
|
266
345
|
...(resourceLimits && resourceLimits.length > 0 ? { resourceLimits } : {}),
|
|
267
346
|
};
|
|
268
347
|
record.status = 'executing';
|
|
@@ -270,6 +349,7 @@ function recoverKilledExecution(params = {}) {
|
|
|
270
349
|
record.endTime = null;
|
|
271
350
|
record.exitReason = undefined;
|
|
272
351
|
record.oomKilled = undefined;
|
|
352
|
+
record.cgroupMemory = undefined;
|
|
273
353
|
try {
|
|
274
354
|
store.save(record);
|
|
275
355
|
} catch {
|
|
@@ -286,19 +366,32 @@ function recoverKilledExecution(params = {}) {
|
|
|
286
366
|
record.uuid,
|
|
287
367
|
{ since, recoverOnKill: true }
|
|
288
368
|
);
|
|
289
|
-
return {
|
|
369
|
+
return {
|
|
370
|
+
recovered: true,
|
|
371
|
+
reason: 'resumed',
|
|
372
|
+
attempt,
|
|
373
|
+
...(delayRange ? { delayMs } : {}),
|
|
374
|
+
};
|
|
290
375
|
}
|
|
291
376
|
|
|
292
377
|
/**
|
|
293
378
|
* Entry point used by the detached watcher:
|
|
294
|
-
* <runtime> execution-recovery.js <uuid> <exit> <oom> <started> <finished> <error>
|
|
379
|
+
* <runtime> execution-recovery.js <uuid> <exit> <oom> <started> <finished> <error> <cgroup>
|
|
295
380
|
* Exits 0 only when the container was resumed; any other outcome lets the
|
|
296
381
|
* watcher continue with its normal cleanup, footer and finalization.
|
|
297
382
|
* @param {string[]} argv - Positional arguments
|
|
298
383
|
* @returns {number} Process exit code
|
|
299
384
|
*/
|
|
300
385
|
function main(argv) {
|
|
301
|
-
const [
|
|
386
|
+
const [
|
|
387
|
+
executionId,
|
|
388
|
+
exitCode,
|
|
389
|
+
oomKilled,
|
|
390
|
+
startedAt,
|
|
391
|
+
finishedAt,
|
|
392
|
+
error,
|
|
393
|
+
cgroupMemory,
|
|
394
|
+
] = argv;
|
|
302
395
|
if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
|
|
303
396
|
return 1;
|
|
304
397
|
}
|
|
@@ -317,6 +410,7 @@ function main(argv) {
|
|
|
317
410
|
startedAt,
|
|
318
411
|
finishedAt,
|
|
319
412
|
containerError: error,
|
|
413
|
+
cgroupMemory,
|
|
320
414
|
});
|
|
321
415
|
return outcome.recovered ? 0 : 1;
|
|
322
416
|
} catch (err) {
|
|
@@ -116,6 +116,12 @@ class ExecutionRecord {
|
|
|
116
116
|
options.memoryExhaustedReason !== undefined
|
|
117
117
|
? options.memoryExhaustedReason
|
|
118
118
|
: undefined;
|
|
119
|
+
// cgroup v2 memory counters the detached watcher sampled for the last run
|
|
120
|
+
// ({limitBytes, peakBytes, oomEvents, oomKills}, issue #182). Unlike the
|
|
121
|
+
// sticky `oomKilled`, they say how many processes the OOM killer took and
|
|
122
|
+
// whether the container hit its own limit.
|
|
123
|
+
this.cgroupMemory =
|
|
124
|
+
options.cgroupMemory !== undefined ? options.cgroupMemory : undefined;
|
|
119
125
|
this.workingDirectory = options.workingDirectory || process.cwd();
|
|
120
126
|
this.shell = options.shell || process.env.SHELL || '/bin/sh';
|
|
121
127
|
this.platform = options.platform || process.platform;
|
|
@@ -176,6 +182,9 @@ class ExecutionRecord {
|
|
|
176
182
|
) {
|
|
177
183
|
obj.memoryExhaustedReason = this.memoryExhaustedReason;
|
|
178
184
|
}
|
|
185
|
+
if (this.cgroupMemory !== undefined && this.cgroupMemory !== null) {
|
|
186
|
+
obj.cgroupMemory = this.cgroupMemory;
|
|
187
|
+
}
|
|
179
188
|
Object.assign(obj, {
|
|
180
189
|
workingDirectory: this.workingDirectory,
|
|
181
190
|
shell: this.shell,
|
package/src/lib/exit-reason.js
CHANGED
|
@@ -25,6 +25,11 @@ const os = require('os');
|
|
|
25
25
|
*/
|
|
26
26
|
const MEMORY_EXHAUSTION_PREFIX = 'memory-exhaustion';
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Reason reported when the container-wide OOM observation explains the exit.
|
|
30
|
+
*/
|
|
31
|
+
const CGROUP_OOM_EXIT_REASON = `${MEMORY_EXHAUSTION_PREFIX} (cgroup-oom-killer)`;
|
|
32
|
+
|
|
28
33
|
/**
|
|
29
34
|
* Fatal markers, most specific first. The first matching entry wins.
|
|
30
35
|
* @type {{reason: string, pattern: RegExp}[]}
|
|
@@ -155,7 +160,12 @@ function detectMemoryMarker(text) {
|
|
|
155
160
|
* a fatal marker (a test fixture, an `rg` dump) cannot turn a clean run into a
|
|
156
161
|
* reported memory failure.
|
|
157
162
|
*
|
|
158
|
-
*
|
|
163
|
+
* The container-wide OOM signals (`State.OOMKilled`, the cgroup `oom_kill`
|
|
164
|
+
* counter) only count when the exit fits an OOM kill of the command itself:
|
|
165
|
+
* a child OOM-killed under a command that later exits 1 is not the reason the
|
|
166
|
+
* command ended (#180).
|
|
167
|
+
*
|
|
168
|
+
* @param {{exitCode?: number|null, logTail?: string|null, oomKilled?: boolean, cgroupMemory?: object|null}|null} input
|
|
159
169
|
* @returns {{memoryExhausted: true, memoryExhaustedReason: string}|null}
|
|
160
170
|
*/
|
|
161
171
|
function resolveMemoryExhaustion(input) {
|
|
@@ -175,15 +185,63 @@ function resolveMemoryExhaustion(input) {
|
|
|
175
185
|
if (marker) {
|
|
176
186
|
return { memoryExhausted: true, memoryExhaustedReason: marker.line };
|
|
177
187
|
}
|
|
178
|
-
if (input.oomKilled === true) {
|
|
188
|
+
if (input.oomKilled === true && isOomKillOfCommand(input)) {
|
|
179
189
|
return {
|
|
180
190
|
memoryExhausted: true,
|
|
181
191
|
memoryExhaustedReason: 'Docker reported State.OOMKilled=true',
|
|
182
192
|
};
|
|
183
193
|
}
|
|
194
|
+
const oomKills = cgroupOomKills(input.cgroupMemory);
|
|
195
|
+
if (oomKills > 0 && isOomKillOfCommand(input)) {
|
|
196
|
+
return {
|
|
197
|
+
memoryExhausted: true,
|
|
198
|
+
memoryExhaustedReason: `cgroup memory.events reported oom_kill=${oomKills}`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
184
201
|
return null;
|
|
185
202
|
}
|
|
186
203
|
|
|
204
|
+
/**
|
|
205
|
+
* `oom_kill` counter sampled from the container cgroup (issue #182).
|
|
206
|
+
* @param {{oomKills?: number|null}|null|undefined} cgroupMemory - Sampled counters
|
|
207
|
+
* @returns {number} Number of OOM-killed processes, 0 when unknown
|
|
208
|
+
*/
|
|
209
|
+
function cgroupOomKills(cgroupMemory) {
|
|
210
|
+
const value = cgroupMemory ? cgroupMemory.oomKills : null;
|
|
211
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Can a container-wide OOM observation be blamed on the command itself?
|
|
216
|
+
*
|
|
217
|
+
* Docker's `State.OOMKilled` and the cgroup `oom_kill` counter are
|
|
218
|
+
* container-wide and sticky (moby/moby#43564): they turn on as soon as *any*
|
|
219
|
+
* process in the container is OOM-killed, e.g. one `rustc` child under
|
|
220
|
+
* `cargo test`, while the command keeps running and later exits on its own
|
|
221
|
+
* (issue #180). The OOM killer always sends SIGKILL, so the observation only
|
|
222
|
+
* explains the command's exit when that exit is 137 (`128 + SIGKILL`) or there
|
|
223
|
+
* is no usable exit code at all (null, or the `-1` of an unknown exit). This is
|
|
224
|
+
* the rule `isKilledExit()` already applies to `--on-kill-resume` (#178).
|
|
225
|
+
*
|
|
226
|
+
* @param {{exitCode?: number|null, oomKilled?: boolean, cgroupMemory?: object|null}|null} input
|
|
227
|
+
* @returns {boolean} True when an OOM was observed and the exit fits an OOM kill
|
|
228
|
+
*/
|
|
229
|
+
function isOomKillOfCommand(input) {
|
|
230
|
+
if (!input) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
const observed =
|
|
234
|
+
input.oomKilled === true || cgroupOomKills(input.cgroupMemory) > 0;
|
|
235
|
+
if (!observed) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
const { exitCode } = input;
|
|
239
|
+
if (typeof exitCode !== 'number' || !Number.isFinite(exitCode)) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
return exitCode < 0 || signalNameForExitCode(exitCode) === 'SIGKILL';
|
|
243
|
+
}
|
|
244
|
+
|
|
187
245
|
/**
|
|
188
246
|
* Exit code of a child process that has finished.
|
|
189
247
|
*
|
|
@@ -249,9 +307,10 @@ function describeExitCode(exitCode) {
|
|
|
249
307
|
* Resolve the best available hint for why an execution ended.
|
|
250
308
|
*
|
|
251
309
|
* Precedence: the log marker (evidence written by the command itself), then the
|
|
252
|
-
* cgroup OOM observation
|
|
310
|
+
* cgroup OOM observation when the exit fits an OOM kill of the command (#180),
|
|
311
|
+
* then the signal encoded in the exit code.
|
|
253
312
|
*
|
|
254
|
-
* @param {{exitCode?: number|null, logTail?: string|null, oomKilled?: boolean}|null} input
|
|
313
|
+
* @param {{exitCode?: number|null, logTail?: string|null, oomKilled?: boolean, cgroupMemory?: object|null}|null} input
|
|
255
314
|
* @returns {string|null} Exit reason hint, or null when nothing is known
|
|
256
315
|
*/
|
|
257
316
|
function resolveExitReason(input) {
|
|
@@ -264,8 +323,8 @@ function resolveExitReason(input) {
|
|
|
264
323
|
return fromLog;
|
|
265
324
|
}
|
|
266
325
|
|
|
267
|
-
if (input
|
|
268
|
-
return
|
|
326
|
+
if (isOomKillOfCommand(input)) {
|
|
327
|
+
return CGROUP_OOM_EXIT_REASON;
|
|
269
328
|
}
|
|
270
329
|
|
|
271
330
|
const signalName = signalNameForExitCode(input.exitCode);
|
|
@@ -273,6 +332,7 @@ function resolveExitReason(input) {
|
|
|
273
332
|
}
|
|
274
333
|
|
|
275
334
|
module.exports = {
|
|
335
|
+
CGROUP_OOM_EXIT_REASON,
|
|
276
336
|
EXIT_REASON_MARKERS,
|
|
277
337
|
UNKNOWN_EXIT_CODE,
|
|
278
338
|
MEMORY_EXHAUSTION_PREFIX,
|
|
@@ -280,6 +340,7 @@ module.exports = {
|
|
|
280
340
|
detectExitReason,
|
|
281
341
|
detectMemoryMarker,
|
|
282
342
|
findExitReasonMarker,
|
|
343
|
+
isOomKillOfCommand,
|
|
283
344
|
resolveMemoryExhaustion,
|
|
284
345
|
describeExitCode,
|
|
285
346
|
resolveChildExitCode,
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Random delay before each launch-time kill recovery (issue #181):
|
|
3
|
+
* --on-kill-resume-delay <min[-max]> seconds; one number is a fixed delay,
|
|
4
|
+
* 0 (the default) resumes at once
|
|
5
|
+
*
|
|
6
|
+
* One host-wide OOM event can kill several detached docker executions at the
|
|
7
|
+
* same moment. Without a delay every watcher resumes its container in the same
|
|
8
|
+
* second, all of them rebuild their working sets at once, and the next OOM
|
|
9
|
+
* event follows. A uniformly random delay spreads the recoveries out.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DELAY_PATTERN = /^(\d+(?:\.\d+)?)(?:-(\d+(?:\.\d+)?))?$/;
|
|
13
|
+
|
|
14
|
+
/** How often a pending recovery checks whether a stop was requested. */
|
|
15
|
+
const CANCEL_CHECK_INTERVAL_MS = 1000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a delay range in seconds.
|
|
19
|
+
* @param {string|number|null|undefined} value - `30-90`, `45`, `0`, ...
|
|
20
|
+
* @returns {?{minSeconds: number, maxSeconds: number}} Range, or null when
|
|
21
|
+
* the value is missing or malformed
|
|
22
|
+
*/
|
|
23
|
+
function parseRecoveryDelayRange(value) {
|
|
24
|
+
if (value === null || value === undefined) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const match = DELAY_PATTERN.exec(String(value).trim());
|
|
28
|
+
if (!match) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const minSeconds = Number(match[1]);
|
|
32
|
+
const maxSeconds = match[2] === undefined ? minSeconds : Number(match[2]);
|
|
33
|
+
if (
|
|
34
|
+
!Number.isFinite(minSeconds) ||
|
|
35
|
+
!Number.isFinite(maxSeconds) ||
|
|
36
|
+
maxSeconds < minSeconds
|
|
37
|
+
) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return { minSeconds, maxSeconds };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate and normalize the `--on-kill-resume-delay` argument.
|
|
45
|
+
* @param {string} value - Raw argument
|
|
46
|
+
* @returns {string} Normalized range: `30-90`, `45` or `0`
|
|
47
|
+
* @throws {Error} If the value is not `<min>[-<max>]` seconds with max >= min
|
|
48
|
+
*/
|
|
49
|
+
function parseOnKillResumeDelayValue(value) {
|
|
50
|
+
const range = parseRecoveryDelayRange(value);
|
|
51
|
+
if (!range) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Invalid --on-kill-resume-delay value: "${value}". ` +
|
|
54
|
+
'Expected seconds as <min>[-<max>] with max >= min, e.g. 30-90.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return formatRecoveryDelayRange(range);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {{minSeconds: number, maxSeconds: number}} range - Delay range
|
|
62
|
+
* @returns {string} `30-90` for a range, `45` for a fixed delay
|
|
63
|
+
*/
|
|
64
|
+
function formatRecoveryDelayRange(range) {
|
|
65
|
+
return range.minSeconds === range.maxSeconds
|
|
66
|
+
? `${range.minSeconds}`
|
|
67
|
+
: `${range.minSeconds}-${range.maxSeconds}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The delay range stored in the record, or null when there is no delay.
|
|
72
|
+
* @param {object} options - Options or record options (onKillResumeDelay)
|
|
73
|
+
* @returns {?string} Normalized non-zero range
|
|
74
|
+
*/
|
|
75
|
+
function getOnKillResumeDelay(options = {}) {
|
|
76
|
+
const range = parseRecoveryDelayRange(options.onKillResumeDelay);
|
|
77
|
+
if (!range || range.maxSeconds <= 0) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return formatRecoveryDelayRange(range);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Pick a uniformly random delay from the range.
|
|
85
|
+
* @param {string|null} value - Stored range (`30-90`)
|
|
86
|
+
* @param {function(): number} [random] - Source in [0, 1), for tests
|
|
87
|
+
* @returns {number} Delay in whole milliseconds, 0 without a range
|
|
88
|
+
*/
|
|
89
|
+
function pickRecoveryDelayMs(value, random = Math.random) {
|
|
90
|
+
const range = parseRecoveryDelayRange(value);
|
|
91
|
+
if (!range) {
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
const minMs = range.minSeconds * 1000;
|
|
95
|
+
const maxMs = range.maxSeconds * 1000;
|
|
96
|
+
return Math.round(minMs + (maxMs - minMs) * random());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {number} delayMs - Delay in milliseconds
|
|
101
|
+
* @returns {string} `42s`, `42.5s`
|
|
102
|
+
*/
|
|
103
|
+
function formatRecoveryDelay(delayMs) {
|
|
104
|
+
return `${Math.round(delayMs / 100) / 10}s`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Block the current thread. The recovery entry point is a short-lived
|
|
109
|
+
* synchronous child of the watcher shell, so there is no event loop to keep
|
|
110
|
+
* responsive; `Atomics.wait` works on the main thread of node and bun.
|
|
111
|
+
* @param {number} ms - Milliseconds
|
|
112
|
+
*/
|
|
113
|
+
function sleepSync(ms) {
|
|
114
|
+
if (ms > 0) {
|
|
115
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Wait out the delay in short steps, giving up as soon as `shouldCancel`
|
|
121
|
+
* returns true (a `--stop` during the wait).
|
|
122
|
+
* @param {object} params - {delayMs, shouldCancel, sleep, intervalMs}
|
|
123
|
+
* @returns {boolean} True when the wait was cancelled
|
|
124
|
+
*/
|
|
125
|
+
function waitForRecoveryDelay(params) {
|
|
126
|
+
const sleep = params.sleep || sleepSync;
|
|
127
|
+
const shouldCancel = params.shouldCancel || (() => false);
|
|
128
|
+
const intervalMs = params.intervalMs || CANCEL_CHECK_INTERVAL_MS;
|
|
129
|
+
let remaining = Math.max(0, Number(params.delayMs) || 0);
|
|
130
|
+
while (remaining > 0) {
|
|
131
|
+
if (shouldCancel()) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
const step = Math.min(intervalMs, remaining);
|
|
135
|
+
sleep(step);
|
|
136
|
+
remaining -= step;
|
|
137
|
+
}
|
|
138
|
+
return shouldCancel();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
CANCEL_CHECK_INTERVAL_MS,
|
|
143
|
+
formatRecoveryDelay,
|
|
144
|
+
formatRecoveryDelayRange,
|
|
145
|
+
getOnKillResumeDelay,
|
|
146
|
+
parseOnKillResumeDelayValue,
|
|
147
|
+
parseRecoveryDelayRange,
|
|
148
|
+
pickRecoveryDelayMs,
|
|
149
|
+
sleepSync,
|
|
150
|
+
waitForRecoveryDelay,
|
|
151
|
+
};
|
|
@@ -16,6 +16,7 @@ const { collectProcessIds } = require('./execution-control');
|
|
|
16
16
|
const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
|
|
17
17
|
const {
|
|
18
18
|
describeExitCode,
|
|
19
|
+
CGROUP_OOM_EXIT_REASON,
|
|
19
20
|
resolveExitReason,
|
|
20
21
|
resolveMemoryExhaustion,
|
|
21
22
|
} = require('./exit-reason');
|
|
@@ -25,6 +26,7 @@ const {
|
|
|
25
26
|
} = require('./isolation-log-utils');
|
|
26
27
|
const { normalizeDockerTimestamp } = require('./docker-post-mortem');
|
|
27
28
|
const { END_TIME_SOURCE } = require('./detached-finalize');
|
|
29
|
+
const { formatCgroupMemory } = require('./cgroup-memory');
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
32
|
* Inspect the live state of a detached docker container by name.
|
|
@@ -433,9 +435,18 @@ function attachExitReason(record, logTail) {
|
|
|
433
435
|
exitCode: record.exitCode,
|
|
434
436
|
logTail,
|
|
435
437
|
oomKilled: record.oomKilled,
|
|
438
|
+
cgroupMemory: record.cgroupMemory,
|
|
436
439
|
});
|
|
437
440
|
|
|
438
441
|
if (!exitReason) {
|
|
442
|
+
// A record finalized before #180 may carry the cgroup OOM reason for an
|
|
443
|
+
// ordinary exit (the sticky flag was blamed for exit 0/1): drop it.
|
|
444
|
+
if (record.exitReason === CGROUP_OOM_EXIT_REASON) {
|
|
445
|
+
const cleared = Object.create(Object.getPrototypeOf(record));
|
|
446
|
+
Object.assign(cleared, record);
|
|
447
|
+
cleared.exitReason = undefined;
|
|
448
|
+
return cleared;
|
|
449
|
+
}
|
|
439
450
|
return record;
|
|
440
451
|
}
|
|
441
452
|
|
|
@@ -470,6 +481,7 @@ function attachMemoryExhaustion(record, logTail) {
|
|
|
470
481
|
exitCode: record.exitCode,
|
|
471
482
|
logTail,
|
|
472
483
|
oomKilled: record.oomKilled,
|
|
484
|
+
cgroupMemory: record.cgroupMemory,
|
|
473
485
|
});
|
|
474
486
|
|
|
475
487
|
if (!memory) {
|
|
@@ -655,6 +667,10 @@ function formatRecordAsText(record) {
|
|
|
655
667
|
...(obj.memoryExhaustedReason !== undefined
|
|
656
668
|
? [`Memory Evidence: ${obj.memoryExhaustedReason}`]
|
|
657
669
|
: []),
|
|
670
|
+
// cgroup v2 counters the detached watcher sampled (issue #182).
|
|
671
|
+
...(formatCgroupMemory(obj.cgroupMemory)
|
|
672
|
+
? [`Cgroup Memory: ${formatCgroupMemory(obj.cgroupMemory)}`]
|
|
673
|
+
: []),
|
|
658
674
|
`PID: ${obj.pid !== null ? obj.pid : 'N/A'}`,
|
|
659
675
|
`Working Directory: ${obj.workingDirectory}`,
|
|
660
676
|
`Shell: ${obj.shell}`,
|