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.
@@ -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}" >/dev/null 2>&1`
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
- * @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
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 { recovered: true, reason: 'resumed', attempt };
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 [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;
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,
@@ -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}`,
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
  });
@@ -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 ;;',