start-command 0.34.0 → 0.35.0

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,17 @@
1
1
  # start-command
2
2
 
3
+ ## 0.35.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9679323: Keep Docker resource limits across `--resume <id> -- <command>`, and add `--on-kill-resume <N>` / `--recovery-command <cmd>`. `docker commit` does not capture HostConfig, so the snapshot resume now reads the stopped container's limits with `docker inspect` (including ones applied later with `docker update`) and re-applies the non-default ones (`--memory`, `--memory-swap`, `--memory-reservation`, `--cpus`/`--cpu-quota`/`--cpu-period`, `--cpu-shares`, `--cpuset-cpus`, `--cpuset-mems`, `--pids-limit`, `--shm-size`, `--storage-opt`, `--ulimit`) to the `-resume-N` container, prints them as `[Isolation] Resource limits: ...` and stores them as `resourceLimits`. With `--on-kill-resume <N>` a detached Docker execution whose main process is killed (exit 137 or `OOMKilled`) is resumed in the same container up to N times, running `--recovery-command` (or the original command) with `START_COMMAND_RECOVERY_ATTEMPT` set. The UUID and log file are kept, each attempt is separated by a `[Recovery k/N]` line, and `--status` shows `recoveryAttempts` and `recoveryHistory`. `--stop` cancels further recovery.
8
+
9
+ ## 0.34.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 2dd9a41: Never finalize a still-running detached Docker container as a success. `docker logs -f` also returns when its own write to the log fails (ENOSPC) or when dockerd restarts, so the completion watcher now asks `docker inspect -f '{{.State.Running}}'` and keeps waiting with `docker wait` until the container really exits. If the watcher still sees a running container, it notes this in the log and leaves the container and the execution record alone: no `docker rm -f`, no `Exit Code: 0` footer, and no `executed` status. A zero `FinishedAt` (`0001-01-01T00:00:00Z`) combined with an exit code of 0 is recorded as `-1` with `exitReason: watcher-lost-container`, not as a success. A child process that is killed by a signal now reports `128+n` (for example `137` for SIGKILL) instead of `0` in `runWithNodeSpawn`, `runAsIsolatedUser` and the command-stream wrapper.
14
+
3
15
  ## 0.34.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
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": {
@@ -72,7 +72,7 @@
72
72
  "lino-objects-codec": "^0.4.0"
73
73
  },
74
74
  "overrides": {
75
- "brace-expansion": "^5.0.9",
75
+ "brace-expansion": "^5.0.12",
76
76
  "flatted": "^3.4.2",
77
77
  "js-yaml": "^4.3.2"
78
78
  }
package/src/bin/cli.js CHANGED
@@ -38,6 +38,7 @@ const { dispatchQueryCommand, hasQueryMode } = require('../lib/query-commands');
38
38
  const { printVersion } = require('../lib/version');
39
39
  const { createStartBlock, createFinishBlock } = require('../lib/output-blocks');
40
40
  const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
41
+ const { resolveChildExitCode } = require('../lib/exit-reason');
41
42
  const { printUsage } = require('../lib/usage');
42
43
  const { buildDisplayCommand, getCommandName } = require('../lib/shell-utils');
43
44
 
@@ -820,7 +821,7 @@ async function runDirectWithCommandStream(
820
821
  // Use raw() to pass the command without auto-escaping
821
822
  // This is important for complex commands with pipes, redirects, etc.
822
823
  const result = await $cmd`${raw(cmd)}`;
823
- exitCode = result.code || 0;
824
+ exitCode = resolveChildExitCode(result.code);
824
825
 
825
826
  // Update PID if available from result
826
827
  if (executionRecord && result.pid) {
@@ -43,6 +43,7 @@
43
43
 
44
44
  const { getDefaultDockerImage } = require('./docker-utils');
45
45
  const dockerNetworkOptions = require('./docker-network-options');
46
+ const dockerRecoveryOptions = require('./docker-recovery-options');
46
47
  const { parseSequence, isSequence } = require('./sequence-parser');
47
48
  const { buildCommandString } = require('./shell-utils');
48
49
  const {
@@ -194,6 +195,8 @@ function parseArgs(args) {
194
195
  network: null, // First Docker network name (compatibility accessor)
195
196
  networks: [], // Ordered Docker network names
196
197
  networkAliases: [], // Docker network-scoped aliases
198
+ onKillResume: null, // Resume up to N times when the docker main process is killed (issue #176)
199
+ recoveryCommand: null, // Command run in the same container on such a resume (issue #176)
197
200
  endpoint: null, // SSH endpoint (current level, e.g., user@host)
198
201
  endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
199
202
  user: false, // Create isolated user
@@ -410,6 +413,11 @@ function parseOption(args, index, options) {
410
413
  return networkOption;
411
414
  }
412
415
 
416
+ const recoveryOption = dockerRecoveryOptions.parse(args, index, options);
417
+ if (recoveryOption) {
418
+ return recoveryOption;
419
+ }
420
+
413
421
  // --endpoint (for ssh) - supports sequence for stacked isolation
414
422
  if (arg === '--endpoint') {
415
423
  if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
@@ -746,6 +754,8 @@ function validateOptions(options) {
746
754
  validateDockerRuntimeOptionsRequireDocker(options);
747
755
  }
748
756
 
757
+ dockerRecoveryOptions.validate(options);
758
+
749
759
  // Session name is only valid with isolation
750
760
  if (options.session && !options.isolated) {
751
761
  throw new Error('--session option is only valid with --isolated');
@@ -11,6 +11,8 @@
11
11
  * - Real-time output capture
12
12
  */
13
13
 
14
+ const { resolveChildExitCode } = require('./exit-reason');
15
+
14
16
  // Debug mode from environment
15
17
  const DEBUG =
16
18
  process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
@@ -54,7 +56,7 @@ async function execCommand(command, options = {}) {
54
56
  return {
55
57
  stdout: (result.stdout || '').trim(),
56
58
  stderr: (result.stderr || '').trim(),
57
- code: result.code || 0,
59
+ code: resolveChildExitCode(result.code),
58
60
  };
59
61
  } catch (err) {
60
62
  if (DEBUG) {
@@ -91,7 +93,7 @@ async function execCommandAsync(command, options = {}) {
91
93
  return {
92
94
  stdout: (result.stdout || '').trim(),
93
95
  stderr: (result.stderr || '').trim(),
94
- code: result.code || 0,
96
+ code: resolveChildExitCode(result.code),
95
97
  };
96
98
  } catch (err) {
97
99
  if (DEBUG) {
@@ -34,6 +34,13 @@ const END_TIME_SOURCE = {
34
34
  OBSERVED_AT: 'observed-at',
35
35
  };
36
36
 
37
+ /**
38
+ * `exitReason` of a record whose container exit the watcher never observed:
39
+ * docker had no `FinishedAt` for it, so neither the exit code nor the end time
40
+ * is a fact (issue #174).
41
+ */
42
+ const WATCHER_LOST_CONTAINER = 'watcher-lost-container';
43
+
37
44
  /**
38
45
  * Persist the terminal state of a detached execution.
39
46
  *
@@ -49,6 +56,7 @@ const END_TIME_SOURCE = {
49
56
  * @param {string|null} [options.startedAt] - `.State.StartedAt`
50
57
  * @param {string|null} [options.finishedAt] - `.State.FinishedAt`
51
58
  * @param {string|null} [options.containerError] - `.State.Error`
59
+ * @param {boolean|string|null} [options.running] - `.State.Running`
52
60
  * @returns {{updated: boolean, reason?: string, record?: Object}} Outcome
53
61
  */
54
62
  function finalizeDetachedExecution(options = {}) {
@@ -56,6 +64,11 @@ function finalizeDetachedExecution(options = {}) {
56
64
  if (!store || !executionId) {
57
65
  return { updated: false, reason: 'missing-arguments' };
58
66
  }
67
+ if (normalizeBoolean(options.running) === true) {
68
+ // A running container has not finished: its record stays `executing`, and
69
+ // `--status` keeps probing the live container (issue #174).
70
+ return { updated: false, reason: 'still-running' };
71
+ }
59
72
 
60
73
  let record;
61
74
  try {
@@ -72,15 +85,22 @@ function finalizeDetachedExecution(options = {}) {
72
85
  return { updated: false, reason: 'already-final', record };
73
86
  }
74
87
 
88
+ const finishedAt = normalizeDockerTimestamp(options.finishedAt);
75
89
  const described = describeExitCode(options.exitCode);
90
+ // Without a `FinishedAt`, `ExitCode=0` is Docker's zero value for a container
91
+ // that has not finished — never an observed success (issue #174).
92
+ const exitCode = described.code === 0 && !finishedAt ? null : described.code;
76
93
  record.status = 'executed';
77
- if (described.code !== null) {
78
- record.exitCode = described.code;
79
- } else if (record.exitCode === null || record.exitCode === undefined) {
94
+ if (exitCode !== null) {
95
+ record.exitCode = exitCode;
96
+ } else if (
97
+ record.exitCode === null ||
98
+ record.exitCode === undefined ||
99
+ !finishedAt
100
+ ) {
80
101
  record.exitCode = -1;
81
102
  }
82
103
 
83
- const finishedAt = normalizeDockerTimestamp(options.finishedAt);
84
104
  if (finishedAt) {
85
105
  record.endTime = new Date(finishedAt).toISOString();
86
106
  record.endTimeSource = END_TIME_SOURCE.DOCKER_FINISHED_AT;
@@ -103,7 +123,8 @@ function finalizeDetachedExecution(options = {}) {
103
123
  record.oomKilled = oomKilled;
104
124
  }
105
125
 
106
- const exitReason = resolveReason(record);
126
+ const exitReason =
127
+ resolveReason(record) || (finishedAt ? null : WATCHER_LOST_CONTAINER);
107
128
  if (exitReason) {
108
129
  record.exitReason = exitReason;
109
130
  }
@@ -222,14 +243,14 @@ function buildDetachedFinalizeSnippet(executionId) {
222
243
  return (
223
244
  `${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
224
245
  `${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
225
- `"$${v.started}" "$${v.finished}" "$${v.error}" ` +
246
+ `"$${v.started}" "$${v.finished}" "$${v.error}" "$${v.running}" ` +
226
247
  `>/dev/null 2>&1 || true`
227
248
  );
228
249
  }
229
250
 
230
251
  /**
231
252
  * Entry point used by the detached watcher:
232
- * <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error>
253
+ * <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error> <running>
233
254
  * Always exits 0 — a bookkeeping failure must never turn into a visible error
234
255
  * in a log the user is reading for the command's own output.
235
256
  * @param {string[]} argv - Positional arguments
@@ -243,6 +264,7 @@ function main(argv) {
243
264
  startedAt,
244
265
  finishedAt,
245
266
  containerError,
267
+ running,
246
268
  ] = argv;
247
269
  if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
248
270
  return;
@@ -262,6 +284,7 @@ function main(argv) {
262
284
  startedAt,
263
285
  finishedAt,
264
286
  containerError,
287
+ running,
265
288
  });
266
289
  } catch {
267
290
  // Deliberately silent: see the exit-0 contract above.
@@ -274,6 +297,7 @@ if (require.main === module) {
274
297
 
275
298
  module.exports = {
276
299
  END_TIME_SOURCE,
300
+ WATCHER_LOST_CONTAINER,
277
301
  buildDetachedFinalizeSnippet,
278
302
  finalizeDetachedExecution,
279
303
  main,
@@ -11,10 +11,13 @@ const {
11
11
  buildDockerPostMortemSnippet,
12
12
  buildDockerRemovalNoteSnippet,
13
13
  buildDockerStateSnippet,
14
+ buildDockerStillRunningNoteSnippet,
15
+ buildDockerWaitForExitSnippet,
14
16
  DOCKER_STATE_INSPECT_FORMAT,
15
17
  formatContainerPostMortem,
16
18
  formatContainerRemovalNote,
17
19
  normalizeDockerTimestamp,
20
+ SHELL_VARS,
18
21
  } = require('./docker-post-mortem');
19
22
  const { buildDetachedFinalizeSnippet } = require('./detached-finalize');
20
23
 
@@ -312,20 +315,38 @@ function buildSuccessfulNonOomCondition() {
312
315
  * 3. it hands the same facts to the finalizer so the execution record stops
313
316
  * being `executing` forever (issue #170.1).
314
317
  *
318
+ * It only does any of that once the container has really exited: the return of
319
+ * `docker logs -f` / `docker wait` is not proof of exit (issue #174). A
320
+ * container that is somehow still running afterwards is never removed, gets no
321
+ * `Exit Code:` footer and is never finalized — its record stays `executing`.
322
+ *
323
+ * With `recoverOnKill` (issue #176), a killed main process (exit 137 or
324
+ * `OOMKilled`) is first handed to the recovery entry point. When it resumes
325
+ * the container, this watcher stops here — a new one follows the resumed run —
326
+ * and cleanup, footer and finalization are left to whichever watcher sees the
327
+ * last run end.
328
+ *
315
329
  * @param {string} containerName - Docker container name
316
330
  * @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
317
331
  * @param {string|null} logPath - Log file to append to, or null
318
332
  * @param {string|null} [executionId] - Execution UUID to finalize, or null
333
+ * @param {{since?: string, recoverOnKill?: boolean}} [watcherOptions] -
334
+ * `since` limits `docker logs` to output after a restart, so a resumed run
335
+ * does not copy the previous run's output into the log again
319
336
  * @returns {string} The shell script
320
337
  */
321
338
  function buildDetachedDockerCompletionScript(
322
339
  containerName,
323
340
  policy,
324
341
  logPath,
325
- executionId = null
342
+ executionId = null,
343
+ watcherOptions = {}
326
344
  ) {
327
345
  const quotedName = shellQuote(containerName);
328
346
  const parts = [];
347
+ // Everything that assumes the container has exited: cleanup, footer and
348
+ // finalization. Guarded as a whole by `.State.Running` below.
349
+ const exited = [];
329
350
 
330
351
  if (logPath) {
331
352
  const quotedLogPath = shellQuote(logPath);
@@ -340,34 +361,39 @@ function buildDetachedDockerCompletionScript(
340
361
  const remove = `docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; ${removalNote}`;
341
362
  const keep = `${postMortem}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
342
363
 
343
- parts.push(`docker logs -f ${quotedName} >> ${quotedLogPath} 2>&1`);
364
+ const since = watcherOptions.since
365
+ ? ` --since ${shellQuote(watcherOptions.since)}`
366
+ : '';
367
+ parts.push(`docker logs -f${since} ${quotedName} >> ${quotedLogPath} 2>&1`);
368
+ parts.push(buildDockerWaitForExitSnippet(containerName, quotedLogPath));
344
369
  parts.push(buildDockerStateSnippet(containerName));
345
370
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
346
- parts.push(remove);
371
+ exited.push(remove);
347
372
  } else if (
348
373
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
349
374
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
350
375
  ) {
351
- parts.push(
376
+ exited.push(
352
377
  `if ${buildSuccessfulNonOomCondition()}; then ${remove}; else ${keep}; fi`
353
378
  );
354
379
  } else {
355
380
  // KEEP: the container is never removed, so the log used to end with the
356
381
  // raw command output and nothing else. The post-mortem is exactly the
357
382
  // information an operator needs before deciding what to do with it.
358
- parts.push(keep);
383
+ exited.push(keep);
359
384
  }
360
- parts.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
385
+ exited.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
361
386
  } else {
362
387
  parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
388
+ parts.push(buildDockerWaitForExitSnippet(containerName));
363
389
  parts.push(buildDockerStateSnippet(containerName));
364
390
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
365
- parts.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
391
+ exited.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
366
392
  } else if (
367
393
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
368
394
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
369
395
  ) {
370
- parts.push(
396
+ exited.push(
371
397
  `if ${buildSuccessfulNonOomCondition()}; then docker rm -f ${quotedName} >/dev/null 2>&1 || true; fi`
372
398
  );
373
399
  }
@@ -375,9 +401,23 @@ function buildDetachedDockerCompletionScript(
375
401
 
376
402
  if (executionId) {
377
403
  // Last, so the record is only marked terminal once the log is complete.
378
- parts.push(buildDetachedFinalizeSnippet(executionId));
404
+ exited.push(buildDetachedFinalizeSnippet(executionId));
379
405
  }
380
406
 
407
+ const stillRunning = logPath
408
+ ? buildDockerStillRunningNoteSnippet(containerName, shellQuote(logPath))
409
+ : ':';
410
+ let recovery = '';
411
+ if (executionId && watcherOptions.recoverOnKill) {
412
+ // Lazily required: the recovery module starts watchers itself.
413
+ const { buildRecoverySnippet } = require('./execution-recovery');
414
+ recovery = `elif ${buildRecoverySnippet(executionId)}; then :; `;
415
+ }
416
+ parts.push(
417
+ `if [ "$${SHELL_VARS.running}" = true ]; then ${stillRunning}; ` +
418
+ `${recovery}else ${exited.length ? exited.join('; ') : ':'}; fi`
419
+ );
420
+
381
421
  return parts.join('; ');
382
422
  }
383
423
 
@@ -387,13 +427,16 @@ function buildDetachedDockerCompletionScript(
387
427
  * @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
388
428
  * @param {string|null} logPath - Log file to append to, or null
389
429
  * @param {string|null} [executionId] - Execution UUID to finalize, or null
430
+ * @param {{since?: string, recoverOnKill?: boolean}} [watcherOptions] - See
431
+ * `buildDetachedDockerCompletionScript`
390
432
  * @returns {void}
391
433
  */
392
434
  function startDetachedDockerCompletionWatcher(
393
435
  containerName,
394
436
  policy,
395
437
  logPath,
396
- executionId = null
438
+ executionId = null,
439
+ watcherOptions = {}
397
440
  ) {
398
441
  const watcher = spawn(
399
442
  'sh',
@@ -403,7 +446,8 @@ function startDetachedDockerCompletionWatcher(
403
446
  containerName,
404
447
  policy,
405
448
  logPath,
406
- executionId
449
+ executionId,
450
+ watcherOptions
407
451
  ),
408
452
  ],
409
453
  {
@@ -138,15 +138,83 @@ const SHELL_VARS = {
138
138
  signal: '__start_command_signal',
139
139
  exitText: '__start_command_exit_text',
140
140
  lifetime: '__start_command_lifetime',
141
+ running: '__start_command_running',
141
142
  };
142
143
 
143
144
  /** `docker inspect -f` template collecting every post-mortem fact at once. */
144
145
  const DOCKER_STATE_INSPECT_FORMAT =
145
146
  '{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.StartedAt}} {{.State.FinishedAt}}';
146
147
 
148
+ /** `docker inspect -f` template answering "is the container still running?". */
149
+ const DOCKER_RUNNING_INSPECT_FORMAT = '{{.State.Running}}';
150
+
151
+ /**
152
+ * Written into the log when `docker logs -f` returned while the container was
153
+ * still running (issue #174).
154
+ */
155
+ const LOG_CAPTURE_STOPPED_NOTE =
156
+ 'Log capture stopped early: docker logs -f returned while the container ' +
157
+ 'was still running (a failed write to this log such as ENOSPC, or a dockerd ' +
158
+ 'restart). Waiting for the container to exit.';
159
+
160
+ /**
161
+ * Written into the log when the watcher gave up while the container was still
162
+ * running. No footer follows: an `Exit Code:` line would be a fabrication.
163
+ */
164
+ const STILL_RUNNING_NOTE =
165
+ 'The watcher stopped observing it before it exited, so it was not removed ' +
166
+ 'and the execution record stays executing.';
167
+
168
+ /**
169
+ * Shell condition that holds while the container is running.
170
+ * @param {string} quotedName - Already shell-quoted container name
171
+ * @returns {string} Shell condition
172
+ */
173
+ function buildDockerRunningCondition(quotedName) {
174
+ return `[ "$(docker inspect -f '${DOCKER_RUNNING_INSPECT_FORMAT}' ${quotedName} 2>/dev/null)" = true ]`;
175
+ }
176
+
177
+ /**
178
+ * Shell fragment blocking until the container has really exited.
179
+ *
180
+ * `docker logs -f` and `docker wait` both return early when the *stream* ends
181
+ * rather than the container: `docker logs -f C >> LOG` returns as soon as its
182
+ * own write to LOG fails (ENOSPC, when the container filled the disk the log
183
+ * lives on), and both return when dockerd restarts under live-restore while the
184
+ * container keeps running. The watcher used to take that return as the
185
+ * container's exit, inspect a *running* container — Docker reports its zero
186
+ * values `ExitCode=0 FinishedAt=0001-01-01T00:00:00Z` — and `docker rm -f` the
187
+ * live workload as a success (issue #174). So the fragment loops on
188
+ * `.State.Running`, not on the return of the command it waited on; `sleep 1`
189
+ * keeps a failing `docker wait` from spinning.
190
+ *
191
+ * @param {string} containerName - Container to wait for
192
+ * @param {string|null} [quotedLogPath] - Already shell-quoted log path, to
193
+ * record that log capture stopped early; null when there is no log
194
+ * @returns {string} Shell command
195
+ */
196
+ function buildDockerWaitForExitSnippet(containerName, quotedLogPath = null) {
197
+ const quotedName = shellQuote(containerName);
198
+ const running = buildDockerRunningCondition(quotedName);
199
+ const parts = [];
200
+ if (quotedLogPath) {
201
+ parts.push(
202
+ `if ${running}; then printf '\\n%s\\n' ${shellQuote(LOG_CAPTURE_STOPPED_NOTE)} >> ${quotedLogPath} 2>/dev/null; fi`
203
+ );
204
+ }
205
+ parts.push(
206
+ `while ${running}; do docker wait ${quotedName} >/dev/null 2>&1 || sleep 1; done`
207
+ );
208
+ return parts.join('; ');
209
+ }
210
+
147
211
  /**
148
212
  * Shell fragment reading the container's terminal state.
149
213
  *
214
+ * Also records `.State.Running` in `$__start_command_running`, so the caller
215
+ * can refuse to remove or finalize a container that is still alive
216
+ * (issue #174).
217
+ *
150
218
  * The four whitespace-free fields are split with `set --` rather than the
151
219
  * `${var%% *}` / `${var##* }` pair the two-field version used: those only ever
152
220
  * reach the first and the last field. `State.Error` is read separately because
@@ -168,6 +236,10 @@ function buildDockerStateSnippet(containerName) {
168
236
  `${v.error}=$(docker inspect -f '{{.State.Error}}' ${quotedName} 2>/dev/null)`,
169
237
  `case "$${v.started}" in ${DOCKER_ZERO_TIME}|'') ${v.started}=${UNKNOWN};; esac`,
170
238
  `case "$${v.finished}" in ${DOCKER_ZERO_TIME}|'') ${v.finished}=${UNKNOWN};; esac`,
239
+ // `ExitCode=0` next to a zero `FinishedAt` is Docker's zero value for a
240
+ // container that has not finished, never an observed exit 0 (issue #174).
241
+ `if [ "$${v.finished}" = ${UNKNOWN} ] && [ "$${v.exit}" = 0 ]; then ${v.exit}=-1; fi`,
242
+ `${v.running}=$(docker inspect -f '${DOCKER_RUNNING_INSPECT_FORMAT}' ${quotedName} 2>/dev/null)`,
171
243
  buildSignalDecodeSnippet(),
172
244
  buildLifetimeSnippet(),
173
245
  ].join('; ');
@@ -252,6 +324,21 @@ function buildDockerPostMortemSnippet(containerName, quotedLogPath) {
252
324
  );
253
325
  }
254
326
 
327
+ /**
328
+ * Shell fragment appending the note for a container the watcher lost while it
329
+ * was still running (issue #174).
330
+ * @param {string} containerName - Container that is still running
331
+ * @param {string} quotedLogPath - Already shell-quoted log path
332
+ * @returns {string} Shell command
333
+ */
334
+ function buildDockerStillRunningNoteSnippet(containerName, quotedLogPath) {
335
+ const quotedName = shellQuote(containerName);
336
+ return (
337
+ `printf '\\nContainer still running: %s\\n%s\\nCheck it with: docker inspect %s\\n' ` +
338
+ `${quotedName} ${shellQuote(STILL_RUNNING_NOTE)} ${quotedName} >> ${quotedLogPath}`
339
+ );
340
+ }
341
+
255
342
  /**
256
343
  * Shell fragment appending the one-line note for a removed container.
257
344
  * @param {string} containerName - Container that was removed
@@ -270,15 +357,21 @@ function buildDockerRemovalNoteSnippet(containerName, quotedLogPath) {
270
357
  }
271
358
 
272
359
  module.exports = {
360
+ DOCKER_RUNNING_INSPECT_FORMAT,
273
361
  DOCKER_STATE_INSPECT_FORMAT,
274
362
  DOCKER_ZERO_TIME,
363
+ LOG_CAPTURE_STOPPED_NOTE,
364
+ STILL_RUNNING_NOTE,
275
365
  NO_ERROR,
276
366
  POST_MORTEM_HEADER,
277
367
  SHELL_VARS,
278
368
  UNKNOWN,
279
369
  buildDockerPostMortemSnippet,
280
370
  buildDockerRemovalNoteSnippet,
371
+ buildDockerRunningCondition,
281
372
  buildDockerStateSnippet,
373
+ buildDockerStillRunningNoteSnippet,
374
+ buildDockerWaitForExitSnippet,
282
375
  buildLifetimeSnippet,
283
376
  buildSignalDecodeSnippet,
284
377
  describeExitCode,
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Parse and validate the launch-time recovery options (issue #176):
3
+ * --on-kill-resume <N> resume up to N times when the main process is
4
+ * killed (exit 137 / OOMKilled)
5
+ * --recovery-command <cmd> command to run in the same container on resume
6
+ * (without it, the original command is re-run)
7
+ */
8
+
9
+ function parseOnKillResumeValue(value) {
10
+ const text = String(value).trim();
11
+ const count = Number(text);
12
+ if (!/^\d+$/.test(text) || !Number.isSafeInteger(count) || count < 1) {
13
+ throw new Error(
14
+ `Invalid --on-kill-resume value: "${value}". Expected a positive integer.`
15
+ );
16
+ }
17
+ return count;
18
+ }
19
+
20
+ function parseDockerRecoveryOption(args, index, options) {
21
+ const arg = args[index];
22
+ if (arg === '--on-kill-resume' || arg === '--recovery-command') {
23
+ if (index + 1 >= args.length || args[index + 1].startsWith('-')) {
24
+ const value = arg === '--on-kill-resume' ? 'count' : 'command';
25
+ throw new Error(`Option ${arg} requires a ${value} argument`);
26
+ }
27
+ if (arg === '--on-kill-resume') {
28
+ options.onKillResume = parseOnKillResumeValue(args[index + 1]);
29
+ } else {
30
+ options.recoveryCommand = args[index + 1];
31
+ }
32
+ return 2;
33
+ }
34
+ if (arg.startsWith('--on-kill-resume=')) {
35
+ options.onKillResume = parseOnKillResumeValue(
36
+ arg.slice('--on-kill-resume='.length)
37
+ );
38
+ return 1;
39
+ }
40
+ if (arg.startsWith('--recovery-command=')) {
41
+ options.recoveryCommand = arg.slice('--recovery-command='.length);
42
+ return 1;
43
+ }
44
+ return 0;
45
+ }
46
+
47
+ /**
48
+ * Recovery is driven by the detached docker completion watcher, so it needs a
49
+ * detached session whose only isolation level is docker. A recovery command on
50
+ * its own implies one attempt.
51
+ * @param {object} options - Parsed options
52
+ * @throws {Error} If the recovery options are used where they cannot work
53
+ */
54
+ function validateDockerRecoveryOptions(options) {
55
+ if (
56
+ options.recoveryCommand !== null &&
57
+ options.recoveryCommand !== undefined
58
+ ) {
59
+ if (String(options.recoveryCommand).trim() === '') {
60
+ throw new Error('--recovery-command requires a non-empty command');
61
+ }
62
+ options.onKillResume ??= 1;
63
+ }
64
+ if (!options.onKillResume) {
65
+ return;
66
+ }
67
+ const flag = options.recoveryCommand
68
+ ? '--recovery-command'
69
+ : '--on-kill-resume';
70
+ const stack = options.isolatedStack || [options.isolated];
71
+ if (!options.isolated || stack.length !== 1 || stack[0] !== 'docker') {
72
+ throw new Error(
73
+ `${flag} option is only valid with --isolated docker as the only isolation level`
74
+ );
75
+ }
76
+ if (!options.detached) {
77
+ throw new Error(`${flag} option requires --detached`);
78
+ }
79
+ }
80
+
81
+ module.exports = {
82
+ parse: parseDockerRecoveryOption,
83
+ validate: validateDockerRecoveryOptions,
84
+ };