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.
@@ -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 / `OOMKilled`), the
6
- * detached completion watcher hands the inspected facts to this module, which
7
- * restarts the *same* container so `B` continues on the same filesystem, with
8
- * the same resource limits (they live in the container's HostConfig), under
9
- * the same execution UUID and appending to the same log file.
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
- return (
90
- describeExitCode(exitCode).code === KILLED_EXIT_CODE ||
91
- oomKilled === true ||
92
- oomKilled === 'true'
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} ] || [ "$${v.oom}" = true ]; } && ` +
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}" >/dev/null 2>&1`
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
- * @returns {{recovered: boolean, reason: string, attempt?: number}} Outcome
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 { recovered: true, reason: 'resumed', attempt };
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 [executionId, exitCode, oomKilled, startedAt, finishedAt, error] = argv;
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,
@@ -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
- * @param {{exitCode?: number|null, logTail?: string|null, oomKilled?: boolean}|null} input
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, then the signal encoded in the exit code.
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.oomKilled === true) {
268
- return 'memory-exhaustion (cgroup-oom-killer)';
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}`,