start-command 0.33.0 → 0.34.1

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.34.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+
9
+ ## 0.34.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 22c1158: Persist the terminal state of a detached Docker execution and record the container post-mortem. The completion watcher now writes `status`, `exitCode`, `oomKilled` and a real `endTime` back into the store instead of leaving the record `executing` forever, so `--status` stops calling the clock at query time and reporting a different "finish" on every call. `endTime` carries its provenance in `endTimeSource` (`docker-finished-at`, `log-footer`, `observed-at`); a stale record keeps `endTime` empty and records `staleDetectedAt` instead of a fabricated finish time. The single `docker inspect` now also collects `StartedAt`, `FinishedAt` and `State.Error`, and both the detached and the attached path write a post-mortem block for a kept container and a one-line note for a removed one. Signal decoding (`128+n`) is shared by the log and `--status`, so `137` reads as `137 (SIGKILL - 128+9)` everywhere.
14
+
3
15
  ## 0.33.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.33.0",
3
+ "version": "0.34.1",
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": {
package/src/bin/cli.js CHANGED
@@ -33,10 +33,12 @@ const {
33
33
  } = require('../lib/user-manager');
34
34
  const { handleFailure } = require('../lib/failure-handler');
35
35
  const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store');
36
+ const { reconcileFinalizedRecord } = require('../lib/detached-finalize');
36
37
  const { dispatchQueryCommand, hasQueryMode } = require('../lib/query-commands');
37
38
  const { printVersion } = require('../lib/version');
38
39
  const { createStartBlock, createFinishBlock } = require('../lib/output-blocks');
39
40
  const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
41
+ const { resolveChildExitCode } = require('../lib/exit-reason');
40
42
  const { printUsage } = require('../lib/usage');
41
43
  const { buildDisplayCommand, getCommandName } = require('../lib/shell-utils');
42
44
 
@@ -469,6 +471,9 @@ async function runWithIsolation(
469
471
  ...buildDockerRuntimeMetadata(options),
470
472
  shell: options.shell,
471
473
  logPath: logFilePath,
474
+ // Lets the detached completion watcher write the terminal state back
475
+ // into this record once the container is gone (issue #170.1).
476
+ executionId: executionRecord ? executionRecord.uuid : null,
472
477
  });
473
478
  } else if (createdUser) {
474
479
  // Run directly as the created user (no isolation environment)
@@ -504,7 +509,11 @@ async function runWithIsolation(
504
509
  executionRecord.complete(exitCode);
505
510
  }
506
511
  try {
507
- store.save(executionRecord);
512
+ store.save(
513
+ mode === 'detached'
514
+ ? reconcileFinalizedRecord(store, executionRecord)
515
+ : executionRecord
516
+ );
508
517
  } catch (err) {
509
518
  if (config.verbose) {
510
519
  console.error(
@@ -812,7 +821,7 @@ async function runDirectWithCommandStream(
812
821
  // Use raw() to pass the command without auto-escaping
813
822
  // This is important for complex commands with pipes, redirects, etc.
814
823
  const result = await $cmd`${raw(cmd)}`;
815
- exitCode = result.code || 0;
824
+ exitCode = resolveChildExitCode(result.code);
816
825
 
817
826
  // Update PID if available from result
818
827
  if (executionRecord && result.pid) {
@@ -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) {
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Detached completion finalizer (issue #170)
3
+ *
4
+ * A detached Docker session outlives the CLI process that started it: the
5
+ * foreground `start` invocation returns as soon as the container is up, so
6
+ * nobody is left to write the terminal state back into the execution store.
7
+ * The record therefore stayed `executing` forever, with `exitCode: null` and
8
+ * `endTime: null`, and every `--status` query had to re-derive the outcome from
9
+ * scratch — fabricating `endTime` with `new Date()` in the process.
10
+ *
11
+ * The detached completion watcher (see `docker-cleanup.js`) now re-invokes this
12
+ * module once the container is gone, handing over the facts it already read out
13
+ * of a single `docker inspect`. This module is deliberately tiny and
14
+ * dependency-light: it is executed as a short-lived child process on a host
15
+ * whose CLI invocation is long gone, so it must never throw and never block.
16
+ */
17
+
18
+ const path = require('path');
19
+
20
+ const {
21
+ SHELL_VARS,
22
+ describeExitCode,
23
+ normalizeDockerTimestamp,
24
+ } = require('./docker-post-mortem');
25
+ const { shellQuote } = require('./isolation-log-utils');
26
+
27
+ /** Provenance markers for `endTime` (issue #170.2). */
28
+ const END_TIME_SOURCE = {
29
+ /** `docker inspect .State.FinishedAt` — the container's own clock. */
30
+ DOCKER_FINISHED_AT: 'docker-finished-at',
31
+ /** The anchored `Finished:` line `start` itself wrote into the log. */
32
+ LOG_FOOTER: 'log-footer',
33
+ /** No real finish time exists: this is when the end was *observed*. */
34
+ OBSERVED_AT: 'observed-at',
35
+ };
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
+
44
+ /**
45
+ * Persist the terminal state of a detached execution.
46
+ *
47
+ * Purely additive with respect to the observation-vs-verdict discipline of
48
+ * issues #148/#151/#162: `oomKilled` and `exitReason` are recorded as facts
49
+ * next to the exit code, never as a substitute for it.
50
+ *
51
+ * @param {Object} options - Finalization input
52
+ * @param {Object} options.store - An `ExecutionStore`
53
+ * @param {string} options.executionId - UUID (or session name) of the record
54
+ * @param {number|string|null} [options.exitCode] - `.State.ExitCode`
55
+ * @param {boolean|string|null} [options.oomKilled] - `.State.OOMKilled`
56
+ * @param {string|null} [options.startedAt] - `.State.StartedAt`
57
+ * @param {string|null} [options.finishedAt] - `.State.FinishedAt`
58
+ * @param {string|null} [options.containerError] - `.State.Error`
59
+ * @param {boolean|string|null} [options.running] - `.State.Running`
60
+ * @returns {{updated: boolean, reason?: string, record?: Object}} Outcome
61
+ */
62
+ function finalizeDetachedExecution(options = {}) {
63
+ const { store, executionId } = options;
64
+ if (!store || !executionId) {
65
+ return { updated: false, reason: 'missing-arguments' };
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
+ }
72
+
73
+ let record;
74
+ try {
75
+ record = store.get(executionId);
76
+ } catch {
77
+ return { updated: false, reason: 'store-unreadable' };
78
+ }
79
+ if (!record) {
80
+ return { updated: false, reason: 'record-not-found' };
81
+ }
82
+ if (record.status === 'executed' && record.endTime) {
83
+ // Already finalized (e.g. a `--status` query got there first, or the
84
+ // watcher ran twice after a resume). Nothing to correct.
85
+ return { updated: false, reason: 'already-final', record };
86
+ }
87
+
88
+ const finishedAt = normalizeDockerTimestamp(options.finishedAt);
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;
93
+ record.status = 'executed';
94
+ if (exitCode !== null) {
95
+ record.exitCode = exitCode;
96
+ } else if (
97
+ record.exitCode === null ||
98
+ record.exitCode === undefined ||
99
+ !finishedAt
100
+ ) {
101
+ record.exitCode = -1;
102
+ }
103
+
104
+ if (finishedAt) {
105
+ record.endTime = new Date(finishedAt).toISOString();
106
+ record.endTimeSource = END_TIME_SOURCE.DOCKER_FINISHED_AT;
107
+ } else {
108
+ // Docker has no finish time for this container (it never started, or it
109
+ // was removed before we could look). Record *when we noticed*, and say so,
110
+ // rather than passing observation time off as a finish time (issue #170.2).
111
+ record.endTime = new Date().toISOString();
112
+ record.endTimeSource = END_TIME_SOURCE.OBSERVED_AT;
113
+ record.observedAt = record.endTime;
114
+ }
115
+
116
+ const startedAt = normalizeDockerTimestamp(options.startedAt);
117
+ if (startedAt) {
118
+ record.containerStartedAt = startedAt;
119
+ }
120
+
121
+ const oomKilled = normalizeBoolean(options.oomKilled);
122
+ if (oomKilled !== null) {
123
+ record.oomKilled = oomKilled;
124
+ }
125
+
126
+ const exitReason =
127
+ resolveReason(record) || (finishedAt ? null : WATCHER_LOST_CONTAINER);
128
+ if (exitReason) {
129
+ record.exitReason = exitReason;
130
+ }
131
+
132
+ const containerError = normalizeContainerError(options.containerError);
133
+ if (containerError) {
134
+ record.options = { ...(record.options || {}), containerError };
135
+ }
136
+
137
+ try {
138
+ store.save(record);
139
+ } catch (err) {
140
+ return { updated: false, reason: `save-failed: ${err.message}`, record };
141
+ }
142
+ return { updated: true, record };
143
+ }
144
+
145
+ /**
146
+ * Derive the `exitReason` hint from the same evidence `--status` would use.
147
+ * Kept in a helper so a failure to read the log never aborts finalization.
148
+ * @param {Object} record - Execution record being finalized
149
+ * @returns {string|null} Reason hint, or null
150
+ */
151
+ function resolveReason(record) {
152
+ try {
153
+ const { resolveExitReason } = require('./exit-reason');
154
+ const {
155
+ FATAL_MARKER_TAIL_BYTES,
156
+ readLogTail,
157
+ } = require('./isolation-log-utils');
158
+ const logTail = record.logPath
159
+ ? readLogTail(record.logPath, FATAL_MARKER_TAIL_BYTES)
160
+ : null;
161
+ return resolveExitReason({
162
+ exitCode: record.exitCode,
163
+ logTail,
164
+ oomKilled: record.oomKilled,
165
+ });
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * @param {boolean|string|null|undefined} value - Raw shell-provided flag
173
+ * @returns {boolean|null} Parsed flag, or null when it carries no information
174
+ */
175
+ function normalizeBoolean(value) {
176
+ if (typeof value === 'boolean') {
177
+ return value;
178
+ }
179
+ if (value === 'true') {
180
+ return true;
181
+ }
182
+ if (value === 'false') {
183
+ return false;
184
+ }
185
+ return null;
186
+ }
187
+
188
+ /**
189
+ * @param {string|null|undefined} value - Raw `.State.Error`
190
+ * @returns {string|null} Error text, or null when docker reported none
191
+ */
192
+ function normalizeContainerError(value) {
193
+ if (typeof value !== 'string') {
194
+ return null;
195
+ }
196
+ const trimmed = value.trim();
197
+ if (trimmed === '' || trimmed === '<no value>' || trimmed === '(none)') {
198
+ return null;
199
+ }
200
+ return trimmed;
201
+ }
202
+
203
+ /**
204
+ * Reconcile an in-memory record with one the detached watcher already
205
+ * finalized.
206
+ *
207
+ * A container that exits almost immediately can be finalized by the watcher
208
+ * before the foreground `start` invocation writes its own last update. Saving
209
+ * the stale in-memory copy would resurrect `status: executing` and throw the
210
+ * terminal facts away, so the stored record wins and only the fields the
211
+ * foreground process learned (its `options`, e.g. `containerId`) are merged in.
212
+ *
213
+ * @param {Object} store - An `ExecutionStore`
214
+ * @param {Object} record - The in-memory record about to be saved
215
+ * @returns {Object} The record to save
216
+ */
217
+ function reconcileFinalizedRecord(store, record) {
218
+ try {
219
+ const stored = store.get(record.uuid);
220
+ if (!stored || stored.status !== 'executed' || !stored.endTime) {
221
+ return record;
222
+ }
223
+ stored.options = { ...(stored.options || {}), ...(record.options || {}) };
224
+ return stored;
225
+ } catch {
226
+ return record;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Shell fragment that hands the inspected docker facts to this module.
232
+ *
233
+ * Runs with the same runtime that started the session, inherits
234
+ * `START_APP_FOLDER`/`START_DISABLE_TRACKING` from the detached watcher, and is
235
+ * suffixed with `|| true` so a finalization failure can never abort the
236
+ * watcher's remaining cleanup work.
237
+ *
238
+ * @param {string} executionId - UUID of the record to finalize
239
+ * @returns {string} POSIX shell fragment
240
+ */
241
+ function buildDetachedFinalizeSnippet(executionId) {
242
+ const v = SHELL_VARS;
243
+ return (
244
+ `${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
245
+ `${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
246
+ `"$${v.started}" "$${v.finished}" "$${v.error}" "$${v.running}" ` +
247
+ `>/dev/null 2>&1 || true`
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Entry point used by the detached watcher:
253
+ * <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error> <running>
254
+ * Always exits 0 — a bookkeeping failure must never turn into a visible error
255
+ * in a log the user is reading for the command's own output.
256
+ * @param {string[]} argv - Positional arguments
257
+ * @returns {void}
258
+ */
259
+ function main(argv) {
260
+ const [
261
+ executionId,
262
+ exitCode,
263
+ oomKilled,
264
+ startedAt,
265
+ finishedAt,
266
+ containerError,
267
+ running,
268
+ ] = argv;
269
+ if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
270
+ return;
271
+ }
272
+ try {
273
+ const { ExecutionStore } = require('./execution-store');
274
+ const store = new ExecutionStore(
275
+ process.env.START_APP_FOLDER
276
+ ? { appFolder: path.resolve(process.env.START_APP_FOLDER) }
277
+ : {}
278
+ );
279
+ finalizeDetachedExecution({
280
+ store,
281
+ executionId,
282
+ exitCode,
283
+ oomKilled,
284
+ startedAt,
285
+ finishedAt,
286
+ containerError,
287
+ running,
288
+ });
289
+ } catch {
290
+ // Deliberately silent: see the exit-0 contract above.
291
+ }
292
+ }
293
+
294
+ if (require.main === module) {
295
+ main(process.argv.slice(2));
296
+ }
297
+
298
+ module.exports = {
299
+ END_TIME_SOURCE,
300
+ WATCHER_LOST_CONTAINER,
301
+ buildDetachedFinalizeSnippet,
302
+ finalizeDetachedExecution,
303
+ main,
304
+ reconcileFinalizedRecord,
305
+ };
@@ -7,6 +7,19 @@ const {
7
7
  shellQuote,
8
8
  } = require('./isolation-log-utils');
9
9
  const { resolveMemoryExhaustion } = require('./exit-reason');
10
+ const {
11
+ buildDockerPostMortemSnippet,
12
+ buildDockerRemovalNoteSnippet,
13
+ buildDockerStateSnippet,
14
+ buildDockerStillRunningNoteSnippet,
15
+ buildDockerWaitForExitSnippet,
16
+ DOCKER_STATE_INSPECT_FORMAT,
17
+ formatContainerPostMortem,
18
+ formatContainerRemovalNote,
19
+ normalizeDockerTimestamp,
20
+ SHELL_VARS,
21
+ } = require('./docker-post-mortem');
22
+ const { buildDetachedFinalizeSnippet } = require('./detached-finalize');
10
23
 
11
24
  const DOCKER_CONTAINER_CLEANUP_POLICY = {
12
25
  DEFAULT: 'default',
@@ -91,27 +104,85 @@ function appendDockerContainerCleanupPolicyMessage(
91
104
  return `${message}\nContainer will be removed after command completes.`;
92
105
  }
93
106
 
94
- function readDockerContainerOomKilled(containerName) {
95
- const result = spawnSync(
96
- getDockerCommand(),
97
- ['inspect', '-f', '{{.State.OOMKilled}}', containerName],
98
- getDockerSpawnOptions({
99
- encoding: 'utf8',
100
- env: process.env,
101
- stdio: ['pipe', 'pipe', 'pipe'],
102
- })
103
- );
104
- if (result.error || result.status !== 0) {
107
+ /**
108
+ * Read a finished container's post-mortem facts in a single `docker inspect`.
109
+ *
110
+ * The attached path only ever inspected `State.OOMKilled`, which left its
111
+ * "Container kept for investigation" message unable to say *why* the container
112
+ * died — the same gap the detached watcher had (issue #171.1). Reading the
113
+ * facts once, before any `docker rm`, also makes them available to the removal
114
+ * path, where the container is about to stop existing (issue #171.3).
115
+ *
116
+ * @param {string} containerName - Container to inspect
117
+ * @returns {object|null} Facts accepted by the `docker-post-mortem` formatters,
118
+ * or null when the container could not be inspected at all
119
+ */
120
+ function readDockerContainerState(containerName) {
121
+ const inspect = (format) => {
122
+ const result = spawnSync(
123
+ getDockerCommand(),
124
+ ['inspect', '-f', format, containerName],
125
+ getDockerSpawnOptions({
126
+ encoding: 'utf8',
127
+ env: process.env,
128
+ stdio: ['pipe', 'pipe', 'pipe'],
129
+ })
130
+ );
131
+ if (result.error || result.status !== 0) {
132
+ return null;
133
+ }
134
+ return String(result.stdout || '').trim();
135
+ };
136
+ const state = inspect(DOCKER_STATE_INSPECT_FORMAT);
137
+ if (state === null) {
105
138
  return null;
106
139
  }
107
- const value = String(result.stdout || '').trim();
108
- if (value === 'true') {
109
- return true;
140
+ const [exitCode, oomKilled, startedAt, finishedAt] = state.split(/\s+/);
141
+ // `State.Error` is free-form text that may contain spaces, so it cannot ride
142
+ // along in the whitespace-separated template above.
143
+ const error = inspect('{{.State.Error}}');
144
+ const parsedExit = Number.parseInt(exitCode, 10);
145
+ return {
146
+ containerName,
147
+ exitCode: Number.isFinite(parsedExit) ? parsedExit : null,
148
+ oomKilled:
149
+ oomKilled === 'true' ? true : oomKilled === 'false' ? false : null,
150
+ startedAt: normalizeDockerTimestamp(startedAt),
151
+ finishedAt: normalizeDockerTimestamp(finishedAt),
152
+ error: error || null,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Write the post-mortem for an attached run into its log and return the same
158
+ * text for the console message.
159
+ *
160
+ * Kept and removed containers both get their facts recorded — a kept container
161
+ * gets the full block, a removed one the single line — so the log of an
162
+ * attached run carries exactly what the detached watcher writes (issues #171.2
163
+ * and #171.3).
164
+ *
165
+ * @param {object} params - Container name, inspected state, log path, and
166
+ * whether the container was removed
167
+ * @returns {string} Message lines to append, or '' when nothing is known
168
+ */
169
+ function recordAttachedDockerPostMortem({
170
+ containerName,
171
+ state,
172
+ logPath = null,
173
+ removed = false,
174
+ }) {
175
+ if (!state) {
176
+ return '';
110
177
  }
111
- if (value === 'false') {
112
- return false;
178
+ const facts = { ...state, containerName };
179
+ const text = removed
180
+ ? formatContainerRemovalNote(facts)
181
+ : formatContainerPostMortem(facts);
182
+ if (logPath) {
183
+ appendLogFile(logPath, `\n${text}`);
113
184
  }
114
- return null;
185
+ return `\n${text.trimEnd()}`;
115
186
  }
116
187
 
117
188
  function readDockerContainerStatus(containerName) {
@@ -231,56 +302,131 @@ function buildSuccessfulNonOomCondition() {
231
302
  );
232
303
  }
233
304
 
234
- function buildDetachedDockerCompletionScript(containerName, policy, logPath) {
305
+ /**
306
+ * Build the POSIX shell run by the detached completion watcher.
307
+ *
308
+ * The watcher is the only observer left once `start --detached` returns, so it
309
+ * carries three responsibilities that used to be missing or deferred:
310
+ * 1. it reads the container's post-mortem facts out of a single
311
+ * `docker inspect` (issue #171.1);
312
+ * 2. it writes those facts into the log on every path — kept *and* removed —
313
+ * so an operator reading the log never has to reconstruct them from a
314
+ * container that no longer exists (issues #171.2, #171.3);
315
+ * 3. it hands the same facts to the finalizer so the execution record stops
316
+ * being `executing` forever (issue #170.1).
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
+ * @param {string} containerName - Docker container name
324
+ * @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
325
+ * @param {string|null} logPath - Log file to append to, or null
326
+ * @param {string|null} [executionId] - Execution UUID to finalize, or null
327
+ * @returns {string} The shell script
328
+ */
329
+ function buildDetachedDockerCompletionScript(
330
+ containerName,
331
+ policy,
332
+ logPath,
333
+ executionId = null
334
+ ) {
235
335
  const quotedName = shellQuote(containerName);
236
336
  const parts = [];
337
+ // Everything that assumes the container has exited: cleanup, footer and
338
+ // finalization. Guarded as a whole by `.State.Running` below.
339
+ const exited = [];
237
340
 
238
341
  if (logPath) {
239
342
  const quotedLogPath = shellQuote(logPath);
240
- parts.push(`docker logs -f ${quotedName} >> ${quotedLogPath} 2>&1`);
241
- parts.push(
242
- `__start_command_state=$(docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' ${quotedName} 2>/dev/null || printf '%s' '-1 false')`
343
+ const removalNote = buildDockerRemovalNoteSnippet(
344
+ containerName,
345
+ quotedLogPath
346
+ );
347
+ const postMortem = buildDockerPostMortemSnippet(
348
+ containerName,
349
+ quotedLogPath
243
350
  );
244
- parts.push('__start_command_exit=${__start_command_state%% *}');
245
- parts.push('__start_command_oom=${__start_command_state##* }');
351
+ const remove = `docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; ${removalNote}`;
352
+ const keep = `${postMortem}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
353
+
354
+ parts.push(`docker logs -f ${quotedName} >> ${quotedLogPath} 2>&1`);
355
+ parts.push(buildDockerWaitForExitSnippet(containerName, quotedLogPath));
356
+ parts.push(buildDockerStateSnippet(containerName));
246
357
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
247
- parts.push(`docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true`);
358
+ exited.push(remove);
248
359
  } else if (
249
360
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
250
361
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
251
362
  ) {
252
- const successCondition = buildSuccessfulNonOomCondition();
253
- parts.push(
254
- `if ${successCondition}; then docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; else ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}; fi`
363
+ exited.push(
364
+ `if ${buildSuccessfulNonOomCondition()}; then ${remove}; else ${keep}; fi`
255
365
  );
366
+ } else {
367
+ // KEEP: the container is never removed, so the log used to end with the
368
+ // raw command output and nothing else. The post-mortem is exactly the
369
+ // information an operator needs before deciding what to do with it.
370
+ exited.push(keep);
256
371
  }
257
- parts.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
372
+ exited.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
258
373
  } else {
259
374
  parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
260
- parts.push(
261
- `__start_command_state=$(docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' ${quotedName} 2>/dev/null || printf '%s' '-1 false')`
262
- );
263
- parts.push('__start_command_exit=${__start_command_state%% *}');
264
- parts.push('__start_command_oom=${__start_command_state##* }');
375
+ parts.push(buildDockerWaitForExitSnippet(containerName));
376
+ parts.push(buildDockerStateSnippet(containerName));
265
377
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
266
- parts.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
378
+ exited.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
267
379
  } else if (
268
380
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
269
381
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
270
382
  ) {
271
- parts.push(
383
+ exited.push(
272
384
  `if ${buildSuccessfulNonOomCondition()}; then docker rm -f ${quotedName} >/dev/null 2>&1 || true; fi`
273
385
  );
274
386
  }
275
387
  }
276
388
 
389
+ if (executionId) {
390
+ // Last, so the record is only marked terminal once the log is complete.
391
+ exited.push(buildDetachedFinalizeSnippet(executionId));
392
+ }
393
+
394
+ const stillRunning = logPath
395
+ ? buildDockerStillRunningNoteSnippet(containerName, shellQuote(logPath))
396
+ : ':';
397
+ parts.push(
398
+ `if [ "$${SHELL_VARS.running}" = true ]; then ${stillRunning}; ` +
399
+ `else ${exited.length ? exited.join('; ') : ':'}; fi`
400
+ );
401
+
277
402
  return parts.join('; ');
278
403
  }
279
404
 
280
- function startDetachedDockerCompletionWatcher(containerName, policy, logPath) {
405
+ /**
406
+ * Start the detached completion watcher.
407
+ * @param {string} containerName - Docker container name
408
+ * @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
409
+ * @param {string|null} logPath - Log file to append to, or null
410
+ * @param {string|null} [executionId] - Execution UUID to finalize, or null
411
+ * @returns {void}
412
+ */
413
+ function startDetachedDockerCompletionWatcher(
414
+ containerName,
415
+ policy,
416
+ logPath,
417
+ executionId = null
418
+ ) {
281
419
  const watcher = spawn(
282
420
  'sh',
283
- ['-c', buildDetachedDockerCompletionScript(containerName, policy, logPath)],
421
+ [
422
+ '-c',
423
+ buildDetachedDockerCompletionScript(
424
+ containerName,
425
+ policy,
426
+ logPath,
427
+ executionId
428
+ ),
429
+ ],
284
430
  {
285
431
  detached: true,
286
432
  stdio: 'ignore',
@@ -327,8 +473,9 @@ module.exports = {
327
473
  shouldCleanupDockerContainer,
328
474
  getDockerContainerCleanupInstructions,
329
475
  appendDockerContainerCleanupPolicyMessage,
330
- readDockerContainerOomKilled,
476
+ readDockerContainerState,
331
477
  readDockerContainerStatus,
478
+ recordAttachedDockerPostMortem,
332
479
  removeDockerContainer,
333
480
  buildDetachedDockerCompletionScript,
334
481
  startDetachedDockerCompletionWatcher,