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 +6 -0
- package/package.json +1 -1
- package/src/bin/cli.js +2 -1
- package/src/lib/command-stream.js +4 -2
- package/src/lib/detached-finalize.js +31 -7
- package/src/lib/docker-cleanup.js +28 -7
- package/src/lib/docker-post-mortem.js +93 -0
- package/src/lib/exit-reason.js +24 -0
- package/src/lib/isolation-log-utils.js +9 -5
- package/src/lib/spawn-helpers.js +4 -2
- package/test/regression-170.js +4 -1
- package/test/regression-174.js +397 -0
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
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
|
|
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
|
|
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
|
|
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 (
|
|
78
|
-
record.exitCode =
|
|
79
|
-
} else if (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
+
exited.push(keep);
|
|
359
371
|
}
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/lib/exit-reason.js
CHANGED
|
@@ -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:
|
|
196
|
-
message:
|
|
197
|
-
|
|
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
|
|
package/src/lib/spawn-helpers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/test/regression-170.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|