start-command 0.32.1 → 0.34.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -0
  3. package/eslint.config.mjs +8 -0
  4. package/package.json +7 -3
  5. package/src/bin/cli.js +32 -137
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/detached-finalize.js +281 -0
  9. package/src/lib/docker-cleanup.js +240 -39
  10. package/src/lib/docker-post-mortem.js +289 -0
  11. package/src/lib/execution-attach.js +245 -0
  12. package/src/lib/execution-resume-all.js +211 -0
  13. package/src/lib/execution-resume.js +455 -0
  14. package/src/lib/execution-store.js +93 -6
  15. package/src/lib/exit-reason.js +264 -0
  16. package/src/lib/failure-handler.js +43 -41
  17. package/src/lib/isolation-log-utils.js +66 -1
  18. package/src/lib/isolation.js +28 -15
  19. package/src/lib/query-commands.js +202 -0
  20. package/src/lib/screen-isolation.js +2 -1
  21. package/src/lib/session-probe.js +121 -0
  22. package/src/lib/shell-utils.js +164 -10
  23. package/src/lib/status-formatter.js +233 -69
  24. package/src/lib/usage.js +13 -1
  25. package/src/lib/user-manager.js +18 -1
  26. package/test/args-parser-attach-resume.js +159 -0
  27. package/test/args-parser-control.js +3 -3
  28. package/test/args-parser.js +1 -1
  29. package/test/check-file-size.js +91 -0
  30. package/test/ci-workflow-invariants.js +191 -0
  31. package/test/echo-integration.js +20 -13
  32. package/test/execution-attach.js +221 -0
  33. package/test/execution-resume.js +433 -0
  34. package/test/execution-store.js +57 -2
  35. package/test/exit-reason.js +209 -0
  36. package/test/failure-handler.js +107 -0
  37. package/test/load-command-stream.mjs +83 -0
  38. package/test/regression-162.js +320 -0
  39. package/test/regression-164.js +308 -0
  40. package/test/regression-165.js +310 -0
  41. package/test/regression-170.js +321 -0
  42. package/test/regression-171.js +562 -0
  43. package/test/regression-84.js +8 -3
  44. package/test/release-name.mjs +57 -0
  45. package/test/session-probe.js +150 -0
  46. package/test/user-manager.js +23 -0
@@ -2,8 +2,21 @@ const { spawn, spawnSync } = require('child_process');
2
2
  const {
3
3
  appendLogFile,
4
4
  createShellLogFooterSnippet,
5
+ FATAL_MARKER_TAIL_BYTES,
6
+ readLogTail,
5
7
  shellQuote,
6
8
  } = require('./isolation-log-utils');
9
+ const { resolveMemoryExhaustion } = require('./exit-reason');
10
+ const {
11
+ buildDockerPostMortemSnippet,
12
+ buildDockerRemovalNoteSnippet,
13
+ buildDockerStateSnippet,
14
+ DOCKER_STATE_INSPECT_FORMAT,
15
+ formatContainerPostMortem,
16
+ formatContainerRemovalNote,
17
+ normalizeDockerTimestamp,
18
+ } = require('./docker-post-mortem');
19
+ const { buildDetachedFinalizeSnippet } = require('./detached-finalize');
7
20
 
8
21
  const DOCKER_CONTAINER_CLEANUP_POLICY = {
9
22
  DEFAULT: 'default',
@@ -56,7 +69,9 @@ function shouldCleanupDockerContainer(policy, exitCode, oomKilled = false) {
56
69
  function getDockerContainerCleanupInstructions(containerName) {
57
70
  return [
58
71
  `Container kept for investigation: ${containerName}`,
59
- `Inspect: docker exec -it ${containerName} sh (if running) or docker start -ai ${containerName}`,
72
+ `Re-enter while running: $ --attach ${containerName}`,
73
+ `Continue the stored command: $ --resume ${containerName}`,
74
+ `Run another command in the same container: $ --resume ${containerName} -- <command>`,
60
75
  `Remove when done: docker rm -f ${containerName}`,
61
76
  ].join('\n');
62
77
  }
@@ -86,27 +101,85 @@ function appendDockerContainerCleanupPolicyMessage(
86
101
  return `${message}\nContainer will be removed after command completes.`;
87
102
  }
88
103
 
89
- function readDockerContainerOomKilled(containerName) {
90
- const result = spawnSync(
91
- getDockerCommand(),
92
- ['inspect', '-f', '{{.State.OOMKilled}}', containerName],
93
- getDockerSpawnOptions({
94
- encoding: 'utf8',
95
- env: process.env,
96
- stdio: ['pipe', 'pipe', 'pipe'],
97
- })
98
- );
99
- if (result.error || result.status !== 0) {
104
+ /**
105
+ * Read a finished container's post-mortem facts in a single `docker inspect`.
106
+ *
107
+ * The attached path only ever inspected `State.OOMKilled`, which left its
108
+ * "Container kept for investigation" message unable to say *why* the container
109
+ * died — the same gap the detached watcher had (issue #171.1). Reading the
110
+ * facts once, before any `docker rm`, also makes them available to the removal
111
+ * path, where the container is about to stop existing (issue #171.3).
112
+ *
113
+ * @param {string} containerName - Container to inspect
114
+ * @returns {object|null} Facts accepted by the `docker-post-mortem` formatters,
115
+ * or null when the container could not be inspected at all
116
+ */
117
+ function readDockerContainerState(containerName) {
118
+ const inspect = (format) => {
119
+ const result = spawnSync(
120
+ getDockerCommand(),
121
+ ['inspect', '-f', format, containerName],
122
+ getDockerSpawnOptions({
123
+ encoding: 'utf8',
124
+ env: process.env,
125
+ stdio: ['pipe', 'pipe', 'pipe'],
126
+ })
127
+ );
128
+ if (result.error || result.status !== 0) {
129
+ return null;
130
+ }
131
+ return String(result.stdout || '').trim();
132
+ };
133
+ const state = inspect(DOCKER_STATE_INSPECT_FORMAT);
134
+ if (state === null) {
100
135
  return null;
101
136
  }
102
- const value = String(result.stdout || '').trim();
103
- if (value === 'true') {
104
- return true;
137
+ const [exitCode, oomKilled, startedAt, finishedAt] = state.split(/\s+/);
138
+ // `State.Error` is free-form text that may contain spaces, so it cannot ride
139
+ // along in the whitespace-separated template above.
140
+ const error = inspect('{{.State.Error}}');
141
+ const parsedExit = Number.parseInt(exitCode, 10);
142
+ return {
143
+ containerName,
144
+ exitCode: Number.isFinite(parsedExit) ? parsedExit : null,
145
+ oomKilled:
146
+ oomKilled === 'true' ? true : oomKilled === 'false' ? false : null,
147
+ startedAt: normalizeDockerTimestamp(startedAt),
148
+ finishedAt: normalizeDockerTimestamp(finishedAt),
149
+ error: error || null,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Write the post-mortem for an attached run into its log and return the same
155
+ * text for the console message.
156
+ *
157
+ * Kept and removed containers both get their facts recorded — a kept container
158
+ * gets the full block, a removed one the single line — so the log of an
159
+ * attached run carries exactly what the detached watcher writes (issues #171.2
160
+ * and #171.3).
161
+ *
162
+ * @param {object} params - Container name, inspected state, log path, and
163
+ * whether the container was removed
164
+ * @returns {string} Message lines to append, or '' when nothing is known
165
+ */
166
+ function recordAttachedDockerPostMortem({
167
+ containerName,
168
+ state,
169
+ logPath = null,
170
+ removed = false,
171
+ }) {
172
+ if (!state) {
173
+ return '';
105
174
  }
106
- if (value === 'false') {
107
- return false;
175
+ const facts = { ...state, containerName };
176
+ const text = removed
177
+ ? formatContainerRemovalNote(facts)
178
+ : formatContainerPostMortem(facts);
179
+ if (logPath) {
180
+ appendLogFile(logPath, `\n${text}`);
108
181
  }
109
- return null;
182
+ return `\n${text.trimEnd()}`;
110
183
  }
111
184
 
112
185
  function readDockerContainerStatus(containerName) {
@@ -142,14 +215,80 @@ function removeDockerContainer(containerName, logPath = null) {
142
215
  return !result.error && result.status === 0;
143
216
  }
144
217
 
218
+ /**
219
+ * Exit codes of a process that aborted itself: SIGABRT (134) and SIGSEGV (139).
220
+ * These are exactly the codes a runtime produces when it dies on its own memory
221
+ * limit — Node/V8 prints `FATAL ERROR: Reached heap limit ...` and aborts — long
222
+ * before the container limit is reached, so the kernel never OOM-kills anything
223
+ * and `State.OOMKilled` stays `false` (issue #165).
224
+ */
225
+ const SELF_ABORT_EXIT_CODES = [134, 139];
226
+
227
+ /**
228
+ * Appended to the kept-container reason for a self-abort exit code, so the
229
+ * footer stops asserting the opposite of the `FATAL ERROR` printed a few lines
230
+ * above it. The footer is the string downstream tooling greps.
231
+ */
232
+ const OOM_FLAG_BLIND_NOTE =
233
+ 'a runtime self-abort on its own memory limit is invisible to this flag - ' +
234
+ 'check the log above for a fatal memory marker';
235
+
236
+ /**
237
+ * Shell fragment computing `$__start_command_reason` for the kept footer.
238
+ * @returns {string} Shell command
239
+ */
240
+ function buildDockerKeptReasonSnippet() {
241
+ return (
242
+ '__start_command_reason="exitCode=$__start_command_exit oomKilled=$__start_command_oom"; ' +
243
+ `case "$__start_command_exit" in ${SELF_ABORT_EXIT_CODES.join('|')}) ` +
244
+ '[ "$__start_command_oom" = true ] || ' +
245
+ `__start_command_reason="$__start_command_reason (${OOM_FLAG_BLIND_NOTE})";; ` +
246
+ 'esac'
247
+ );
248
+ }
249
+
250
+ /**
251
+ * Lines appended to an attached session's message when the container is kept
252
+ * because the command failed. A runtime that aborts on its own memory limit
253
+ * never trips `State.OOMKilled`, so a bare `oomKilled false` would contradict
254
+ * the `FATAL ERROR` the runtime just printed into this very log (issue #165).
255
+ * Best effort: the tail is read right after the child exits.
256
+ *
257
+ * @param {object} params - Container name, exit code, OOM flag and log path
258
+ * @returns {string} Message lines to append
259
+ */
260
+ function buildAttachedDockerKeptMessage({
261
+ containerName,
262
+ exitCode,
263
+ oomKilled,
264
+ logPath,
265
+ }) {
266
+ let message =
267
+ oomKilled === true
268
+ ? `\nContainer kept because Docker reports it was OOM-killed.`
269
+ : `\nContainer kept because the command failed.`;
270
+ const memory = resolveMemoryExhaustion({
271
+ exitCode,
272
+ logTail: logPath ? readLogTail(logPath, FATAL_MARKER_TAIL_BYTES) : null,
273
+ oomKilled,
274
+ });
275
+ if (memory) {
276
+ message += `\nMemory exhaustion detected in the log: ${memory.memoryExhaustedReason}`;
277
+ }
278
+ return `${message}\nRemove when done: docker rm -f ${containerName}`;
279
+ }
280
+
145
281
  function buildDockerKeptLogSnippet(containerName, quotedLogPath) {
146
282
  const quotedName = shellQuote(containerName);
147
283
  return (
148
- `printf '\\nContainer kept for investigation: %s\\nReason: exitCode=%s oomKilled=%s\\n` +
149
- `Inspect: docker exec -it %s sh (if running) or docker start -ai %s\\n` +
284
+ `${buildDockerKeptReasonSnippet()}; ` +
285
+ `printf '\\nContainer kept for investigation: %s\\nReason: %s\\n` +
286
+ `Re-enter while running: $ --attach %s\\n` +
287
+ `Continue the stored command: $ --resume %s\\n` +
288
+ `Run another command in the same container: $ --resume %s -- <command>\\n` +
150
289
  `Remove when done: docker rm -f %s\\n' ` +
151
- `${quotedName} "$__start_command_exit" "$__start_command_oom" ` +
152
- `${quotedName} ${quotedName} ${quotedName} >> ${quotedLogPath}`
290
+ `${quotedName} "$__start_command_reason" ` +
291
+ `${quotedName} ${quotedName} ${quotedName} ${quotedName} >> ${quotedLogPath}`
153
292
  );
154
293
  }
155
294
 
@@ -160,37 +299,68 @@ function buildSuccessfulNonOomCondition() {
160
299
  );
161
300
  }
162
301
 
163
- function buildDetachedDockerCompletionScript(containerName, policy, logPath) {
302
+ /**
303
+ * Build the POSIX shell run by the detached completion watcher.
304
+ *
305
+ * The watcher is the only observer left once `start --detached` returns, so it
306
+ * carries three responsibilities that used to be missing or deferred:
307
+ * 1. it reads the container's post-mortem facts out of a single
308
+ * `docker inspect` (issue #171.1);
309
+ * 2. it writes those facts into the log on every path — kept *and* removed —
310
+ * so an operator reading the log never has to reconstruct them from a
311
+ * container that no longer exists (issues #171.2, #171.3);
312
+ * 3. it hands the same facts to the finalizer so the execution record stops
313
+ * being `executing` forever (issue #170.1).
314
+ *
315
+ * @param {string} containerName - Docker container name
316
+ * @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
317
+ * @param {string|null} logPath - Log file to append to, or null
318
+ * @param {string|null} [executionId] - Execution UUID to finalize, or null
319
+ * @returns {string} The shell script
320
+ */
321
+ function buildDetachedDockerCompletionScript(
322
+ containerName,
323
+ policy,
324
+ logPath,
325
+ executionId = null
326
+ ) {
164
327
  const quotedName = shellQuote(containerName);
165
328
  const parts = [];
166
329
 
167
330
  if (logPath) {
168
331
  const quotedLogPath = shellQuote(logPath);
169
- parts.push(`docker logs -f ${quotedName} >> ${quotedLogPath} 2>&1`);
170
- parts.push(
171
- `__start_command_state=$(docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' ${quotedName} 2>/dev/null || printf '%s' '-1 false')`
332
+ const removalNote = buildDockerRemovalNoteSnippet(
333
+ containerName,
334
+ quotedLogPath
335
+ );
336
+ const postMortem = buildDockerPostMortemSnippet(
337
+ containerName,
338
+ quotedLogPath
172
339
  );
173
- parts.push('__start_command_exit=${__start_command_state%% *}');
174
- parts.push('__start_command_oom=${__start_command_state##* }');
340
+ const remove = `docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; ${removalNote}`;
341
+ const keep = `${postMortem}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
342
+
343
+ parts.push(`docker logs -f ${quotedName} >> ${quotedLogPath} 2>&1`);
344
+ parts.push(buildDockerStateSnippet(containerName));
175
345
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
176
- parts.push(`docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true`);
346
+ parts.push(remove);
177
347
  } else if (
178
348
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
179
349
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
180
350
  ) {
181
- const successCondition = buildSuccessfulNonOomCondition();
182
351
  parts.push(
183
- `if ${successCondition}; then docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; else ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}; fi`
352
+ `if ${buildSuccessfulNonOomCondition()}; then ${remove}; else ${keep}; fi`
184
353
  );
354
+ } else {
355
+ // KEEP: the container is never removed, so the log used to end with the
356
+ // raw command output and nothing else. The post-mortem is exactly the
357
+ // information an operator needs before deciding what to do with it.
358
+ parts.push(keep);
185
359
  }
186
360
  parts.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
187
361
  } else {
188
362
  parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
189
- parts.push(
190
- `__start_command_state=$(docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' ${quotedName} 2>/dev/null || printf '%s' '-1 false')`
191
- );
192
- parts.push('__start_command_exit=${__start_command_state%% *}');
193
- parts.push('__start_command_oom=${__start_command_state##* }');
363
+ parts.push(buildDockerStateSnippet(containerName));
194
364
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
195
365
  parts.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
196
366
  } else if (
@@ -203,13 +373,39 @@ function buildDetachedDockerCompletionScript(containerName, policy, logPath) {
203
373
  }
204
374
  }
205
375
 
376
+ if (executionId) {
377
+ // Last, so the record is only marked terminal once the log is complete.
378
+ parts.push(buildDetachedFinalizeSnippet(executionId));
379
+ }
380
+
206
381
  return parts.join('; ');
207
382
  }
208
383
 
209
- function startDetachedDockerCompletionWatcher(containerName, policy, logPath) {
384
+ /**
385
+ * Start the detached completion watcher.
386
+ * @param {string} containerName - Docker container name
387
+ * @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
388
+ * @param {string|null} logPath - Log file to append to, or null
389
+ * @param {string|null} [executionId] - Execution UUID to finalize, or null
390
+ * @returns {void}
391
+ */
392
+ function startDetachedDockerCompletionWatcher(
393
+ containerName,
394
+ policy,
395
+ logPath,
396
+ executionId = null
397
+ ) {
210
398
  const watcher = spawn(
211
399
  'sh',
212
- ['-c', buildDetachedDockerCompletionScript(containerName, policy, logPath)],
400
+ [
401
+ '-c',
402
+ buildDetachedDockerCompletionScript(
403
+ containerName,
404
+ policy,
405
+ logPath,
406
+ executionId
407
+ ),
408
+ ],
213
409
  {
214
410
  detached: true,
215
411
  stdio: 'ignore',
@@ -244,7 +440,11 @@ function spawnAttachedDocker(dockerArgs, logPath) {
244
440
  }
245
441
 
246
442
  module.exports = {
443
+ buildAttachedDockerKeptMessage,
247
444
  DOCKER_CONTAINER_CLEANUP_POLICY,
445
+ SELF_ABORT_EXIT_CODES,
446
+ OOM_FLAG_BLIND_NOTE,
447
+ buildDockerKeptReasonSnippet,
248
448
  getDockerCommand,
249
449
  getDockerSpawnOptions,
250
450
  getDockerContainerCleanupPolicy,
@@ -252,8 +452,9 @@ module.exports = {
252
452
  shouldCleanupDockerContainer,
253
453
  getDockerContainerCleanupInstructions,
254
454
  appendDockerContainerCleanupPolicyMessage,
255
- readDockerContainerOomKilled,
455
+ readDockerContainerState,
256
456
  readDockerContainerStatus,
457
+ recordAttachedDockerPostMortem,
257
458
  removeDockerContainer,
258
459
  buildDetachedDockerCompletionScript,
259
460
  startDetachedDockerCompletionWatcher,
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Container post-mortem facts (issues #170, #171).
3
+ *
4
+ * When a detached container dies the only thing `start` used to write into the
5
+ * log was `Reason: exitCode=137 oomKilled=false` — the two facts that, together,
6
+ * say the least. `137` is `128 + 9`, i.e. SIGKILL, and `oomKilled=false` rules
7
+ * out the cgroup OOM killer, so the container was killed from the outside
8
+ * (`docker kill`, a `docker stop` escalation, systemd, the CI runner). None of
9
+ * that was said out loud, and the three fields that would have shown *when* it
10
+ * happened — `State.StartedAt`, `State.FinishedAt`, `State.Error` — were never
11
+ * read at all, even though the watcher was already one `docker inspect` away
12
+ * from them.
13
+ *
14
+ * This module owns those facts in one place:
15
+ * - `describeExitCode()` decodes `128 + n` once, for the watcher (completion
16
+ * time, written into the log) and for `--status` (query time, in memory), so
17
+ * the two can never disagree (issue #171);
18
+ * - the `build*Snippet()` helpers generate the POSIX shell the detached watcher
19
+ * runs, including the signal table, so the shell and the runtime share a
20
+ * single source of truth;
21
+ * - `formatContainerPostMortem()` renders the same block for callers that
22
+ * already hold the facts in memory (the attached docker path).
23
+ */
24
+
25
+ const {
26
+ SIGNAL_NAMES,
27
+ UNKNOWN_EXIT_CODE,
28
+ describeExitCode,
29
+ } = require('./exit-reason');
30
+ const { shellQuote } = require('./isolation-log-utils');
31
+
32
+ /**
33
+ * Docker's zero value for a timestamp that was never set. `State.FinishedAt`
34
+ * carries it for every container that has not finished, and `State.StartedAt`
35
+ * for every container that never started. It must never reach a record as an
36
+ * `endTime`: a year-1 timestamp is worse than an honest `null`.
37
+ */
38
+ const DOCKER_ZERO_TIME = '0001-01-01T00:00:00Z';
39
+
40
+ /** Placeholder written instead of a fact that could not be observed. */
41
+ const UNKNOWN = UNKNOWN_EXIT_CODE;
42
+
43
+ /** Placeholder for `State.Error` when docker reported no error at all. */
44
+ const NO_ERROR = '(none)';
45
+
46
+ /** Header line of the post-mortem block appended to a kept container's log. */
47
+ const POST_MORTEM_HEADER = '=== Container post-mortem ===';
48
+
49
+ /**
50
+ * Normalize a timestamp reported by `docker inspect`.
51
+ * @param {string|null|undefined} value - Raw `State.StartedAt`/`State.FinishedAt`
52
+ * @returns {string|null} The timestamp, or null when docker reported no time
53
+ */
54
+ function normalizeDockerTimestamp(value) {
55
+ const text = String(
56
+ value === null || value === undefined ? '' : value
57
+ ).trim();
58
+ if (!text || text === DOCKER_ZERO_TIME || text === UNKNOWN) {
59
+ return null;
60
+ }
61
+ if (text === '<no value>' || !Number.isFinite(Date.parse(text))) {
62
+ return null;
63
+ }
64
+ return text;
65
+ }
66
+
67
+ /**
68
+ * Format the time a container was alive.
69
+ * @param {string|null} startedAt - `State.StartedAt`
70
+ * @param {string|null} finishedAt - `State.FinishedAt`
71
+ * @returns {string|null} Lifetime such as `5.798s`, or null when unknown
72
+ */
73
+ function formatLifetime(startedAt, finishedAt) {
74
+ const start = normalizeDockerTimestamp(startedAt);
75
+ const end = normalizeDockerTimestamp(finishedAt);
76
+ if (!start || !end) {
77
+ return null;
78
+ }
79
+ const ms = Date.parse(end) - Date.parse(start);
80
+ if (!Number.isFinite(ms) || ms < 0) {
81
+ return null;
82
+ }
83
+ return `${Math.floor(ms / 1000)}.${String(ms % 1000).padStart(3, '0')}s`;
84
+ }
85
+
86
+ /**
87
+ * Render the post-mortem block for facts already held in memory.
88
+ * @param {object} facts - Container name and inspected `State` fields
89
+ * @returns {string} Block text, newline terminated
90
+ */
91
+ function formatContainerPostMortem(facts = {}) {
92
+ const startedAt = normalizeDockerTimestamp(facts.startedAt);
93
+ const finishedAt = normalizeDockerTimestamp(facts.finishedAt);
94
+ const lifetime = formatLifetime(facts.startedAt, facts.finishedAt);
95
+ const error = String(facts.error || '').trim();
96
+ return [
97
+ POST_MORTEM_HEADER,
98
+ `Container: ${facts.containerName || UNKNOWN}`,
99
+ `Exit Code: ${describeExitCode(facts.exitCode).text}`,
100
+ `OOMKilled: ${facts.oomKilled === undefined || facts.oomKilled === null ? UNKNOWN : facts.oomKilled}`,
101
+ `StartedAt: ${startedAt || UNKNOWN}`,
102
+ `FinishedAt: ${finishedAt || UNKNOWN}`,
103
+ `Lifetime: ${lifetime || UNKNOWN}`,
104
+ `Error: ${error || NO_ERROR}`,
105
+ '',
106
+ ].join('\n');
107
+ }
108
+
109
+ /**
110
+ * Render the single line written when a container is removed, so a successful
111
+ * run keeps its log terse while still carrying the same facts (issue #171).
112
+ * @param {object} facts - Container name and inspected `State` fields
113
+ * @returns {string} One line, newline terminated
114
+ */
115
+ function formatContainerRemovalNote(facts = {}) {
116
+ const described = describeExitCode(facts.exitCode);
117
+ const signal = described.signal ? `, ${described.signal}` : '';
118
+ const lifetime = formatLifetime(facts.startedAt, facts.finishedAt) || UNKNOWN;
119
+ const oomKilled =
120
+ facts.oomKilled === undefined || facts.oomKilled === null
121
+ ? UNKNOWN
122
+ : facts.oomKilled;
123
+ return (
124
+ `Container removed: ${facts.containerName || UNKNOWN} ` +
125
+ `(exit ${described.code === null ? UNKNOWN : described.code}${signal}, ` +
126
+ `lifetime ${lifetime}, oomKilled=${oomKilled})\n`
127
+ );
128
+ }
129
+
130
+ /** Shell variables the completion watcher fills in and the snippets consume. */
131
+ const SHELL_VARS = {
132
+ exit: '__start_command_exit',
133
+ oom: '__start_command_oom',
134
+ started: '__start_command_started',
135
+ finished: '__start_command_finished',
136
+ error: '__start_command_error',
137
+ errorText: '__start_command_error_text',
138
+ signal: '__start_command_signal',
139
+ exitText: '__start_command_exit_text',
140
+ lifetime: '__start_command_lifetime',
141
+ };
142
+
143
+ /** `docker inspect -f` template collecting every post-mortem fact at once. */
144
+ const DOCKER_STATE_INSPECT_FORMAT =
145
+ '{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.StartedAt}} {{.State.FinishedAt}}';
146
+
147
+ /**
148
+ * Shell fragment reading the container's terminal state.
149
+ *
150
+ * The four whitespace-free fields are split with `set --` rather than the
151
+ * `${var%% *}` / `${var##* }` pair the two-field version used: those only ever
152
+ * reach the first and the last field. `State.Error` is read separately because
153
+ * it is free-form text that can contain spaces.
154
+ *
155
+ * @param {string} containerName - Container to inspect
156
+ * @returns {string} Shell command
157
+ */
158
+ function buildDockerStateSnippet(containerName) {
159
+ const quotedName = shellQuote(containerName);
160
+ const v = SHELL_VARS;
161
+ return [
162
+ `__start_command_state=$(docker inspect -f '${DOCKER_STATE_INSPECT_FORMAT}' ${quotedName} 2>/dev/null || printf '%s' '-1 false ${UNKNOWN} ${UNKNOWN}')`,
163
+ 'set -- $__start_command_state',
164
+ `${v.exit}=\${1:--1}`,
165
+ `${v.oom}=\${2:-false}`,
166
+ `${v.started}=\${3:-${UNKNOWN}}`,
167
+ `${v.finished}=\${4:-${UNKNOWN}}`,
168
+ `${v.error}=$(docker inspect -f '{{.State.Error}}' ${quotedName} 2>/dev/null)`,
169
+ `case "$${v.started}" in ${DOCKER_ZERO_TIME}|'') ${v.started}=${UNKNOWN};; esac`,
170
+ `case "$${v.finished}" in ${DOCKER_ZERO_TIME}|'') ${v.finished}=${UNKNOWN};; esac`,
171
+ buildSignalDecodeSnippet(),
172
+ buildLifetimeSnippet(),
173
+ ].join('; ');
174
+ }
175
+
176
+ /**
177
+ * Shell fragment decoding `128 + n` into a signal name.
178
+ *
179
+ * The `case` arms are generated from the very table `describeExitCode()` uses,
180
+ * so the log the watcher writes and the `exitReason` `--status` computes can
181
+ * never name different signals for the same code (issue #171).
182
+ *
183
+ * @returns {string} Shell command
184
+ */
185
+ function buildSignalDecodeSnippet() {
186
+ const v = SHELL_VARS;
187
+ const arms = Object.entries(SIGNAL_NAMES)
188
+ .map(([signal, name]) => `${128 + Number(signal)}) ${v.signal}=${name};;`)
189
+ .join(' ');
190
+ return [
191
+ `${v.signal}=''`,
192
+ `case "$${v.exit}" in ${arms} esac`,
193
+ `${v.exitText}="$${v.exit}"`,
194
+ `if [ -n "$${v.signal}" ]; then ${v.exitText}="$${v.exit} ($${v.signal} - 128+$((${v.exit} - 128)))"; fi`,
195
+ ].join('; ');
196
+ }
197
+
198
+ /**
199
+ * Shell fragment computing how long the container was alive.
200
+ *
201
+ * Best effort by design: `date -d` is GNU, `date -j -f` is BSD, and a host that
202
+ * offers neither leaves the lifetime `unknown` rather than guessing.
203
+ *
204
+ * BSD `date` cannot parse the fractional seconds docker reports, so the
205
+ * fallback strips them before parsing and adds them back afterwards. Truncating
206
+ * both timestamps to whole seconds instead would *not* cancel out: a container
207
+ * alive from `.942` to `.740` of the next-but-five second measures 5.798s but
208
+ * truncates to 6.000s, and the error can reach a full second either way.
209
+ *
210
+ * @returns {string} Shell command
211
+ */
212
+ function buildLifetimeSnippet() {
213
+ const v = SHELL_VARS;
214
+ return [
215
+ '__start_command_millis() { ' +
216
+ 'case "$1" in *.*) __start_command_frac=${1#*.};; ' +
217
+ "*) printf '000'; return;; esac; " +
218
+ '__start_command_frac=${__start_command_frac%%[!0-9]*}; ' +
219
+ 'printf \'%.3s\' "${__start_command_frac}000"; }',
220
+ '__start_command_epoch_ms() { ' +
221
+ '__start_command_ts=$(date -u -d "$1" +%s%3N 2>/dev/null); ' +
222
+ 'case "$__start_command_ts" in \'\'|*[!0-9]*) ' +
223
+ '__start_command_ts=$(date -u -j -f \'%Y-%m-%dT%H:%M:%S\' "${1%.*}" +%s 2>/dev/null) && ' +
224
+ '__start_command_ts="${__start_command_ts}$(__start_command_millis "$1")";; esac; ' +
225
+ "case \"$__start_command_ts\" in ''|*[!0-9]*) __start_command_ts='';; esac; " +
226
+ 'printf \'%s\' "$__start_command_ts"; }',
227
+ `${v.lifetime}=${UNKNOWN}`,
228
+ `__start_command_t0=$(__start_command_epoch_ms "$${v.started}")`,
229
+ `__start_command_t1=$(__start_command_epoch_ms "$${v.finished}")`,
230
+ 'if [ -n "$__start_command_t0" ] && [ -n "$__start_command_t1" ] && ' +
231
+ '[ "$__start_command_t1" -ge "$__start_command_t0" ] 2>/dev/null; then ' +
232
+ '__start_command_ms=$((__start_command_t1 - __start_command_t0)); ' +
233
+ `${v.lifetime}="$((__start_command_ms / 1000)).$(printf '%03d' "$((__start_command_ms % 1000))")s"; fi`,
234
+ ].join('; ');
235
+ }
236
+
237
+ /**
238
+ * Shell fragment appending the post-mortem block for a kept container.
239
+ * @param {string} containerName - Container that was kept
240
+ * @param {string} quotedLogPath - Already shell-quoted log path
241
+ * @returns {string} Shell command
242
+ */
243
+ function buildDockerPostMortemSnippet(containerName, quotedLogPath) {
244
+ const v = SHELL_VARS;
245
+ return (
246
+ `${v.errorText}="$${v.error}"; ` +
247
+ `if [ -z "$${v.errorText}" ]; then ${v.errorText}='${NO_ERROR}'; fi; ` +
248
+ `printf '\\n${POST_MORTEM_HEADER}\\nContainer: %s\\nExit Code: %s\\n` +
249
+ `OOMKilled: %s\\nStartedAt: %s\\nFinishedAt: %s\\nLifetime: %s\\nError: %s\\n' ` +
250
+ `${shellQuote(containerName)} "$${v.exitText}" "$${v.oom}" ` +
251
+ `"$${v.started}" "$${v.finished}" "$${v.lifetime}" "$${v.errorText}" >> ${quotedLogPath}`
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Shell fragment appending the one-line note for a removed container.
257
+ * @param {string} containerName - Container that was removed
258
+ * @param {string} quotedLogPath - Already shell-quoted log path
259
+ * @returns {string} Shell command
260
+ */
261
+ function buildDockerRemovalNoteSnippet(containerName, quotedLogPath) {
262
+ const v = SHELL_VARS;
263
+ return (
264
+ `__start_command_signal_note=''; ` +
265
+ `if [ -n "$${v.signal}" ]; then __start_command_signal_note=", $${v.signal}"; fi; ` +
266
+ `printf '\\nContainer removed: %s (exit %s%s, lifetime %s, oomKilled=%s)\\n' ` +
267
+ `${shellQuote(containerName)} "$${v.exit}" "$__start_command_signal_note" ` +
268
+ `"$${v.lifetime}" "$${v.oom}" >> ${quotedLogPath}`
269
+ );
270
+ }
271
+
272
+ module.exports = {
273
+ DOCKER_STATE_INSPECT_FORMAT,
274
+ DOCKER_ZERO_TIME,
275
+ NO_ERROR,
276
+ POST_MORTEM_HEADER,
277
+ SHELL_VARS,
278
+ UNKNOWN,
279
+ buildDockerPostMortemSnippet,
280
+ buildDockerRemovalNoteSnippet,
281
+ buildDockerStateSnippet,
282
+ buildLifetimeSnippet,
283
+ buildSignalDecodeSnippet,
284
+ describeExitCode,
285
+ formatContainerPostMortem,
286
+ formatContainerRemovalNote,
287
+ formatLifetime,
288
+ normalizeDockerTimestamp,
289
+ };