start-command 0.35.1 → 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 +10 -0
- package/package.json +1 -1
- 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 +27 -8
- 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 +90 -10
- 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-180.js +196 -0
- package/test/regression-181.js +338 -0
- package/test/regression-182.js +439 -0
|
@@ -39,6 +39,17 @@ const {
|
|
|
39
39
|
readDockerResourceLimits,
|
|
40
40
|
} = require('./docker-resource-limits');
|
|
41
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');
|
|
42
53
|
|
|
43
54
|
/** File whose presence makes the selector run the recovery command. */
|
|
44
55
|
const RECOVERY_MARKER_PATH = '/.start-command-recovery';
|
|
@@ -119,13 +130,14 @@ function buildRecoverySnippet(executionId) {
|
|
|
119
130
|
`{ [ "$${v.oom}" = true ] && ! [ "$${v.exit}" -ge 0 ] 2>/dev/null; }; } && ` +
|
|
120
131
|
`${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
|
|
121
132
|
`${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
|
|
122
|
-
`"$${v.started}" "$${v.finished}" "$${v.error}"
|
|
133
|
+
`"$${v.started}" "$${v.finished}" "$${v.error}" ` +
|
|
134
|
+
`"$${CGROUP_SHELL_VARS.sample}" >/dev/null 2>&1`
|
|
123
135
|
);
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
/**
|
|
127
139
|
* The `[Recovery k/N]` separator written into the session log.
|
|
128
|
-
* @param {object} params - {attempt, maxAttempts, exitCode, oomKilled, containerName, command}
|
|
140
|
+
* @param {object} params - {attempt, maxAttempts, exitCode, oomKilled, containerName, command, delayMs}
|
|
129
141
|
* @returns {string} Separator line, newline terminated
|
|
130
142
|
*/
|
|
131
143
|
function formatRecoverySeparator(params) {
|
|
@@ -134,14 +146,29 @@ function formatRecoverySeparator(params) {
|
|
|
134
146
|
const what = params.command
|
|
135
147
|
? `running recovery command: ${params.command}`
|
|
136
148
|
: 'running the original command again';
|
|
149
|
+
const delay =
|
|
150
|
+
params.delayMs > 0
|
|
151
|
+
? ` after a ${formatRecoveryDelay(params.delayMs)} delay`
|
|
152
|
+
: '';
|
|
137
153
|
return (
|
|
138
154
|
`\n[Recovery ${params.attempt}/${params.maxAttempts}] ` +
|
|
139
155
|
`Main process was killed (exit ${described.code}${signal}, ` +
|
|
140
156
|
`oomKilled=${params.oomKilled === true || params.oomKilled === 'true'}); ` +
|
|
141
|
-
`resuming container ${params.containerName}, ${what}\n`
|
|
157
|
+
`resuming container ${params.containerName}${delay}, ${what}\n`
|
|
142
158
|
);
|
|
143
159
|
}
|
|
144
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
|
+
|
|
145
172
|
function appendToLog(logPath, text) {
|
|
146
173
|
if (!logPath) {
|
|
147
174
|
return;
|
|
@@ -176,10 +203,13 @@ function failureDetail(result) {
|
|
|
176
203
|
}
|
|
177
204
|
|
|
178
205
|
/**
|
|
179
|
-
* 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.
|
|
180
208
|
* @param {object} params - {store, executionId, exitCode, oomKilled, startedAt,
|
|
181
|
-
* finishedAt, containerError, runner, startWatcher, now
|
|
182
|
-
*
|
|
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
|
|
183
213
|
*/
|
|
184
214
|
function recoverKilledExecution(params = {}) {
|
|
185
215
|
const { store, executionId } = params;
|
|
@@ -215,6 +245,8 @@ function recoverKilledExecution(params = {}) {
|
|
|
215
245
|
const runner = params.runner || require('./execution-control').runCommand;
|
|
216
246
|
const now = params.now || (() => new Date());
|
|
217
247
|
const attempt = used + 1;
|
|
248
|
+
const delayRange = getOnKillResumeDelay(opts);
|
|
249
|
+
const delayMs = pickRecoveryDelayMs(delayRange, params.random);
|
|
218
250
|
const giveUp = (reason) => {
|
|
219
251
|
log(`[Recovery ${attempt}/${maxAttempts}] Failed: ${reason}\n`);
|
|
220
252
|
return { recovered: false, reason: 'resume-failed', attempt };
|
|
@@ -230,6 +262,10 @@ function recoverKilledExecution(params = {}) {
|
|
|
230
262
|
error: params.containerError,
|
|
231
263
|
})}`
|
|
232
264
|
);
|
|
265
|
+
const memoryLine = formatCgroupMemoryLogLine(params.cgroupMemory);
|
|
266
|
+
if (memoryLine) {
|
|
267
|
+
log(`${memoryLine}\n`);
|
|
268
|
+
}
|
|
233
269
|
log(
|
|
234
270
|
formatRecoverySeparator({
|
|
235
271
|
attempt,
|
|
@@ -238,9 +274,28 @@ function recoverKilledExecution(params = {}) {
|
|
|
238
274
|
oomKilled: params.oomKilled,
|
|
239
275
|
containerName,
|
|
240
276
|
command: opts.recoveryCommand || null,
|
|
277
|
+
delayMs,
|
|
241
278
|
})
|
|
242
279
|
);
|
|
243
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
|
+
|
|
244
299
|
// Docker keeps `docker update` limits in the HostConfig across restarts;
|
|
245
300
|
// they are read back so the log and `--status` show what the resumed run
|
|
246
301
|
// is held to, and so a later relaunch can re-apply them.
|
|
@@ -269,14 +324,24 @@ function recoverKilledExecution(params = {}) {
|
|
|
269
324
|
}
|
|
270
325
|
|
|
271
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
|
+
: '';
|
|
272
336
|
record.options = {
|
|
273
337
|
...opts,
|
|
274
338
|
recoveryAttempts: attempt,
|
|
275
339
|
recoveryHistory: [
|
|
276
340
|
...(Array.isArray(opts.recoveryHistory) ? opts.recoveryHistory : []),
|
|
277
|
-
`${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}`,
|
|
278
342
|
],
|
|
279
343
|
lastRecoveryAt: since,
|
|
344
|
+
...(delayRange ? { lastRecoveryDelayMs: delayMs } : {}),
|
|
280
345
|
...(resourceLimits && resourceLimits.length > 0 ? { resourceLimits } : {}),
|
|
281
346
|
};
|
|
282
347
|
record.status = 'executing';
|
|
@@ -284,6 +349,7 @@ function recoverKilledExecution(params = {}) {
|
|
|
284
349
|
record.endTime = null;
|
|
285
350
|
record.exitReason = undefined;
|
|
286
351
|
record.oomKilled = undefined;
|
|
352
|
+
record.cgroupMemory = undefined;
|
|
287
353
|
try {
|
|
288
354
|
store.save(record);
|
|
289
355
|
} catch {
|
|
@@ -300,19 +366,32 @@ function recoverKilledExecution(params = {}) {
|
|
|
300
366
|
record.uuid,
|
|
301
367
|
{ since, recoverOnKill: true }
|
|
302
368
|
);
|
|
303
|
-
return {
|
|
369
|
+
return {
|
|
370
|
+
recovered: true,
|
|
371
|
+
reason: 'resumed',
|
|
372
|
+
attempt,
|
|
373
|
+
...(delayRange ? { delayMs } : {}),
|
|
374
|
+
};
|
|
304
375
|
}
|
|
305
376
|
|
|
306
377
|
/**
|
|
307
378
|
* Entry point used by the detached watcher:
|
|
308
|
-
* <runtime> execution-recovery.js <uuid> <exit> <oom> <started> <finished> <error>
|
|
379
|
+
* <runtime> execution-recovery.js <uuid> <exit> <oom> <started> <finished> <error> <cgroup>
|
|
309
380
|
* Exits 0 only when the container was resumed; any other outcome lets the
|
|
310
381
|
* watcher continue with its normal cleanup, footer and finalization.
|
|
311
382
|
* @param {string[]} argv - Positional arguments
|
|
312
383
|
* @returns {number} Process exit code
|
|
313
384
|
*/
|
|
314
385
|
function main(argv) {
|
|
315
|
-
const [
|
|
386
|
+
const [
|
|
387
|
+
executionId,
|
|
388
|
+
exitCode,
|
|
389
|
+
oomKilled,
|
|
390
|
+
startedAt,
|
|
391
|
+
finishedAt,
|
|
392
|
+
error,
|
|
393
|
+
cgroupMemory,
|
|
394
|
+
] = argv;
|
|
316
395
|
if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
|
|
317
396
|
return 1;
|
|
318
397
|
}
|
|
@@ -331,6 +410,7 @@ function main(argv) {
|
|
|
331
410
|
startedAt,
|
|
332
411
|
finishedAt,
|
|
333
412
|
containerError: error,
|
|
413
|
+
cgroupMemory,
|
|
334
414
|
});
|
|
335
415
|
return outcome.recovered ? 0 : 1;
|
|
336
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}`,
|
package/src/lib/usage.js
CHANGED
|
@@ -18,6 +18,7 @@ Options:
|
|
|
18
18
|
--network-alias <alias> Add alias to the first network (repeatable, docker only)
|
|
19
19
|
--on-kill-resume <N> Resume a killed (exit 137 / OOM) detached docker session up to N times
|
|
20
20
|
--recovery-command <cmd> Command to run in the same container on such a resume
|
|
21
|
+
--on-kill-resume-delay <min[-max]> Wait a random number of seconds before each such resume (default 0)
|
|
21
22
|
--endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
22
23
|
--isolated-user, -u [name] Create isolated user with same permissions
|
|
23
24
|
--keep-user Keep isolated user after command completes
|
|
@@ -69,6 +70,7 @@ Examples:
|
|
|
69
70
|
$ --resume my-docker-session -- bash # Run a different command in the same container
|
|
70
71
|
$ --resume-all # Re-attach every still-running execution
|
|
71
72
|
$ -i docker -d --on-kill-resume 3 --recovery-command 'solve --resume' -- solve
|
|
73
|
+
$ -i docker -d --on-kill-resume 3 --on-kill-resume-delay 30-90 -- cargo test
|
|
72
74
|
$ --use-command-stream echo "Hello" # Use command-stream library`);
|
|
73
75
|
console.log('');
|
|
74
76
|
console.log('Piping with $:');
|
|
@@ -307,6 +307,7 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
307
307
|
resourceLimits: null,
|
|
308
308
|
onKillResume: null,
|
|
309
309
|
recoveryCommand: null,
|
|
310
|
+
onKillResumeDelay: null,
|
|
310
311
|
});
|
|
311
312
|
});
|
|
312
313
|
|
|
@@ -332,6 +333,7 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
332
333
|
resourceLimits: null,
|
|
333
334
|
onKillResume: null,
|
|
334
335
|
recoveryCommand: null,
|
|
336
|
+
onKillResumeDelay: null,
|
|
335
337
|
}
|
|
336
338
|
);
|
|
337
339
|
});
|
package/test/regression-174.js
CHANGED
|
@@ -113,6 +113,8 @@ function runWatcher({ policy, running, state, withLog = true }) {
|
|
|
113
113
|
' echo "$answer" > "$dir/current"',
|
|
114
114
|
' echo "$answer" ;;',
|
|
115
115
|
' *State.Error*) echo "" ;;',
|
|
116
|
+
// The cgroup sampler (issue #182): no cgroup to find here.
|
|
117
|
+
" *'{{.Id}}'*|*State.Pid*) echo 0 ;;",
|
|
116
118
|
' *) echo "inspect-state" >> "$dir/calls"; cat "$dir/state" ;;',
|
|
117
119
|
' esac ;;',
|
|
118
120
|
' logs) echo logs >> "$dir/calls"; echo work; exit 1 ;;',
|