start-command 0.35.1 → 0.35.3

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # start-command
2
2
 
3
+ ## 0.35.3
4
+
5
+ ### Patch Changes
6
+
7
+ - ec0a9cf: Report OOM kill scope as unknown when only cgroup memory counters are available. Allocation events and killed processes have different units, so their comparison cannot establish container, parent or host scope. Preserve raw counters and the exit-code guard for earlier child OOM kills.
8
+
9
+ ## 0.35.2
10
+
11
+ ### Patch Changes
12
+
13
+ - e234c05: Stop reporting `exitReason: memory-exhaustion (cgroup-oom-killer)` and `memoryExhausted: true` (`Docker reported State.OOMKilled=true`) for executions that exited 0, 1 or any other ordinary code while Docker's sticky `OOMKilled` flag was set. The flag is container-wide (moby/moby#43564): it turns on when _any_ process in the container is OOM-killed, such as a `rustc` child under `cargo test`, and stays on until the next start. It now counts as the exit reason only when the command itself died by SIGKILL (exit 137) or its exit code is unknown, the same rule `--on-kill-resume` uses since #178. `oomKilled: true` is still reported. A stale cgroup reason stored by an older version is dropped by `--status`. An attached container kept after a child OOM kill now says `Docker reports a process in it was OOM-killed`.
14
+
15
+ Add `--on-kill-resume-delay <min[-max]>` for `--on-kill-resume`/`--recovery-command`: wait a uniformly random number of seconds (e.g. `30-90`; one number is a fixed delay, the default `0` resumes at once) before each recovery, so executions killed by the same host-wide OOM event do not all restart in the same second and trigger the next one. The chosen delay is printed in the `[Recovery k/N]` line, stored as `delayMs` in `recoveryHistory` and as `lastRecoveryDelayMs`, and shown in the `[Isolation] On kill:` line. A `--stop` or `--terminate` during the wait cancels the pending resume; `--terminate` of the already exited container now reports `recovery-cancelled` instead of failing.
16
+
17
+ Record the container's cgroup v2 memory counters for detached Docker executions: the completion watcher samples `memory.max`, `memory.peak` and the `memory.events` `oom`/`oom_kill` counters every second (and once more after the container exits, since the cgroup disappears with it). They are written as a `Memory:` line after the container post-mortem, stored as `cgroupMemory` (`limitBytes`, `peakBytes`, `oomEvents`, `oomKills`) and shown by `--status` as `Cgroup Memory:` with a note telling a container-limit OOM (`oom_kill == oom`) from a host-wide or parent-cgroup OOM (`oom_kill > oom`). A non-zero `oomKills` explains a SIGKILL or unknown exit as a cgroup OOM kill, and each `--on-kill-resume` `recoveryHistory` entry notes `oomEvents`/`oomKills` of the killed run. Hosts without cgroup v2 or with a remote `DOCKER_HOST` record nothing.
18
+
3
19
  ## 0.35.1
4
20
 
5
21
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.35.1",
3
+ "version": "0.35.3",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -197,6 +197,7 @@ function parseArgs(args) {
197
197
  networkAliases: [], // Docker network-scoped aliases
198
198
  onKillResume: null, // Resume up to N times when the docker main process is killed (issue #176)
199
199
  recoveryCommand: null, // Command run in the same container on such a resume (issue #176)
200
+ onKillResumeDelay: null, // Random <min>[-<max>] seconds to wait before each such resume (issue #181)
200
201
  endpoint: null, // SSH endpoint (current level, e.g., user@host)
201
202
  endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
202
203
  user: false, // Create isolated user
@@ -0,0 +1,288 @@
1
+ /**
2
+ * cgroup v2 memory counters of a detached docker container (issue #182).
3
+ *
4
+ * Docker's `State.OOMKilled` is container-wide and sticky (moby/moby#43564):
5
+ * it says that *some* process in the container was OOM-killed at some point,
6
+ * but not how many, not whether the container hit its own `--memory` limit or
7
+ * the whole host ran out, and not how close the execution came to its limit.
8
+ * The kernel provides raw counters per cgroup
9
+ * (https://docs.kernel.org/admin-guide/cgroup-v2.html#memory-interface-files):
10
+ * memory.events `oom` allocation events reaching the memory limit
11
+ * memory.events `oom_kill` processes killed here by *any* OOM killer
12
+ * memory.peak / memory.max peak usage (Linux 5.19+) and limit (`max` when
13
+ * unlimited)
14
+ * These counters have different units and are hierarchical. A group OOM can
15
+ * kill several processes for one allocation event; earlier allocation events
16
+ * can also coexist with a later host OOM. Their comparison cannot establish
17
+ * container, parent or host scope (issue #185).
18
+ *
19
+ * The cgroup is removed when the container stops, so the values cannot be read
20
+ * afterwards. The detached completion watcher therefore samples them while the
21
+ * container runs and hands the last sample to the post-mortem, the finalizer
22
+ * and the recovery step. Best effort by design: no cgroup v2, a remote docker
23
+ * daemon or a hidden `/proc` leave the counters unknown, and a kill in the last
24
+ * sampling interval can be missed when the cgroup is gone before the final
25
+ * read.
26
+ */
27
+
28
+ const { shellQuote } = require('./isolation-log-utils');
29
+
30
+ /** Seconds between two samples of a running container's cgroup. */
31
+ const CGROUP_SAMPLE_INTERVAL_SECONDS = 1;
32
+
33
+ /** Shell variables used by the sampler snippets. */
34
+ const CGROUP_SHELL_VARS = {
35
+ /** Last sample: `<memory.max> <memory.peak> <oom> <oom_kill>`. */
36
+ sample: '__start_command_cgroup',
37
+ sampler: '__start_command_cgroup_sampler',
38
+ file: '__start_command_cgroup_file',
39
+ };
40
+
41
+ /**
42
+ * Shell functions shared by the sampler and its final read:
43
+ * __start_command_cgroup_dir NAME print the container's cgroup v2 directory
44
+ * __start_command_cgroup_read DIR print `<max> <peak> <oom> <oom_kill> DIR`
45
+ *
46
+ * The directory comes from `/proc/<.State.Pid>/cgroup` (`0::<path>`), which
47
+ * covers the systemd and cgroupfs drivers, `--cgroup-parent` and rootless
48
+ * docker alike; the two default layouts are fallbacks. A candidate must name
49
+ * the container ID, so a PID reported by a remote daemon can never select an
50
+ * unrelated local cgroup. `START_COMMAND_CGROUP_ROOT` and
51
+ * `START_COMMAND_PROC_ROOT` replace `/sys/fs/cgroup` and `/proc` (tests).
52
+ *
53
+ * @returns {string} Shell function definitions
54
+ */
55
+ function buildCgroupFunctionsSnippet() {
56
+ return [
57
+ '__start_command_cgroup_dir() { __scd_id=$(docker inspect -f \'{{.Id}}\' "$1" 2>/dev/null); ' +
58
+ '[ -n "$__scd_id" ] || return 1; ' +
59
+ "__scd_pid=$(docker inspect -f '{{.State.Pid}}' \"$1\" 2>/dev/null); __scd_rel=''; " +
60
+ 'case "$__scd_pid" in \'\'|0|*[!0-9]*) ;; ' +
61
+ '*) __scd_rel=$(sed -n \'s/^0:://p\' "${START_COMMAND_PROC_ROOT:-/proc}/$__scd_pid/cgroup" 2>/dev/null);; esac; ' +
62
+ '__scd_root=${START_COMMAND_CGROUP_ROOT:-/sys/fs/cgroup}; ' +
63
+ 'for __scd_dir in ${__scd_rel:+"$__scd_root$__scd_rel"} ' +
64
+ '"$__scd_root/system.slice/docker-$__scd_id.scope" "$__scd_root/docker/$__scd_id"; do ' +
65
+ 'case "$__scd_dir" in *"$__scd_id"*) if [ -r "$__scd_dir/memory.events" ]; then ' +
66
+ 'printf \'%s\' "$__scd_dir"; return 0; fi;; esac; done; return 1; }',
67
+ '__start_command_cgroup_read() { __scr_events=$(cat "$1/memory.events" 2>/dev/null) || return 1; ' +
68
+ "__scr_oom=$(printf '%s\\n' \"$__scr_events\" | sed -n 's/^oom //p'); " +
69
+ "__scr_kill=$(printf '%s\\n' \"$__scr_events\" | sed -n 's/^oom_kill //p'); " +
70
+ '__scr_max=$(cat "$1/memory.max" 2>/dev/null); __scr_peak=$(cat "$1/memory.peak" 2>/dev/null); ' +
71
+ 'printf \'%s %s %s %s %s\\n\' "${__scr_max:--}" "${__scr_peak:--}" ' +
72
+ '"${__scr_oom:--}" "${__scr_kill:--}" "$1"; }',
73
+ ].join('; ');
74
+ }
75
+
76
+ /**
77
+ * Shell fragment starting the background sampler. It writes each sample to a
78
+ * temp file with `mv`, so the watcher never reads a half-written line, and
79
+ * ends by itself once the cgroup is gone.
80
+ * @param {string} containerName - Container to sample
81
+ * @returns {string} Shell command
82
+ */
83
+ function buildCgroupSamplerStartSnippet(containerName) {
84
+ const v = CGROUP_SHELL_VARS;
85
+ return [
86
+ buildCgroupFunctionsSnippet(),
87
+ `${v.file}="\${TMPDIR:-/tmp}/start-command-cgroup.$$"`,
88
+ `rm -f "$${v.file}" "$${v.file}.tmp"`,
89
+ `( __scs_dir=$(__start_command_cgroup_dir ${shellQuote(containerName)}) || exit 0; ` +
90
+ 'while __scs_line=$(__start_command_cgroup_read "$__scs_dir"); do ' +
91
+ `printf '%s\\n' "$__scs_line" > "$${v.file}.tmp" && mv -f "$${v.file}.tmp" "$${v.file}"; ` +
92
+ `sleep ${CGROUP_SAMPLE_INTERVAL_SECONDS}; done ) >/dev/null 2>&1 & ${v.sampler}=$!`,
93
+ ].join('; ');
94
+ }
95
+
96
+ /**
97
+ * Shell fragment stopping the sampler once the container has exited and
98
+ * leaving the last sample (`<max> <peak> <oom> <oom_kill>`, or empty) in
99
+ * `$__start_command_cgroup`. The cgroup can outlive the main process for a
100
+ * moment, so it is read one last time when it still exists.
101
+ * @returns {string} Shell command
102
+ */
103
+ function buildCgroupSamplerStopSnippet() {
104
+ const v = CGROUP_SHELL_VARS;
105
+ return [
106
+ `kill "$${v.sampler}" 2>/dev/null`,
107
+ `wait "$${v.sampler}" 2>/dev/null`,
108
+ `${v.sample}=$(cat "$${v.file}" 2>/dev/null)`,
109
+ `__scs_dir=$(printf '%s' "$${v.sample}" | cut -s -d ' ' -f 5-)`,
110
+ `if [ -n "$__scs_dir" ] && __scs_line=$(__start_command_cgroup_read "$__scs_dir"); then ${v.sample}=$__scs_line; fi`,
111
+ `rm -f "$${v.file}" "$${v.file}.tmp"`,
112
+ `${v.sample}=$(printf '%s' "$${v.sample}" | cut -d ' ' -f 1-4)`,
113
+ ].join('; ');
114
+ }
115
+
116
+ /**
117
+ * Shell fragment appending the `Memory:` line of the post-mortem when a
118
+ * sample exists.
119
+ * @param {string} quotedLogPath - Already shell-quoted log path
120
+ * @returns {string} Shell command
121
+ */
122
+ function buildCgroupMemoryLogSnippet(quotedLogPath) {
123
+ const v = CGROUP_SHELL_VARS;
124
+ // A function, so `$1`.. of the watcher script itself stay untouched.
125
+ return (
126
+ `__start_command_cgroup_log() { __scm_note=''; ` +
127
+ `if [ "$4" -gt 0 ] 2>/dev/null; then ` +
128
+ `__scm_note=' (${OOM_SCOPE_NOTES[OOM_SCOPE.UNKNOWN]})'; fi; ` +
129
+ `printf 'Memory: memory.max=%s memory.peak=%s oom=%s oom_kill=%s%s\\n' ` +
130
+ `"$1" "$2" "$3" "$4" "$__scm_note"; }; ` +
131
+ `if [ -n "$${v.sample}" ]; then __start_command_cgroup_log $${v.sample} >> ${quotedLogPath}; fi`
132
+ );
133
+ }
134
+
135
+ /**
136
+ * The `Memory:` line for a sample, exactly as the watcher's shell writes it.
137
+ * Used where JavaScript writes the post-mortem itself (the recovery step).
138
+ * @param {string|null|undefined} text - `<memory.max> <memory.peak> <oom> <oom_kill>`
139
+ * @returns {?string} Line without a trailing newline, or null without a sample
140
+ */
141
+ function formatCgroupMemoryLogLine(text) {
142
+ const counters = parseCgroupMemorySample(text);
143
+ if (!counters) {
144
+ return null;
145
+ }
146
+ const [max, peak, oom, kill] = String(text).trim().split(/\s+/);
147
+ const scope = describeCgroupOomScope(counters);
148
+ return `Memory: memory.max=${max} memory.peak=${peak} oom=${oom} oom_kill=${kill}${
149
+ scope ? ` (${OOM_SCOPE_NOTES[scope]})` : ''
150
+ }`;
151
+ }
152
+
153
+ /** Where the OOM killer that killed processes in the container came from. */
154
+ const OOM_SCOPE = {
155
+ UNKNOWN: 'unknown',
156
+ // Retained scope names; raw counters alone cannot establish either one.
157
+ CONTAINER_LIMIT: 'container-limit',
158
+ HOST_OR_PARENT: 'host-or-parent',
159
+ };
160
+
161
+ const OOM_SCOPE_NOTES = {
162
+ [OOM_SCOPE.UNKNOWN]: 'OOM kill scope unknown',
163
+ [OOM_SCOPE.CONTAINER_LIMIT]: 'the container hit its own memory limit',
164
+ [OOM_SCOPE.HOST_OR_PARENT]:
165
+ 'a host-wide or parent cgroup OOM killed processes here',
166
+ };
167
+
168
+ function parseCounter(text) {
169
+ return /^\d+$/.test(text || '') ? Number(text) : null;
170
+ }
171
+
172
+ /**
173
+ * Parse a sample written by the watcher.
174
+ * @param {string|null|undefined} text - `<memory.max> <memory.peak> <oom> <oom_kill>`
175
+ * @returns {?{limitBytes: ?number, peakBytes: ?number, oomEvents: ?number, oomKills: ?number}}
176
+ * Counters (`limitBytes` is null without a limit), or null without a sample
177
+ */
178
+ function parseCgroupMemorySample(text) {
179
+ const fields = String(text === null || text === undefined ? '' : text)
180
+ .trim()
181
+ .split(/\s+/);
182
+ if (fields.length < 4) {
183
+ return null;
184
+ }
185
+ const [max, peak, oom, kill] = fields;
186
+ const counters = {
187
+ limitBytes: parseCounter(max),
188
+ peakBytes: parseCounter(peak),
189
+ oomEvents: parseCounter(oom),
190
+ oomKills: parseCounter(kill),
191
+ };
192
+ if (counters.oomEvents === null && counters.oomKills === null) {
193
+ // memory.events was unreadable: the line carries no information.
194
+ return null;
195
+ }
196
+ return counters;
197
+ }
198
+
199
+ /**
200
+ * Normalize counters read back from an execution record.
201
+ * @param {*} value - `record.cgroupMemory`
202
+ * @returns {?{limitBytes: ?number, peakBytes: ?number, oomEvents: ?number, oomKills: ?number}}
203
+ */
204
+ function normalizeCgroupMemory(value) {
205
+ if (!value || typeof value !== 'object') {
206
+ return null;
207
+ }
208
+ const counter = (field) => {
209
+ const raw = value[field];
210
+ const number = typeof raw === 'string' && raw !== '' ? Number(raw) : raw;
211
+ return Number.isSafeInteger(number) && number >= 0 ? number : null;
212
+ };
213
+ return {
214
+ limitBytes: counter('limitBytes'),
215
+ peakBytes: counter('peakBytes'),
216
+ oomEvents: counter('oomEvents'),
217
+ oomKills: counter('oomKills'),
218
+ };
219
+ }
220
+
221
+ /**
222
+ * @param {?object} memory - Counters
223
+ * Raw counters do not attribute the OOM killer's scope. Separately attributed
224
+ * kernel/cgroup evidence would be needed to establish container, parent or
225
+ * host scope. An unknown oomEvents count remains unknown, never zero.
226
+ * @returns {?string} OOM_SCOPE.UNKNOWN for observed kills, otherwise null
227
+ */
228
+ function describeCgroupOomScope(memory) {
229
+ const counters = normalizeCgroupMemory(memory);
230
+ if (!counters || !(counters.oomKills > 0)) {
231
+ return null;
232
+ }
233
+ return OOM_SCOPE.UNKNOWN;
234
+ }
235
+
236
+ function formatBytes(bytes) {
237
+ if (bytes === null) {
238
+ return 'unknown';
239
+ }
240
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
241
+ let value = bytes;
242
+ let unit = 0;
243
+ while (value >= 1024 && unit < units.length - 1) {
244
+ value /= 1024;
245
+ unit++;
246
+ }
247
+ return unit === 0 ? `${bytes} B` : `${value.toFixed(1)} ${units[unit]}`;
248
+ }
249
+
250
+ /**
251
+ * Human-readable counters for `--status`.
252
+ * @param {?object} memory - Counters
253
+ * @returns {?string} e.g. `peak 255.9 MiB of 256.0 MiB limit, oom 0, oom_kill 3 (...)`
254
+ */
255
+ function formatCgroupMemory(memory) {
256
+ const counters = normalizeCgroupMemory(memory);
257
+ if (!counters) {
258
+ return null;
259
+ }
260
+ const limit =
261
+ counters.limitBytes === null
262
+ ? 'no limit'
263
+ : `${formatBytes(counters.limitBytes)} limit`;
264
+ const count = (value) => (value === null ? 'unknown' : value);
265
+ const scope = describeCgroupOomScope(counters);
266
+ return (
267
+ `peak ${formatBytes(counters.peakBytes)} of ${limit}, ` +
268
+ `oom ${count(counters.oomEvents)}, oom_kill ${count(counters.oomKills)}${
269
+ scope ? ` (${OOM_SCOPE_NOTES[scope]})` : ''
270
+ }`
271
+ );
272
+ }
273
+
274
+ module.exports = {
275
+ CGROUP_SAMPLE_INTERVAL_SECONDS,
276
+ CGROUP_SHELL_VARS,
277
+ OOM_SCOPE,
278
+ OOM_SCOPE_NOTES,
279
+ buildCgroupFunctionsSnippet,
280
+ buildCgroupMemoryLogSnippet,
281
+ buildCgroupSamplerStartSnippet,
282
+ buildCgroupSamplerStopSnippet,
283
+ describeCgroupOomScope,
284
+ formatCgroupMemory,
285
+ formatCgroupMemoryLogLine,
286
+ normalizeCgroupMemory,
287
+ parseCgroupMemorySample,
288
+ };
@@ -23,6 +23,10 @@ const {
23
23
  normalizeDockerTimestamp,
24
24
  } = require('./docker-post-mortem');
25
25
  const { shellQuote } = require('./isolation-log-utils');
26
+ const {
27
+ CGROUP_SHELL_VARS,
28
+ parseCgroupMemorySample,
29
+ } = require('./cgroup-memory');
26
30
 
27
31
  /** Provenance markers for `endTime` (issue #170.2). */
28
32
  const END_TIME_SOURCE = {
@@ -57,6 +61,8 @@ const WATCHER_LOST_CONTAINER = 'watcher-lost-container';
57
61
  * @param {string|null} [options.finishedAt] - `.State.FinishedAt`
58
62
  * @param {string|null} [options.containerError] - `.State.Error`
59
63
  * @param {boolean|string|null} [options.running] - `.State.Running`
64
+ * @param {string|null} [options.cgroupMemory] - Last cgroup v2 sample of the
65
+ * watcher, `<memory.max> <memory.peak> <oom> <oom_kill>` (issue #182)
60
66
  * @returns {{updated: boolean, reason?: string, record?: Object}} Outcome
61
67
  */
62
68
  function finalizeDetachedExecution(options = {}) {
@@ -123,6 +129,12 @@ function finalizeDetachedExecution(options = {}) {
123
129
  record.oomKilled = oomKilled;
124
130
  }
125
131
 
132
+ // Counted by the kernel for this run only, unlike the sticky `OOMKilled`.
133
+ const cgroupMemory = parseCgroupMemorySample(options.cgroupMemory);
134
+ if (cgroupMemory) {
135
+ record.cgroupMemory = cgroupMemory;
136
+ }
137
+
126
138
  const exitReason =
127
139
  resolveReason(record) || (finishedAt ? null : WATCHER_LOST_CONTAINER);
128
140
  if (exitReason) {
@@ -162,6 +174,7 @@ function resolveReason(record) {
162
174
  exitCode: record.exitCode,
163
175
  logTail,
164
176
  oomKilled: record.oomKilled,
177
+ cgroupMemory: record.cgroupMemory,
165
178
  });
166
179
  } catch {
167
180
  return null;
@@ -244,13 +257,13 @@ function buildDetachedFinalizeSnippet(executionId) {
244
257
  `${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
245
258
  `${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
246
259
  `"$${v.started}" "$${v.finished}" "$${v.error}" "$${v.running}" ` +
247
- `>/dev/null 2>&1 || true`
260
+ `"$${CGROUP_SHELL_VARS.sample}" >/dev/null 2>&1 || true`
248
261
  );
249
262
  }
250
263
 
251
264
  /**
252
265
  * Entry point used by the detached watcher:
253
- * <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error> <running>
266
+ * <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error> <running> <cgroup>
254
267
  * Always exits 0 — a bookkeeping failure must never turn into a visible error
255
268
  * in a log the user is reading for the command's own output.
256
269
  * @param {string[]} argv - Positional arguments
@@ -265,6 +278,7 @@ function main(argv) {
265
278
  finishedAt,
266
279
  containerError,
267
280
  running,
281
+ cgroupMemory,
268
282
  ] = argv;
269
283
  if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
270
284
  return;
@@ -285,6 +299,7 @@ function main(argv) {
285
299
  finishedAt,
286
300
  containerError,
287
301
  running,
302
+ cgroupMemory,
288
303
  });
289
304
  } catch {
290
305
  // Deliberately silent: see the exit-0 contract above.
@@ -6,7 +6,10 @@ const {
6
6
  readLogTail,
7
7
  shellQuote,
8
8
  } = require('./isolation-log-utils');
9
- const { resolveMemoryExhaustion } = require('./exit-reason');
9
+ const {
10
+ isOomKillOfCommand,
11
+ resolveMemoryExhaustion,
12
+ } = require('./exit-reason');
10
13
  const {
11
14
  buildDockerPostMortemSnippet,
12
15
  buildDockerRemovalNoteSnippet,
@@ -20,6 +23,11 @@ const {
20
23
  SHELL_VARS,
21
24
  } = require('./docker-post-mortem');
22
25
  const { buildDetachedFinalizeSnippet } = require('./detached-finalize');
26
+ const {
27
+ buildCgroupMemoryLogSnippet,
28
+ buildCgroupSamplerStartSnippet,
29
+ buildCgroupSamplerStopSnippet,
30
+ } = require('./cgroup-memory');
23
31
 
24
32
  const DOCKER_CONTAINER_CLEANUP_POLICY = {
25
33
  DEFAULT: 'default',
@@ -266,10 +274,16 @@ function buildAttachedDockerKeptMessage({
266
274
  oomKilled,
267
275
  logPath,
268
276
  }) {
269
- let message =
270
- oomKilled === true
271
- ? `\nContainer kept because Docker reports it was OOM-killed.`
272
- : `\nContainer kept because the command failed.`;
277
+ let message;
278
+ if (oomKilled !== true) {
279
+ message = `\nContainer kept because the command failed.`;
280
+ } else if (isOomKillOfCommand({ exitCode, oomKilled })) {
281
+ message = `\nContainer kept because Docker reports it was OOM-killed.`;
282
+ } else {
283
+ // The flag is container-wide (#180): a child was OOM-killed, the command
284
+ // itself exited on its own.
285
+ message = `\nContainer kept because Docker reports a process in it was OOM-killed.`;
286
+ }
273
287
  const memory = resolveMemoryExhaustion({
274
288
  exitCode,
275
289
  logTail: logPath ? readLogTail(logPath, FATAL_MARKER_TAIL_BYTES) : null,
@@ -343,7 +357,9 @@ function buildDetachedDockerCompletionScript(
343
357
  watcherOptions = {}
344
358
  ) {
345
359
  const quotedName = shellQuote(containerName);
346
- const parts = [];
360
+ // The container's cgroup disappears with it, so its memory counters are
361
+ // sampled while it runs (issue #182).
362
+ const parts = [buildCgroupSamplerStartSnippet(containerName)];
347
363
  // Everything that assumes the container has exited: cleanup, footer and
348
364
  // finalization. Guarded as a whole by `.State.Running` below.
349
365
  const exited = [];
@@ -358,14 +374,16 @@ function buildDetachedDockerCompletionScript(
358
374
  containerName,
359
375
  quotedLogPath
360
376
  );
361
- const remove = `docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; ${removalNote}`;
362
- const keep = `${postMortem}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
377
+ const memory = buildCgroupMemoryLogSnippet(quotedLogPath);
378
+ const remove = `docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; ${removalNote}; ${memory}`;
379
+ const keep = `${postMortem}; ${memory}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
363
380
 
364
381
  const since = watcherOptions.since
365
382
  ? ` --since ${shellQuote(watcherOptions.since)}`
366
383
  : '';
367
384
  parts.push(`docker logs -f${since} ${quotedName} >> ${quotedLogPath} 2>&1`);
368
385
  parts.push(buildDockerWaitForExitSnippet(containerName, quotedLogPath));
386
+ parts.push(buildCgroupSamplerStopSnippet());
369
387
  parts.push(buildDockerStateSnippet(containerName));
370
388
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
371
389
  exited.push(remove);
@@ -386,6 +404,7 @@ function buildDetachedDockerCompletionScript(
386
404
  } else {
387
405
  parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
388
406
  parts.push(buildDockerWaitForExitSnippet(containerName));
407
+ parts.push(buildCgroupSamplerStopSnippet());
389
408
  parts.push(buildDockerStateSnippet(containerName));
390
409
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
391
410
  exited.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
@@ -4,8 +4,12 @@
4
4
  * killed (exit 137 / OOMKilled)
5
5
  * --recovery-command <cmd> command to run in the same container on resume
6
6
  * (without it, the original command is re-run)
7
+ * --on-kill-resume-delay <s> wait a random <min>[-<max>] seconds before
8
+ * each resume (issue #181)
7
9
  */
8
10
 
11
+ const { parseOnKillResumeDelayValue } = require('./recovery-delay');
12
+
9
13
  function parseOnKillResumeValue(value) {
10
14
  const text = String(value).trim();
11
15
  const count = Number(text);
@@ -19,18 +23,34 @@ function parseOnKillResumeValue(value) {
19
23
 
20
24
  function parseDockerRecoveryOption(args, index, options) {
21
25
  const arg = args[index];
22
- if (arg === '--on-kill-resume' || arg === '--recovery-command') {
26
+ if (
27
+ arg === '--on-kill-resume' ||
28
+ arg === '--recovery-command' ||
29
+ arg === '--on-kill-resume-delay'
30
+ ) {
23
31
  if (index + 1 >= args.length || args[index + 1].startsWith('-')) {
24
- const value = arg === '--on-kill-resume' ? 'count' : 'command';
32
+ const value = {
33
+ '--on-kill-resume': 'count',
34
+ '--recovery-command': 'command',
35
+ '--on-kill-resume-delay': 'seconds',
36
+ }[arg];
25
37
  throw new Error(`Option ${arg} requires a ${value} argument`);
26
38
  }
27
39
  if (arg === '--on-kill-resume') {
28
40
  options.onKillResume = parseOnKillResumeValue(args[index + 1]);
41
+ } else if (arg === '--on-kill-resume-delay') {
42
+ options.onKillResumeDelay = parseOnKillResumeDelayValue(args[index + 1]);
29
43
  } else {
30
44
  options.recoveryCommand = args[index + 1];
31
45
  }
32
46
  return 2;
33
47
  }
48
+ if (arg.startsWith('--on-kill-resume-delay=')) {
49
+ options.onKillResumeDelay = parseOnKillResumeDelayValue(
50
+ arg.slice('--on-kill-resume-delay='.length)
51
+ );
52
+ return 1;
53
+ }
34
54
  if (arg.startsWith('--on-kill-resume=')) {
35
55
  options.onKillResume = parseOnKillResumeValue(
36
56
  arg.slice('--on-kill-resume='.length)
@@ -47,7 +67,7 @@ function parseDockerRecoveryOption(args, index, options) {
47
67
  /**
48
68
  * Recovery is driven by the detached docker completion watcher, so it needs a
49
69
  * detached session whose only isolation level is docker. A recovery command on
50
- * its own implies one attempt.
70
+ * its own implies one attempt; a delay without either has nothing to delay.
51
71
  * @param {object} options - Parsed options
52
72
  * @throws {Error} If the recovery options are used where they cannot work
53
73
  */
@@ -62,6 +82,14 @@ function validateDockerRecoveryOptions(options) {
62
82
  options.onKillResume ??= 1;
63
83
  }
64
84
  if (!options.onKillResume) {
85
+ if (
86
+ options.onKillResumeDelay !== null &&
87
+ options.onKillResumeDelay !== undefined
88
+ ) {
89
+ throw new Error(
90
+ '--on-kill-resume-delay requires --on-kill-resume or --recovery-command'
91
+ );
92
+ }
65
93
  return;
66
94
  }
67
95
  const flag = options.recoveryCommand
@@ -9,11 +9,12 @@ const {
9
9
  buildResourceLimitsStatusLine,
10
10
  normalizeResourceLimits,
11
11
  } = require('./docker-resource-limits');
12
+ const { getOnKillResumeDelay } = require('./recovery-delay');
12
13
 
13
14
  /**
14
15
  * Number of kill recoveries requested at launch; a recovery command on its own
15
16
  * implies one (issue #176).
16
- * @param {object} options - Options (onKillResume, recoveryCommand)
17
+ * @param {object} options - Options (onKillResume, recoveryCommand, onKillResumeDelay)
17
18
  * @returns {?number} Attempt limit, or null when recovery is off
18
19
  */
19
20
  function getOnKillResume(options = {}) {
@@ -63,7 +64,7 @@ function buildDockerRuntimeArgs(options = {}) {
63
64
  * options (volumes, mounts, env, privileged, networks, resource limits and
64
65
  * the launch-time recovery command). Empty collections and a falsy privileged
65
66
  * flag contribute no lines.
66
- * @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases, resourceLimits, onKillResume, recoveryCommand)
67
+ * @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases, resourceLimits, onKillResume, recoveryCommand, onKillResumeDelay)
67
68
  * @returns {string[]} Status lines for the start block / log header
68
69
  */
69
70
  function buildDockerRuntimeStatusLines(options = {}) {
@@ -99,8 +100,10 @@ function buildDockerRuntimeStatusLines(options = {}) {
99
100
  const onKillResume = getOnKillResume(options);
100
101
  if (onKillResume) {
101
102
  const what = options.recoveryCommand || 'the original command';
103
+ const delay = getOnKillResumeDelay(options);
104
+ const after = delay ? ` after a random ${delay}s delay` : '';
102
105
  lines.push(
103
- `[Isolation] On kill: resume up to ${onKillResume} time(s) with ${what}`
106
+ `[Isolation] On kill: resume up to ${onKillResume} time(s)${after} with ${what}`
104
107
  );
105
108
  }
106
109
  return lines;
@@ -109,7 +112,7 @@ function buildDockerRuntimeStatusLines(options = {}) {
109
112
  /**
110
113
  * Build the execution-record metadata for docker runtime options, normalizing
111
114
  * empty collections and a falsy privileged flag to `null`.
112
- * @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases, resourceLimits, onKillResume, recoveryCommand)
115
+ * @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases, resourceLimits, onKillResume, recoveryCommand, onKillResumeDelay)
113
116
  * @returns {object} Record metadata
114
117
  */
115
118
  function buildDockerRuntimeMetadata(options = {}) {
@@ -130,6 +133,9 @@ function buildDockerRuntimeMetadata(options = {}) {
130
133
  resourceLimits: resourceLimits.length > 0 ? resourceLimits : null,
131
134
  onKillResume: getOnKillResume(options),
132
135
  recoveryCommand: options.recoveryCommand || null,
136
+ onKillResumeDelay: getOnKillResume(options)
137
+ ? getOnKillResumeDelay(options)
138
+ : null,
133
139
  };
134
140
  }
135
141
 
@@ -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 =