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.
@@ -0,0 +1,382 @@
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
+ running: '__start_command_running',
142
+ };
143
+
144
+ /** `docker inspect -f` template collecting every post-mortem fact at once. */
145
+ const DOCKER_STATE_INSPECT_FORMAT =
146
+ '{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.StartedAt}} {{.State.FinishedAt}}';
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
+
211
+ /**
212
+ * Shell fragment reading the container's terminal state.
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
+ *
218
+ * The four whitespace-free fields are split with `set --` rather than the
219
+ * `${var%% *}` / `${var##* }` pair the two-field version used: those only ever
220
+ * reach the first and the last field. `State.Error` is read separately because
221
+ * it is free-form text that can contain spaces.
222
+ *
223
+ * @param {string} containerName - Container to inspect
224
+ * @returns {string} Shell command
225
+ */
226
+ function buildDockerStateSnippet(containerName) {
227
+ const quotedName = shellQuote(containerName);
228
+ const v = SHELL_VARS;
229
+ return [
230
+ `__start_command_state=$(docker inspect -f '${DOCKER_STATE_INSPECT_FORMAT}' ${quotedName} 2>/dev/null || printf '%s' '-1 false ${UNKNOWN} ${UNKNOWN}')`,
231
+ 'set -- $__start_command_state',
232
+ `${v.exit}=\${1:--1}`,
233
+ `${v.oom}=\${2:-false}`,
234
+ `${v.started}=\${3:-${UNKNOWN}}`,
235
+ `${v.finished}=\${4:-${UNKNOWN}}`,
236
+ `${v.error}=$(docker inspect -f '{{.State.Error}}' ${quotedName} 2>/dev/null)`,
237
+ `case "$${v.started}" in ${DOCKER_ZERO_TIME}|'') ${v.started}=${UNKNOWN};; esac`,
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)`,
243
+ buildSignalDecodeSnippet(),
244
+ buildLifetimeSnippet(),
245
+ ].join('; ');
246
+ }
247
+
248
+ /**
249
+ * Shell fragment decoding `128 + n` into a signal name.
250
+ *
251
+ * The `case` arms are generated from the very table `describeExitCode()` uses,
252
+ * so the log the watcher writes and the `exitReason` `--status` computes can
253
+ * never name different signals for the same code (issue #171).
254
+ *
255
+ * @returns {string} Shell command
256
+ */
257
+ function buildSignalDecodeSnippet() {
258
+ const v = SHELL_VARS;
259
+ const arms = Object.entries(SIGNAL_NAMES)
260
+ .map(([signal, name]) => `${128 + Number(signal)}) ${v.signal}=${name};;`)
261
+ .join(' ');
262
+ return [
263
+ `${v.signal}=''`,
264
+ `case "$${v.exit}" in ${arms} esac`,
265
+ `${v.exitText}="$${v.exit}"`,
266
+ `if [ -n "$${v.signal}" ]; then ${v.exitText}="$${v.exit} ($${v.signal} - 128+$((${v.exit} - 128)))"; fi`,
267
+ ].join('; ');
268
+ }
269
+
270
+ /**
271
+ * Shell fragment computing how long the container was alive.
272
+ *
273
+ * Best effort by design: `date -d` is GNU, `date -j -f` is BSD, and a host that
274
+ * offers neither leaves the lifetime `unknown` rather than guessing.
275
+ *
276
+ * BSD `date` cannot parse the fractional seconds docker reports, so the
277
+ * fallback strips them before parsing and adds them back afterwards. Truncating
278
+ * both timestamps to whole seconds instead would *not* cancel out: a container
279
+ * alive from `.942` to `.740` of the next-but-five second measures 5.798s but
280
+ * truncates to 6.000s, and the error can reach a full second either way.
281
+ *
282
+ * @returns {string} Shell command
283
+ */
284
+ function buildLifetimeSnippet() {
285
+ const v = SHELL_VARS;
286
+ return [
287
+ '__start_command_millis() { ' +
288
+ 'case "$1" in *.*) __start_command_frac=${1#*.};; ' +
289
+ "*) printf '000'; return;; esac; " +
290
+ '__start_command_frac=${__start_command_frac%%[!0-9]*}; ' +
291
+ 'printf \'%.3s\' "${__start_command_frac}000"; }',
292
+ '__start_command_epoch_ms() { ' +
293
+ '__start_command_ts=$(date -u -d "$1" +%s%3N 2>/dev/null); ' +
294
+ 'case "$__start_command_ts" in \'\'|*[!0-9]*) ' +
295
+ '__start_command_ts=$(date -u -j -f \'%Y-%m-%dT%H:%M:%S\' "${1%.*}" +%s 2>/dev/null) && ' +
296
+ '__start_command_ts="${__start_command_ts}$(__start_command_millis "$1")";; esac; ' +
297
+ "case \"$__start_command_ts\" in ''|*[!0-9]*) __start_command_ts='';; esac; " +
298
+ 'printf \'%s\' "$__start_command_ts"; }',
299
+ `${v.lifetime}=${UNKNOWN}`,
300
+ `__start_command_t0=$(__start_command_epoch_ms "$${v.started}")`,
301
+ `__start_command_t1=$(__start_command_epoch_ms "$${v.finished}")`,
302
+ 'if [ -n "$__start_command_t0" ] && [ -n "$__start_command_t1" ] && ' +
303
+ '[ "$__start_command_t1" -ge "$__start_command_t0" ] 2>/dev/null; then ' +
304
+ '__start_command_ms=$((__start_command_t1 - __start_command_t0)); ' +
305
+ `${v.lifetime}="$((__start_command_ms / 1000)).$(printf '%03d' "$((__start_command_ms % 1000))")s"; fi`,
306
+ ].join('; ');
307
+ }
308
+
309
+ /**
310
+ * Shell fragment appending the post-mortem block for a kept container.
311
+ * @param {string} containerName - Container that was kept
312
+ * @param {string} quotedLogPath - Already shell-quoted log path
313
+ * @returns {string} Shell command
314
+ */
315
+ function buildDockerPostMortemSnippet(containerName, quotedLogPath) {
316
+ const v = SHELL_VARS;
317
+ return (
318
+ `${v.errorText}="$${v.error}"; ` +
319
+ `if [ -z "$${v.errorText}" ]; then ${v.errorText}='${NO_ERROR}'; fi; ` +
320
+ `printf '\\n${POST_MORTEM_HEADER}\\nContainer: %s\\nExit Code: %s\\n` +
321
+ `OOMKilled: %s\\nStartedAt: %s\\nFinishedAt: %s\\nLifetime: %s\\nError: %s\\n' ` +
322
+ `${shellQuote(containerName)} "$${v.exitText}" "$${v.oom}" ` +
323
+ `"$${v.started}" "$${v.finished}" "$${v.lifetime}" "$${v.errorText}" >> ${quotedLogPath}`
324
+ );
325
+ }
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
+
342
+ /**
343
+ * Shell fragment appending the one-line note for a removed container.
344
+ * @param {string} containerName - Container that was removed
345
+ * @param {string} quotedLogPath - Already shell-quoted log path
346
+ * @returns {string} Shell command
347
+ */
348
+ function buildDockerRemovalNoteSnippet(containerName, quotedLogPath) {
349
+ const v = SHELL_VARS;
350
+ return (
351
+ `__start_command_signal_note=''; ` +
352
+ `if [ -n "$${v.signal}" ]; then __start_command_signal_note=", $${v.signal}"; fi; ` +
353
+ `printf '\\nContainer removed: %s (exit %s%s, lifetime %s, oomKilled=%s)\\n' ` +
354
+ `${shellQuote(containerName)} "$${v.exit}" "$__start_command_signal_note" ` +
355
+ `"$${v.lifetime}" "$${v.oom}" >> ${quotedLogPath}`
356
+ );
357
+ }
358
+
359
+ module.exports = {
360
+ DOCKER_RUNNING_INSPECT_FORMAT,
361
+ DOCKER_STATE_INSPECT_FORMAT,
362
+ DOCKER_ZERO_TIME,
363
+ LOG_CAPTURE_STOPPED_NOTE,
364
+ STILL_RUNNING_NOTE,
365
+ NO_ERROR,
366
+ POST_MORTEM_HEADER,
367
+ SHELL_VARS,
368
+ UNKNOWN,
369
+ buildDockerPostMortemSnippet,
370
+ buildDockerRemovalNoteSnippet,
371
+ buildDockerRunningCondition,
372
+ buildDockerStateSnippet,
373
+ buildDockerStillRunningNoteSnippet,
374
+ buildDockerWaitForExitSnippet,
375
+ buildLifetimeSnippet,
376
+ buildSignalDecodeSnippet,
377
+ describeExitCode,
378
+ formatContainerPostMortem,
379
+ formatContainerRemovalNote,
380
+ formatLifetime,
381
+ normalizeDockerTimestamp,
382
+ };
@@ -130,7 +130,8 @@ function resumeAllExecutions(store, deps = {}) {
130
130
  startWatcher(
131
131
  sessionName,
132
132
  getDockerContainerCleanupPolicy(record.options || {}),
133
- record.logPath || null
133
+ record.logPath || null,
134
+ record.uuid || null
134
135
  );
135
136
  executions.push(
136
137
  describeRecord(
@@ -411,7 +411,8 @@ async function resumeExecution(store, identifier, deps = {}) {
411
411
  startWatcher(
412
412
  activeSessionName(plan),
413
413
  getDockerContainerCleanupPolicy(record.options || {}),
414
- record.logPath || null
414
+ record.logPath || null,
415
+ record.uuid || null
415
416
  );
416
417
  }
417
418
 
@@ -75,6 +75,30 @@ class ExecutionRecord {
75
75
  this.logPath = options.logPath || '';
76
76
  this.startTime = options.startTime || new Date().toISOString();
77
77
  this.endTime = options.endTime || null;
78
+ // Provenance of `endTime` (issue #170.2). `--status` used to fabricate a
79
+ // finish time with `new Date()` whenever the record had none, which made an
80
+ // observation indistinguishable from a real finish time. One of
81
+ // 'docker-finished-at' | 'log-footer' | 'observed-at'.
82
+ this.endTimeSource =
83
+ options.endTimeSource !== undefined ? options.endTimeSource : undefined;
84
+ // When the end of the execution was *noticed*, for the cases where no real
85
+ // finish time exists. Always accompanies endTimeSource === 'observed-at'.
86
+ this.observedAt =
87
+ options.observedAt !== undefined ? options.observedAt : undefined;
88
+ // When `cleanupStale()` gave up on a record whose process is gone. This is
89
+ // a detection time, not a finish time, so it is never written to `endTime`
90
+ // (issue #170.3).
91
+ this.staleDetectedAt =
92
+ options.staleDetectedAt !== undefined
93
+ ? options.staleDetectedAt
94
+ : undefined;
95
+ // `docker inspect .State.StartedAt` of the isolation container, kept next
96
+ // to `endTime` so the container's lifetime stays reconstructible after the
97
+ // container itself is gone (issue #171).
98
+ this.containerStartedAt =
99
+ options.containerStartedAt !== undefined
100
+ ? options.containerStartedAt
101
+ : undefined;
78
102
  this.oomKilled =
79
103
  options.oomKilled !== undefined ? options.oomKilled : undefined;
80
104
  // Query-time hint explaining an opaque exit code (issue #162). Derived from
@@ -122,6 +146,21 @@ class ExecutionRecord {
122
146
  startTime: this.startTime,
123
147
  endTime: this.endTime,
124
148
  };
149
+ if (this.endTimeSource !== undefined && this.endTimeSource !== null) {
150
+ obj.endTimeSource = this.endTimeSource;
151
+ }
152
+ if (this.observedAt !== undefined && this.observedAt !== null) {
153
+ obj.observedAt = this.observedAt;
154
+ }
155
+ if (this.staleDetectedAt !== undefined && this.staleDetectedAt !== null) {
156
+ obj.staleDetectedAt = this.staleDetectedAt;
157
+ }
158
+ if (
159
+ this.containerStartedAt !== undefined &&
160
+ this.containerStartedAt !== null
161
+ ) {
162
+ obj.containerStartedAt = this.containerStartedAt;
163
+ }
125
164
  if (this.oomKilled !== undefined && this.oomKilled !== null) {
126
165
  obj.oomKilled = this.oomKilled;
127
166
  }
@@ -662,10 +701,15 @@ class ExecutionStore {
662
701
  (r) => r.uuid === staleRecord.uuid
663
702
  );
664
703
  if (index >= 0) {
665
- // Mark as executed with exit code -1 to indicate abnormal termination
704
+ // Mark as executed with exit code -1 to indicate abnormal
705
+ // termination. The real finish time is unknowable here — the
706
+ // process vanished without writing one — so the record keeps
707
+ // `endTime: null` and states *when the loss was detected* instead
708
+ // of passing cleanup time off as a finish time (issue #170.3).
666
709
  currentRecords[index].status = ExecutionStatus.EXECUTED;
667
710
  currentRecords[index].exitCode = -1;
668
- currentRecords[index].endTime = new Date().toISOString();
711
+ currentRecords[index].endTime = null;
712
+ currentRecords[index].staleDetectedAt = new Date().toISOString();
669
713
  result.cleaned++;
670
714
  }
671
715
  }
@@ -17,6 +17,8 @@
17
17
  * `exitCode` or `oomKilled`.
18
18
  */
19
19
 
20
+ const os = require('os');
21
+
20
22
  /**
21
23
  * Reasons carrying this prefix report memory exhaustion, whatever the mechanism
22
24
  * (runtime self-abort, kernel OOM killer, failed allocation).
@@ -58,6 +60,9 @@ const EXIT_REASON_MARKERS = [
58
60
  * Only the signals that actually show up in command logs are named.
59
61
  * @type {Object<number, string>}
60
62
  */
63
+ /** Rendered when an exit code is unavailable or not numeric. */
64
+ const UNKNOWN_EXIT_CODE = 'unknown';
65
+
61
66
  const SIGNAL_NAMES = {
62
67
  1: 'SIGHUP',
63
68
  2: 'SIGINT',
@@ -179,6 +184,27 @@ function resolveMemoryExhaustion(input) {
179
184
  return null;
180
185
  }
181
186
 
187
+ /**
188
+ * Exit code of a child process that has finished.
189
+ *
190
+ * Node reports `code === null` for a child killed by a signal, and the
191
+ * `code || 0` this replaces turned that into a successful exit 0 (issue #174).
192
+ * A signal death follows the shell's `128 + n` convention, so the result
193
+ * decodes back to the signal through `describeExitCode()`; a missing code with
194
+ * no known signal is a failure (1), never a success.
195
+ *
196
+ * @param {number|null|undefined} code - `code` from `exit`/`close`
197
+ * @param {string|null} [signal] - `signal` from `exit`/`close`, e.g. `SIGKILL`
198
+ * @returns {number} Exit code
199
+ */
200
+ function resolveChildExitCode(code, signal = null) {
201
+ if (typeof code === 'number' && Number.isFinite(code)) {
202
+ return code;
203
+ }
204
+ const number = signal ? os.constants.signals[signal] : undefined;
205
+ return typeof number === 'number' ? 128 + number : 1;
206
+ }
207
+
182
208
  /**
183
209
  * Map a shell exit code to the signal name it encodes.
184
210
  * @param {number|null|undefined} exitCode - Terminal exit code
@@ -194,6 +220,31 @@ function signalNameForExitCode(exitCode) {
194
220
  return SIGNAL_NAMES[exitCode - 128] || null;
195
221
  }
196
222
 
223
+ /**
224
+ * Describe a terminal exit code, decoding the `128+n` signal convention.
225
+ *
226
+ * Single source of truth for issue #171.4: the detached Docker watcher writes
227
+ * this text into the log at completion time (through the shell it generates),
228
+ * and `--status` renders the same text at query time from the stored record.
229
+ * Both used to decode `128+n` on their own, so they could disagree.
230
+ *
231
+ * @param {number|string|null|undefined} exitCode - Terminal exit code
232
+ * @returns {{code: number|null, signal: string|null, text: string}} Description
233
+ */
234
+ function describeExitCode(exitCode) {
235
+ const code =
236
+ typeof exitCode === 'number' ? exitCode : Number.parseInt(exitCode, 10);
237
+ if (!Number.isFinite(code)) {
238
+ return { code: null, signal: null, text: UNKNOWN_EXIT_CODE };
239
+ }
240
+ const signal = signalNameForExitCode(code);
241
+ return {
242
+ code,
243
+ signal,
244
+ text: signal ? `${code} (${signal} - 128+${code - 128})` : String(code),
245
+ };
246
+ }
247
+
197
248
  /**
198
249
  * Resolve the best available hint for why an execution ended.
199
250
  *
@@ -223,12 +274,15 @@ function resolveExitReason(input) {
223
274
 
224
275
  module.exports = {
225
276
  EXIT_REASON_MARKERS,
277
+ UNKNOWN_EXIT_CODE,
226
278
  MEMORY_EXHAUSTION_PREFIX,
227
279
  SIGNAL_NAMES,
228
280
  detectExitReason,
229
281
  detectMemoryMarker,
230
282
  findExitReasonMarker,
231
283
  resolveMemoryExhaustion,
284
+ describeExitCode,
285
+ resolveChildExitCode,
232
286
  resolveExitReason,
233
287
  signalNameForExitCode,
234
288
  };
@@ -4,6 +4,7 @@ const { spawn } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
+ const { describeExitCode, resolveChildExitCode } = require('./exit-reason');
7
8
 
8
9
  function getTempRoot() {
9
10
  return process.env.START_TEMP_ROOT || path.join(os.tmpdir(), 'start-command');
@@ -158,8 +159,11 @@ function shellQuote(value) {
158
159
  }
159
160
 
160
161
  function createShellLogFooterSnippet() {
162
+ // UTC, to match `createLogFooter()` (which formats an ISO timestamp). Two
163
+ // writers of the same `Finished:` line disagreeing on the timezone makes the
164
+ // footer useless as a source for `endTime` (issue #170.2).
161
165
  const dateCommand =
162
- "date '+%Y-%m-%d %H:%M:%S.%3N' 2>/dev/null || date '+%Y-%m-%d %H:%M:%S'";
166
+ "date -u '+%Y-%m-%d %H:%M:%S.%3N' 2>/dev/null || date -u '+%Y-%m-%d %H:%M:%S'";
163
167
  return `printf '\\n==================================================\\nFinished: %s\\nExit Code: %s\\n' "$(${dateCommand})" "$__start_command_exit"`;
164
168
  }
165
169
 
@@ -186,11 +190,15 @@ function runAsIsolatedUser(cmd, username) {
186
190
  stdio: 'inherit',
187
191
  });
188
192
 
189
- child.on('exit', (code) => {
193
+ child.on('exit', (code, signal) => {
194
+ const exitCode = resolveChildExitCode(code, signal);
190
195
  resolve({
191
- success: code === 0,
192
- message: `Command completed as user "${username}" with exit code ${code}`,
193
- exitCode: code || 0,
196
+ success: exitCode === 0,
197
+ message: signal
198
+ ? `Command as user "${username}" was killed by ${signal} (exit code ${exitCode})`
199
+ : `Command completed as user "${username}" with exit code ${exitCode}`,
200
+ exitCode,
201
+ signal: signal || null,
194
202
  });
195
203
  });
196
204
 
@@ -260,6 +268,9 @@ function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
260
268
  }
261
269
 
262
270
  module.exports = {
271
+ // Re-exported so the log writers and the status formatter share one decoder
272
+ // for the `128 + n` signal convention (issue #171.4).
273
+ describeExitCode,
263
274
  LOG_TAIL_BYTES,
264
275
  FATAL_MARKER_TAIL_BYTES,
265
276
  readLogTail,
@@ -28,12 +28,13 @@ const {
28
28
  } = require('./isolation-log-utils');
29
29
  const {
30
30
  buildAttachedDockerKeptMessage,
31
+ readDockerContainerState,
32
+ recordAttachedDockerPostMortem,
31
33
  DOCKER_CONTAINER_CLEANUP_POLICY,
32
34
  getDockerContainerCleanupPolicy,
33
35
  shouldCleanupDockerContainer,
34
36
  getDockerContainerCleanupInstructions,
35
37
  appendDockerContainerCleanupPolicyMessage,
36
- readDockerContainerOomKilled,
37
38
  readDockerContainerStatus,
38
39
  removeDockerContainer,
39
40
  startDetachedDockerCompletionWatcher,
@@ -734,7 +735,8 @@ function runInDocker(command, options = {}) {
734
735
  startDetachedDockerCompletionWatcher(
735
736
  containerName,
736
737
  cleanupPolicy,
737
- options.logPath
738
+ options.logPath,
739
+ options.executionId || null
738
740
  );
739
741
 
740
742
  let message = `Command started in detached docker container: ${containerName}`;
@@ -829,7 +831,15 @@ function runInDocker(command, options = {}) {
829
831
  message += `\n${hint}`;
830
832
  }
831
833
 
832
- const oomKilled = readDockerContainerOomKilled(containerName);
834
+ const dockerState = readDockerContainerState(containerName);
835
+ const oomKilled = dockerState ? dockerState.oomKilled : null;
836
+ const postMortem = (removed) =>
837
+ recordAttachedDockerPostMortem({
838
+ containerName,
839
+ state: dockerState,
840
+ logPath: options.logPath,
841
+ removed,
842
+ });
833
843
  const launchFailed =
834
844
  !containerExistedBeforeLaunch &&
835
845
  readDockerContainerStatus(containerName) === 'created';
@@ -844,23 +854,23 @@ function runInDocker(command, options = {}) {
844
854
  shouldCleanupDockerContainer(cleanupPolicy, exitCode, oomKilled)
845
855
  ) {
846
856
  if (removeDockerContainer(containerName, options.logPath)) {
847
- message += `\nContainer removed after completion.`;
857
+ message += `\nContainer removed after completion.${postMortem(true)}`;
848
858
  } else {
849
859
  message += `\nWarning: failed to remove container automatically.`;
850
860
  message += `\nRemove when done: docker rm -f ${containerName}`;
851
861
  }
852
862
  } else if (cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP) {
853
- message += `\n${getDockerContainerCleanupInstructions(containerName)}`;
863
+ message += `\n${getDockerContainerCleanupInstructions(containerName)}${postMortem(false)}`;
854
864
  } else if (
855
865
  cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL ||
856
866
  cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT
857
867
  ) {
858
- message += buildAttachedDockerKeptMessage({
868
+ message += `${buildAttachedDockerKeptMessage({
859
869
  containerName,
860
870
  exitCode,
861
871
  oomKilled,
862
872
  logPath: options.logPath,
863
- });
873
+ })}${postMortem(false)}`;
864
874
  }
865
875
 
866
876
  resolve({