start-command 0.33.0 → 0.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/bin/cli.js +11 -2
- package/src/lib/command-stream.js +4 -2
- package/src/lib/detached-finalize.js +305 -0
- package/src/lib/docker-cleanup.js +185 -38
- package/src/lib/docker-post-mortem.js +382 -0
- package/src/lib/execution-resume-all.js +2 -1
- package/src/lib/execution-resume.js +2 -1
- package/src/lib/execution-store.js +46 -2
- package/src/lib/exit-reason.js +54 -0
- package/src/lib/isolation-log-utils.js +16 -5
- package/src/lib/isolation.js +17 -7
- package/src/lib/spawn-helpers.js +4 -2
- package/src/lib/status-formatter.js +115 -24
- package/test/execution-store.js +5 -2
- package/test/regression-170.js +324 -0
- package/test/regression-171.js +562 -0
- package/test/regression-174.js +397 -0
|
@@ -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
|
|
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 =
|
|
711
|
+
currentRecords[index].endTime = null;
|
|
712
|
+
currentRecords[index].staleDetectedAt = new Date().toISOString();
|
|
669
713
|
result.cleaned++;
|
|
670
714
|
}
|
|
671
715
|
}
|
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).
|
|
@@ -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:
|
|
192
|
-
message:
|
|
193
|
-
|
|
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,
|
package/src/lib/isolation.js
CHANGED
|
@@ -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
|
|
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({
|