start-command 0.34.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,11 @@
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
+
3
9
  ## 0.34.0
4
10
 
5
11
  ### 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.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
@@ -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) {
@@ -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,6 +315,11 @@ 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
+ *
315
323
  * @param {string} containerName - Docker container name
316
324
  * @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
317
325
  * @param {string|null} logPath - Log file to append to, or null
@@ -326,6 +334,9 @@ function buildDetachedDockerCompletionScript(
326
334
  ) {
327
335
  const quotedName = shellQuote(containerName);
328
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 = [];
329
340
 
330
341
  if (logPath) {
331
342
  const quotedLogPath = shellQuote(logPath);
@@ -341,33 +352,35 @@ function buildDetachedDockerCompletionScript(
341
352
  const keep = `${postMortem}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
342
353
 
343
354
  parts.push(`docker logs -f ${quotedName} >> ${quotedLogPath} 2>&1`);
355
+ parts.push(buildDockerWaitForExitSnippet(containerName, quotedLogPath));
344
356
  parts.push(buildDockerStateSnippet(containerName));
345
357
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
346
- parts.push(remove);
358
+ exited.push(remove);
347
359
  } else if (
348
360
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
349
361
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
350
362
  ) {
351
- parts.push(
363
+ exited.push(
352
364
  `if ${buildSuccessfulNonOomCondition()}; then ${remove}; else ${keep}; fi`
353
365
  );
354
366
  } else {
355
367
  // KEEP: the container is never removed, so the log used to end with the
356
368
  // raw command output and nothing else. The post-mortem is exactly the
357
369
  // information an operator needs before deciding what to do with it.
358
- parts.push(keep);
370
+ exited.push(keep);
359
371
  }
360
- parts.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
372
+ exited.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
361
373
  } else {
362
374
  parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
375
+ parts.push(buildDockerWaitForExitSnippet(containerName));
363
376
  parts.push(buildDockerStateSnippet(containerName));
364
377
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
365
- parts.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
378
+ exited.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
366
379
  } else if (
367
380
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
368
381
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
369
382
  ) {
370
- parts.push(
383
+ exited.push(
371
384
  `if ${buildSuccessfulNonOomCondition()}; then docker rm -f ${quotedName} >/dev/null 2>&1 || true; fi`
372
385
  );
373
386
  }
@@ -375,9 +388,17 @@ function buildDetachedDockerCompletionScript(
375
388
 
376
389
  if (executionId) {
377
390
  // Last, so the record is only marked terminal once the log is complete.
378
- parts.push(buildDetachedFinalizeSnippet(executionId));
391
+ exited.push(buildDetachedFinalizeSnippet(executionId));
379
392
  }
380
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
+
381
402
  return parts.join('; ');
382
403
  }
383
404
 
@@ -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,
@@ -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).
@@ -182,6 +184,27 @@ function resolveMemoryExhaustion(input) {
182
184
  return null;
183
185
  }
184
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
+
185
208
  /**
186
209
  * Map a shell exit code to the signal name it encodes.
187
210
  * @param {number|null|undefined} exitCode - Terminal exit code
@@ -259,6 +282,7 @@ module.exports = {
259
282
  findExitReasonMarker,
260
283
  resolveMemoryExhaustion,
261
284
  describeExitCode,
285
+ resolveChildExitCode,
262
286
  resolveExitReason,
263
287
  signalNameForExitCode,
264
288
  };
@@ -4,7 +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 } = require('./exit-reason');
7
+ const { describeExitCode, resolveChildExitCode } = require('./exit-reason');
8
8
 
9
9
  function getTempRoot() {
10
10
  return process.env.START_TEMP_ROOT || path.join(os.tmpdir(), 'start-command');
@@ -190,11 +190,15 @@ function runAsIsolatedUser(cmd, username) {
190
190
  stdio: 'inherit',
191
191
  });
192
192
 
193
- child.on('exit', (code) => {
193
+ child.on('exit', (code, signal) => {
194
+ const exitCode = resolveChildExitCode(code, signal);
194
195
  resolve({
195
- success: code === 0,
196
- message: `Command completed as user "${username}" with exit code ${code}`,
197
- 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,
198
202
  });
199
203
  });
200
204
 
@@ -12,6 +12,7 @@
12
12
  const { spawn } = require('child_process');
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
+ const { resolveChildExitCode } = require('./exit-reason');
15
16
 
16
17
  function ensureParentDirectory(filePath) {
17
18
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -282,8 +283,9 @@ function runWithNodeSpawn(options) {
282
283
  // The 'close' event fires after all stdio streams have been closed, ensuring
283
284
  // all stdout/stderr data has been received. The 'exit' event can fire before
284
285
  // buffered data is received, causing output loss on macOS (Issue #57).
285
- child.on('close', (code) => {
286
- const exitCode = code || 0;
286
+ child.on('close', (code, signal) => {
287
+ // A signal death reports `code === null`: never record it as exit 0.
288
+ const exitCode = resolveChildExitCode(code, signal);
287
289
  const durationMs = Date.now() - startTimeMs;
288
290
  const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
289
291
 
@@ -301,7 +301,10 @@ describe('issue #170.1: the detached watcher persists the terminal state', () =>
301
301
 
302
302
  const stored = store.get(record.uuid);
303
303
  expect(stored.status).toBe(ExecutionStatus.EXECUTED);
304
- expect(stored.exitCode).toBe(0);
304
+ // `ExitCode=0` next to a zero `FinishedAt` is Docker's zero value, not an
305
+ // observed success (issue #174).
306
+ expect(stored.exitCode).toBe(-1);
307
+ expect(stored.exitReason).toBe('watcher-lost-container');
305
308
  expect(stored.endTimeSource).toBe('observed-at');
306
309
  expect(stored.observedAt).toBe(stored.endTime);
307
310
  expect(new Date(stored.endTime).getUTCFullYear()).toBeGreaterThan(2000);
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Regression tests for issue #174: the detached docker watcher `docker rm -f`ed
3
+ * a still-running container when `docker logs -f` failed (ENOSPC) and
4
+ * finalized it as executed / exit 0.
5
+ *
6
+ * - `docker logs -f C >> LOG` also returns when its own write to LOG fails,
7
+ * and on a dockerd restart; the watcher must wait for the real exit;
8
+ * - a container that is still running must never be removed, get an
9
+ * `Exit Code:` footer, or be finalized;
10
+ * - `ExitCode=0` next to a zero `FinishedAt` is Docker's zero value, never an
11
+ * observed exit 0;
12
+ * - a child killed by a signal (`code === null`) must never become exit 0.
13
+ */
14
+
15
+ const { describe, it, expect, afterEach } = require('bun:test');
16
+ const { spawnSync } = require('child_process');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+
21
+ const {
22
+ ExecutionStore,
23
+ ExecutionRecord,
24
+ ExecutionStatus,
25
+ } = require('../src/lib/execution-store');
26
+ const {
27
+ buildDetachedDockerCompletionScript,
28
+ DOCKER_CONTAINER_CLEANUP_POLICY,
29
+ } = require('../src/lib/docker-cleanup');
30
+ const {
31
+ LOG_CAPTURE_STOPPED_NOTE,
32
+ STILL_RUNNING_NOTE,
33
+ } = require('../src/lib/docker-post-mortem');
34
+ const {
35
+ WATCHER_LOST_CONTAINER,
36
+ finalizeDetachedExecution,
37
+ } = require('../src/lib/detached-finalize');
38
+ const { resolveChildExitCode } = require('../src/lib/exit-reason');
39
+ const { runWithNodeSpawn } = require('../src/lib/spawn-helpers');
40
+
41
+ const tempDirs = [];
42
+
43
+ function makeTempDir(prefix) {
44
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
45
+ tempDirs.push(dir);
46
+ return dir;
47
+ }
48
+
49
+ afterEach(() => {
50
+ while (tempDirs.length > 0) {
51
+ const dir = tempDirs.pop();
52
+ fs.rmSync(dir, { recursive: true, force: true });
53
+ }
54
+ });
55
+
56
+ function detachedDockerRecord(logPath) {
57
+ return new ExecutionRecord({
58
+ status: ExecutionStatus.EXECUTING,
59
+ command: 'work',
60
+ logPath,
61
+ options: {
62
+ isolated: 'docker',
63
+ isolationMode: 'detached',
64
+ sessionName: 'start-command-174',
65
+ },
66
+ });
67
+ }
68
+
69
+ // The watcher body is a POSIX `sh` script; Windows has no `/bin/sh`.
70
+ const needsPosixShell = () => {
71
+ if (process.platform !== 'win32') {
72
+ return false;
73
+ }
74
+ console.log(' Skipping: the watcher needs a POSIX /bin/sh');
75
+ return true;
76
+ };
77
+
78
+ /**
79
+ * Run the real watcher script against a scripted fake `docker`.
80
+ *
81
+ * `running` lists the successive answers to `docker inspect -f
82
+ * '{{.State.Running}}'` (`true`, `false`, or `fail` for an inspect that
83
+ * errors, as during a dockerd restart); the last answer sticks. `docker logs`
84
+ * writes one line and fails, exactly like `docker logs -f C >> LOG` whose write
85
+ * hit ENOSPC. Every call is recorded, and `rm` records whether the container was
86
+ * running at that moment.
87
+ */
88
+ function runWatcher({ policy, running, state, withLog = true }) {
89
+ const dir = makeTempDir('watcher-174-');
90
+ const binDir = path.join(dir, 'bin');
91
+ const appFolder = path.join(dir, 'app');
92
+ fs.mkdirSync(binDir);
93
+ const logPath = path.join(dir, 'run.log');
94
+ fs.writeFileSync(path.join(dir, 'running'), `${running.join('\n')}\n`);
95
+ fs.writeFileSync(path.join(dir, 'state'), `${state}\n`);
96
+ const calls = path.join(dir, 'calls');
97
+ const dockerPath = path.join(binDir, 'docker');
98
+ fs.writeFileSync(
99
+ dockerPath,
100
+ [
101
+ '#!/bin/sh',
102
+ `dir='${dir}'`,
103
+ 'case "$1" in',
104
+ ' inspect)',
105
+ ' case "$3" in',
106
+ ' *State.Running*)',
107
+ ' answer=$(head -n 1 "$dir/running")',
108
+ ' if [ "$(wc -l < "$dir/running")" -gt 1 ]; then',
109
+ ' tail -n +2 "$dir/running" > "$dir/running.next" && mv "$dir/running.next" "$dir/running"',
110
+ ' fi',
111
+ ' echo "inspect-running $answer" >> "$dir/calls"',
112
+ ' [ "$answer" = fail ] && exit 1',
113
+ ' echo "$answer" > "$dir/current"',
114
+ ' echo "$answer" ;;',
115
+ ' *State.Error*) echo "" ;;',
116
+ ' *) echo "inspect-state" >> "$dir/calls"; cat "$dir/state" ;;',
117
+ ' esac ;;',
118
+ ' logs) echo logs >> "$dir/calls"; echo work; exit 1 ;;',
119
+ ' wait) echo wait >> "$dir/calls"; echo 0 ;;',
120
+ ' rm) echo "rm running=$(cat "$dir/current" 2>/dev/null)" >> "$dir/calls" ;;',
121
+ 'esac',
122
+ '',
123
+ ].join('\n'),
124
+ 'utf8'
125
+ );
126
+ fs.chmodSync(dockerPath, 0o755);
127
+
128
+ const store = new ExecutionStore({ appFolder, useLinks: false });
129
+ const record = detachedDockerRecord(withLog ? logPath : null);
130
+ store.save(record);
131
+
132
+ const script = buildDetachedDockerCompletionScript(
133
+ 'demo',
134
+ policy,
135
+ withLog ? logPath : null,
136
+ record.uuid
137
+ );
138
+ const result = spawnSync('/bin/sh', ['-c', script], {
139
+ encoding: 'utf8',
140
+ env: {
141
+ ...process.env,
142
+ PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
143
+ START_APP_FOLDER: appFolder,
144
+ START_DISABLE_TRACKING: '',
145
+ },
146
+ timeout: 20000,
147
+ });
148
+ return {
149
+ status: result.status,
150
+ calls: fs.readFileSync(calls, 'utf8').trim().split('\n'),
151
+ log: fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '',
152
+ record: new ExecutionStore({ appFolder, useLinks: false }).get(record.uuid),
153
+ };
154
+ }
155
+
156
+ const FINISHED_7 =
157
+ '7 false 2026-09-27T10:00:00.000000000Z 2026-09-27T10:00:05.000000000Z';
158
+ const FINISHED_0 =
159
+ '0 false 2026-09-27T10:00:00.000000000Z 2026-09-27T10:00:05.000000000Z';
160
+ const NOT_FINISHED = '0 false 2026-09-27T10:00:00Z 0001-01-01T00:00:00Z';
161
+
162
+ describe('issue #174: docker logs -f returning is not the container exiting', () => {
163
+ it('waits for the real exit after docker logs -f fails (ENOSPC)', () => {
164
+ if (needsPosixShell()) {
165
+ return;
166
+ }
167
+ const run = runWatcher({
168
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
169
+ running: ['true', 'true', 'false'],
170
+ state: FINISHED_7,
171
+ });
172
+
173
+ expect(run.status).toBe(0);
174
+ expect(run.calls).toContain('wait');
175
+ expect(run.calls.indexOf('wait')).toBeGreaterThan(
176
+ run.calls.indexOf('logs')
177
+ );
178
+ expect(run.calls.indexOf('inspect-state')).toBeGreaterThan(
179
+ run.calls.lastIndexOf('wait')
180
+ );
181
+ expect(run.log).toContain(LOG_CAPTURE_STOPPED_NOTE);
182
+ expect(run.log).toContain('Exit Code: 7');
183
+ expect(run.log).not.toContain('Exit Code: 0');
184
+ expect(run.record.status).toBe(ExecutionStatus.EXECUTED);
185
+ expect(run.record.exitCode).toBe(7);
186
+ expect(run.record.endTimeSource).toBe('docker-finished-at');
187
+ });
188
+
189
+ it('only removes the container once it has really exited', () => {
190
+ if (needsPosixShell()) {
191
+ return;
192
+ }
193
+ const run = runWatcher({
194
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
195
+ running: ['true', 'true', 'false'],
196
+ state: FINISHED_0,
197
+ });
198
+
199
+ expect(run.calls).toContain('rm running=false');
200
+ expect(run.calls).not.toContain('rm running=true');
201
+ expect(run.log).toContain('Exit Code: 0');
202
+ expect(run.record.exitCode).toBe(0);
203
+ });
204
+
205
+ it('keeps waiting across a failed docker inspect (dockerd restart)', () => {
206
+ if (needsPosixShell()) {
207
+ return;
208
+ }
209
+ const run = runWatcher({
210
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
211
+ running: ['true', 'true', 'true', 'false'],
212
+ state: FINISHED_7,
213
+ });
214
+
215
+ expect(run.calls.filter((call) => call === 'wait').length).toBe(2);
216
+ expect(run.record.exitCode).toBe(7);
217
+ });
218
+
219
+ it('applies the same wait to the no-log watcher', () => {
220
+ if (needsPosixShell()) {
221
+ return;
222
+ }
223
+ const run = runWatcher({
224
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
225
+ running: ['true', 'false'],
226
+ state: FINISHED_7,
227
+ withLog: false,
228
+ });
229
+
230
+ expect(run.calls.filter((call) => call === 'wait').length).toBe(2);
231
+ expect(run.calls).toContain('rm running=false');
232
+ expect(run.calls).not.toContain('rm running=true');
233
+ expect(run.record.exitCode).toBe(7);
234
+ });
235
+ });
236
+
237
+ describe('issue #174: a container that is still running is left alone', () => {
238
+ it('never removes, footers or finalizes a running container', () => {
239
+ if (needsPosixShell()) {
240
+ return;
241
+ }
242
+ // logs -f fails -> running; the wait loop's inspect fails (daemon down) ->
243
+ // the loop ends; once the daemon is back the container is still running.
244
+ const run = runWatcher({
245
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
246
+ running: ['true', 'fail', 'true'],
247
+ state: NOT_FINISHED,
248
+ });
249
+
250
+ expect(run.calls.some((call) => call.startsWith('rm'))).toBe(false);
251
+ expect(run.log).toContain('Container still running: demo');
252
+ expect(run.log).toContain(STILL_RUNNING_NOTE);
253
+ expect(run.log).not.toContain('Exit Code:');
254
+ expect(run.record.status).toBe(ExecutionStatus.EXECUTING);
255
+ expect(run.record.exitCode).toBeNull();
256
+ });
257
+
258
+ it('never removes a running container without a log either', () => {
259
+ if (needsPosixShell()) {
260
+ return;
261
+ }
262
+ const run = runWatcher({
263
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
264
+ running: ['fail', 'true'],
265
+ state: NOT_FINISHED,
266
+ withLog: false,
267
+ });
268
+
269
+ expect(run.calls.some((call) => call.startsWith('rm'))).toBe(false);
270
+ expect(run.record.status).toBe(ExecutionStatus.EXECUTING);
271
+ });
272
+ });
273
+
274
+ describe('issue #174: a zero FinishedAt is never an exit 0', () => {
275
+ it('keeps the container and records no success in the watcher', () => {
276
+ if (needsPosixShell()) {
277
+ return;
278
+ }
279
+ const run = runWatcher({
280
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
281
+ running: ['false'],
282
+ state: NOT_FINISHED,
283
+ });
284
+
285
+ expect(run.calls.some((call) => call.startsWith('rm'))).toBe(false);
286
+ expect(run.log).toContain('Exit Code: -1');
287
+ expect(run.log).not.toContain('Exit Code: 0');
288
+ expect(run.record.status).toBe(ExecutionStatus.EXECUTED);
289
+ expect(run.record.exitCode).toBe(-1);
290
+ expect(run.record.exitReason).toBe(WATCHER_LOST_CONTAINER);
291
+ expect(run.record.endTimeSource).toBe('observed-at');
292
+ });
293
+
294
+ it('refuses to finalize a record whose container is still running', () => {
295
+ const appFolder = makeTempDir('finalize-174-running-');
296
+ const store = new ExecutionStore({ appFolder, useLinks: false });
297
+ const record = detachedDockerRecord(null);
298
+ store.save(record);
299
+
300
+ const outcome = finalizeDetachedExecution({
301
+ store,
302
+ executionId: record.uuid,
303
+ exitCode: '0',
304
+ oomKilled: 'false',
305
+ startedAt: '2026-09-27T10:00:00Z',
306
+ finishedAt: '0001-01-01T00:00:00Z',
307
+ containerError: '',
308
+ running: 'true',
309
+ });
310
+
311
+ expect(outcome).toEqual({ updated: false, reason: 'still-running' });
312
+ expect(store.get(record.uuid).status).toBe(ExecutionStatus.EXECUTING);
313
+ });
314
+
315
+ it('does not trust exit 0 without a FinishedAt', () => {
316
+ const appFolder = makeTempDir('finalize-174-zero-');
317
+ const store = new ExecutionStore({ appFolder, useLinks: false });
318
+ const record = detachedDockerRecord(null);
319
+ store.save(record);
320
+
321
+ finalizeDetachedExecution({
322
+ store,
323
+ executionId: record.uuid,
324
+ exitCode: '0',
325
+ oomKilled: 'false',
326
+ startedAt: '2026-09-27T10:00:00Z',
327
+ finishedAt: '0001-01-01T00:00:00Z',
328
+ running: 'false',
329
+ });
330
+
331
+ const stored = store.get(record.uuid);
332
+ expect(stored.exitCode).toBe(-1);
333
+ expect(stored.exitReason).toBe(WATCHER_LOST_CONTAINER);
334
+ });
335
+
336
+ it('still records a real exit 0', () => {
337
+ const appFolder = makeTempDir('finalize-174-real-zero-');
338
+ const store = new ExecutionStore({ appFolder, useLinks: false });
339
+ const record = detachedDockerRecord(null);
340
+ store.save(record);
341
+
342
+ finalizeDetachedExecution({
343
+ store,
344
+ executionId: record.uuid,
345
+ exitCode: '0',
346
+ oomKilled: 'false',
347
+ startedAt: '2026-09-27T10:00:00Z',
348
+ finishedAt: '2026-09-27T10:00:05Z',
349
+ running: 'false',
350
+ });
351
+
352
+ const stored = store.get(record.uuid);
353
+ expect(stored.exitCode).toBe(0);
354
+ expect(stored.exitReason).toBeUndefined();
355
+ expect(stored.endTimeSource).toBe('docker-finished-at');
356
+ });
357
+ });
358
+
359
+ describe('issue #174: a signal death is never exit 0', () => {
360
+ it('maps a null code with a signal to 128+n', () => {
361
+ expect(resolveChildExitCode(null, 'SIGKILL')).toBe(137);
362
+ expect(resolveChildExitCode(null, 'SIGTERM')).toBe(143);
363
+ expect(resolveChildExitCode(null, 'SIGINT')).toBe(130);
364
+ });
365
+
366
+ it('maps a missing code without a signal to a failure', () => {
367
+ expect(resolveChildExitCode(null)).toBe(1);
368
+ expect(resolveChildExitCode(undefined)).toBe(1);
369
+ expect(resolveChildExitCode(null, 'SIGNOTREAL')).toBe(1);
370
+ });
371
+
372
+ it('keeps real exit codes, including 0', () => {
373
+ expect(resolveChildExitCode(0)).toBe(0);
374
+ expect(resolveChildExitCode(3, null)).toBe(3);
375
+ });
376
+
377
+ it('records a self-killed child as 137 in the node spawn path', async () => {
378
+ if (needsPosixShell()) {
379
+ return;
380
+ }
381
+ const dir = makeTempDir('spawn-174-');
382
+ const logFilePath = path.join(dir, 'run.log');
383
+ const exitCode = await new Promise((resolve) => {
384
+ runWithNodeSpawn({
385
+ shell: '/bin/sh',
386
+ shellArgs: ['-c', 'kill -KILL $$'],
387
+ logFilePath,
388
+ startTimeMs: Date.now(),
389
+ onComplete: (code) => resolve(code),
390
+ onError: () => resolve('error'),
391
+ });
392
+ });
393
+
394
+ expect(exitCode).toBe(137);
395
+ expect(fs.readFileSync(logFilePath, 'utf8')).toContain('Exit Code: 137');
396
+ });
397
+ });