start-command 0.33.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/bin/cli.js +9 -1
- package/src/lib/detached-finalize.js +281 -0
- package/src/lib/docker-cleanup.js +160 -34
- package/src/lib/docker-post-mortem.js +289 -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 +30 -0
- package/src/lib/isolation-log-utils.js +8 -1
- package/src/lib/isolation.js +17 -7
- package/src/lib/status-formatter.js +115 -24
- package/test/execution-store.js +5 -2
- package/test/regression-170.js +321 -0
- package/test/regression-171.js +562 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container post-mortem facts (issues #170, #171).
|
|
3
|
+
*
|
|
4
|
+
* When a detached container dies the only thing `start` used to write into the
|
|
5
|
+
* log was `Reason: exitCode=137 oomKilled=false` — the two facts that, together,
|
|
6
|
+
* say the least. `137` is `128 + 9`, i.e. SIGKILL, and `oomKilled=false` rules
|
|
7
|
+
* out the cgroup OOM killer, so the container was killed from the outside
|
|
8
|
+
* (`docker kill`, a `docker stop` escalation, systemd, the CI runner). None of
|
|
9
|
+
* that was said out loud, and the three fields that would have shown *when* it
|
|
10
|
+
* happened — `State.StartedAt`, `State.FinishedAt`, `State.Error` — were never
|
|
11
|
+
* read at all, even though the watcher was already one `docker inspect` away
|
|
12
|
+
* from them.
|
|
13
|
+
*
|
|
14
|
+
* This module owns those facts in one place:
|
|
15
|
+
* - `describeExitCode()` decodes `128 + n` once, for the watcher (completion
|
|
16
|
+
* time, written into the log) and for `--status` (query time, in memory), so
|
|
17
|
+
* the two can never disagree (issue #171);
|
|
18
|
+
* - the `build*Snippet()` helpers generate the POSIX shell the detached watcher
|
|
19
|
+
* runs, including the signal table, so the shell and the runtime share a
|
|
20
|
+
* single source of truth;
|
|
21
|
+
* - `formatContainerPostMortem()` renders the same block for callers that
|
|
22
|
+
* already hold the facts in memory (the attached docker path).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
SIGNAL_NAMES,
|
|
27
|
+
UNKNOWN_EXIT_CODE,
|
|
28
|
+
describeExitCode,
|
|
29
|
+
} = require('./exit-reason');
|
|
30
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Docker's zero value for a timestamp that was never set. `State.FinishedAt`
|
|
34
|
+
* carries it for every container that has not finished, and `State.StartedAt`
|
|
35
|
+
* for every container that never started. It must never reach a record as an
|
|
36
|
+
* `endTime`: a year-1 timestamp is worse than an honest `null`.
|
|
37
|
+
*/
|
|
38
|
+
const DOCKER_ZERO_TIME = '0001-01-01T00:00:00Z';
|
|
39
|
+
|
|
40
|
+
/** Placeholder written instead of a fact that could not be observed. */
|
|
41
|
+
const UNKNOWN = UNKNOWN_EXIT_CODE;
|
|
42
|
+
|
|
43
|
+
/** Placeholder for `State.Error` when docker reported no error at all. */
|
|
44
|
+
const NO_ERROR = '(none)';
|
|
45
|
+
|
|
46
|
+
/** Header line of the post-mortem block appended to a kept container's log. */
|
|
47
|
+
const POST_MORTEM_HEADER = '=== Container post-mortem ===';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Normalize a timestamp reported by `docker inspect`.
|
|
51
|
+
* @param {string|null|undefined} value - Raw `State.StartedAt`/`State.FinishedAt`
|
|
52
|
+
* @returns {string|null} The timestamp, or null when docker reported no time
|
|
53
|
+
*/
|
|
54
|
+
function normalizeDockerTimestamp(value) {
|
|
55
|
+
const text = String(
|
|
56
|
+
value === null || value === undefined ? '' : value
|
|
57
|
+
).trim();
|
|
58
|
+
if (!text || text === DOCKER_ZERO_TIME || text === UNKNOWN) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (text === '<no value>' || !Number.isFinite(Date.parse(text))) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Format the time a container was alive.
|
|
69
|
+
* @param {string|null} startedAt - `State.StartedAt`
|
|
70
|
+
* @param {string|null} finishedAt - `State.FinishedAt`
|
|
71
|
+
* @returns {string|null} Lifetime such as `5.798s`, or null when unknown
|
|
72
|
+
*/
|
|
73
|
+
function formatLifetime(startedAt, finishedAt) {
|
|
74
|
+
const start = normalizeDockerTimestamp(startedAt);
|
|
75
|
+
const end = normalizeDockerTimestamp(finishedAt);
|
|
76
|
+
if (!start || !end) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const ms = Date.parse(end) - Date.parse(start);
|
|
80
|
+
if (!Number.isFinite(ms) || ms < 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return `${Math.floor(ms / 1000)}.${String(ms % 1000).padStart(3, '0')}s`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render the post-mortem block for facts already held in memory.
|
|
88
|
+
* @param {object} facts - Container name and inspected `State` fields
|
|
89
|
+
* @returns {string} Block text, newline terminated
|
|
90
|
+
*/
|
|
91
|
+
function formatContainerPostMortem(facts = {}) {
|
|
92
|
+
const startedAt = normalizeDockerTimestamp(facts.startedAt);
|
|
93
|
+
const finishedAt = normalizeDockerTimestamp(facts.finishedAt);
|
|
94
|
+
const lifetime = formatLifetime(facts.startedAt, facts.finishedAt);
|
|
95
|
+
const error = String(facts.error || '').trim();
|
|
96
|
+
return [
|
|
97
|
+
POST_MORTEM_HEADER,
|
|
98
|
+
`Container: ${facts.containerName || UNKNOWN}`,
|
|
99
|
+
`Exit Code: ${describeExitCode(facts.exitCode).text}`,
|
|
100
|
+
`OOMKilled: ${facts.oomKilled === undefined || facts.oomKilled === null ? UNKNOWN : facts.oomKilled}`,
|
|
101
|
+
`StartedAt: ${startedAt || UNKNOWN}`,
|
|
102
|
+
`FinishedAt: ${finishedAt || UNKNOWN}`,
|
|
103
|
+
`Lifetime: ${lifetime || UNKNOWN}`,
|
|
104
|
+
`Error: ${error || NO_ERROR}`,
|
|
105
|
+
'',
|
|
106
|
+
].join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Render the single line written when a container is removed, so a successful
|
|
111
|
+
* run keeps its log terse while still carrying the same facts (issue #171).
|
|
112
|
+
* @param {object} facts - Container name and inspected `State` fields
|
|
113
|
+
* @returns {string} One line, newline terminated
|
|
114
|
+
*/
|
|
115
|
+
function formatContainerRemovalNote(facts = {}) {
|
|
116
|
+
const described = describeExitCode(facts.exitCode);
|
|
117
|
+
const signal = described.signal ? `, ${described.signal}` : '';
|
|
118
|
+
const lifetime = formatLifetime(facts.startedAt, facts.finishedAt) || UNKNOWN;
|
|
119
|
+
const oomKilled =
|
|
120
|
+
facts.oomKilled === undefined || facts.oomKilled === null
|
|
121
|
+
? UNKNOWN
|
|
122
|
+
: facts.oomKilled;
|
|
123
|
+
return (
|
|
124
|
+
`Container removed: ${facts.containerName || UNKNOWN} ` +
|
|
125
|
+
`(exit ${described.code === null ? UNKNOWN : described.code}${signal}, ` +
|
|
126
|
+
`lifetime ${lifetime}, oomKilled=${oomKilled})\n`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Shell variables the completion watcher fills in and the snippets consume. */
|
|
131
|
+
const SHELL_VARS = {
|
|
132
|
+
exit: '__start_command_exit',
|
|
133
|
+
oom: '__start_command_oom',
|
|
134
|
+
started: '__start_command_started',
|
|
135
|
+
finished: '__start_command_finished',
|
|
136
|
+
error: '__start_command_error',
|
|
137
|
+
errorText: '__start_command_error_text',
|
|
138
|
+
signal: '__start_command_signal',
|
|
139
|
+
exitText: '__start_command_exit_text',
|
|
140
|
+
lifetime: '__start_command_lifetime',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** `docker inspect -f` template collecting every post-mortem fact at once. */
|
|
144
|
+
const DOCKER_STATE_INSPECT_FORMAT =
|
|
145
|
+
'{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.StartedAt}} {{.State.FinishedAt}}';
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Shell fragment reading the container's terminal state.
|
|
149
|
+
*
|
|
150
|
+
* The four whitespace-free fields are split with `set --` rather than the
|
|
151
|
+
* `${var%% *}` / `${var##* }` pair the two-field version used: those only ever
|
|
152
|
+
* reach the first and the last field. `State.Error` is read separately because
|
|
153
|
+
* it is free-form text that can contain spaces.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} containerName - Container to inspect
|
|
156
|
+
* @returns {string} Shell command
|
|
157
|
+
*/
|
|
158
|
+
function buildDockerStateSnippet(containerName) {
|
|
159
|
+
const quotedName = shellQuote(containerName);
|
|
160
|
+
const v = SHELL_VARS;
|
|
161
|
+
return [
|
|
162
|
+
`__start_command_state=$(docker inspect -f '${DOCKER_STATE_INSPECT_FORMAT}' ${quotedName} 2>/dev/null || printf '%s' '-1 false ${UNKNOWN} ${UNKNOWN}')`,
|
|
163
|
+
'set -- $__start_command_state',
|
|
164
|
+
`${v.exit}=\${1:--1}`,
|
|
165
|
+
`${v.oom}=\${2:-false}`,
|
|
166
|
+
`${v.started}=\${3:-${UNKNOWN}}`,
|
|
167
|
+
`${v.finished}=\${4:-${UNKNOWN}}`,
|
|
168
|
+
`${v.error}=$(docker inspect -f '{{.State.Error}}' ${quotedName} 2>/dev/null)`,
|
|
169
|
+
`case "$${v.started}" in ${DOCKER_ZERO_TIME}|'') ${v.started}=${UNKNOWN};; esac`,
|
|
170
|
+
`case "$${v.finished}" in ${DOCKER_ZERO_TIME}|'') ${v.finished}=${UNKNOWN};; esac`,
|
|
171
|
+
buildSignalDecodeSnippet(),
|
|
172
|
+
buildLifetimeSnippet(),
|
|
173
|
+
].join('; ');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Shell fragment decoding `128 + n` into a signal name.
|
|
178
|
+
*
|
|
179
|
+
* The `case` arms are generated from the very table `describeExitCode()` uses,
|
|
180
|
+
* so the log the watcher writes and the `exitReason` `--status` computes can
|
|
181
|
+
* never name different signals for the same code (issue #171).
|
|
182
|
+
*
|
|
183
|
+
* @returns {string} Shell command
|
|
184
|
+
*/
|
|
185
|
+
function buildSignalDecodeSnippet() {
|
|
186
|
+
const v = SHELL_VARS;
|
|
187
|
+
const arms = Object.entries(SIGNAL_NAMES)
|
|
188
|
+
.map(([signal, name]) => `${128 + Number(signal)}) ${v.signal}=${name};;`)
|
|
189
|
+
.join(' ');
|
|
190
|
+
return [
|
|
191
|
+
`${v.signal}=''`,
|
|
192
|
+
`case "$${v.exit}" in ${arms} esac`,
|
|
193
|
+
`${v.exitText}="$${v.exit}"`,
|
|
194
|
+
`if [ -n "$${v.signal}" ]; then ${v.exitText}="$${v.exit} ($${v.signal} - 128+$((${v.exit} - 128)))"; fi`,
|
|
195
|
+
].join('; ');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Shell fragment computing how long the container was alive.
|
|
200
|
+
*
|
|
201
|
+
* Best effort by design: `date -d` is GNU, `date -j -f` is BSD, and a host that
|
|
202
|
+
* offers neither leaves the lifetime `unknown` rather than guessing.
|
|
203
|
+
*
|
|
204
|
+
* BSD `date` cannot parse the fractional seconds docker reports, so the
|
|
205
|
+
* fallback strips them before parsing and adds them back afterwards. Truncating
|
|
206
|
+
* both timestamps to whole seconds instead would *not* cancel out: a container
|
|
207
|
+
* alive from `.942` to `.740` of the next-but-five second measures 5.798s but
|
|
208
|
+
* truncates to 6.000s, and the error can reach a full second either way.
|
|
209
|
+
*
|
|
210
|
+
* @returns {string} Shell command
|
|
211
|
+
*/
|
|
212
|
+
function buildLifetimeSnippet() {
|
|
213
|
+
const v = SHELL_VARS;
|
|
214
|
+
return [
|
|
215
|
+
'__start_command_millis() { ' +
|
|
216
|
+
'case "$1" in *.*) __start_command_frac=${1#*.};; ' +
|
|
217
|
+
"*) printf '000'; return;; esac; " +
|
|
218
|
+
'__start_command_frac=${__start_command_frac%%[!0-9]*}; ' +
|
|
219
|
+
'printf \'%.3s\' "${__start_command_frac}000"; }',
|
|
220
|
+
'__start_command_epoch_ms() { ' +
|
|
221
|
+
'__start_command_ts=$(date -u -d "$1" +%s%3N 2>/dev/null); ' +
|
|
222
|
+
'case "$__start_command_ts" in \'\'|*[!0-9]*) ' +
|
|
223
|
+
'__start_command_ts=$(date -u -j -f \'%Y-%m-%dT%H:%M:%S\' "${1%.*}" +%s 2>/dev/null) && ' +
|
|
224
|
+
'__start_command_ts="${__start_command_ts}$(__start_command_millis "$1")";; esac; ' +
|
|
225
|
+
"case \"$__start_command_ts\" in ''|*[!0-9]*) __start_command_ts='';; esac; " +
|
|
226
|
+
'printf \'%s\' "$__start_command_ts"; }',
|
|
227
|
+
`${v.lifetime}=${UNKNOWN}`,
|
|
228
|
+
`__start_command_t0=$(__start_command_epoch_ms "$${v.started}")`,
|
|
229
|
+
`__start_command_t1=$(__start_command_epoch_ms "$${v.finished}")`,
|
|
230
|
+
'if [ -n "$__start_command_t0" ] && [ -n "$__start_command_t1" ] && ' +
|
|
231
|
+
'[ "$__start_command_t1" -ge "$__start_command_t0" ] 2>/dev/null; then ' +
|
|
232
|
+
'__start_command_ms=$((__start_command_t1 - __start_command_t0)); ' +
|
|
233
|
+
`${v.lifetime}="$((__start_command_ms / 1000)).$(printf '%03d' "$((__start_command_ms % 1000))")s"; fi`,
|
|
234
|
+
].join('; ');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Shell fragment appending the post-mortem block for a kept container.
|
|
239
|
+
* @param {string} containerName - Container that was kept
|
|
240
|
+
* @param {string} quotedLogPath - Already shell-quoted log path
|
|
241
|
+
* @returns {string} Shell command
|
|
242
|
+
*/
|
|
243
|
+
function buildDockerPostMortemSnippet(containerName, quotedLogPath) {
|
|
244
|
+
const v = SHELL_VARS;
|
|
245
|
+
return (
|
|
246
|
+
`${v.errorText}="$${v.error}"; ` +
|
|
247
|
+
`if [ -z "$${v.errorText}" ]; then ${v.errorText}='${NO_ERROR}'; fi; ` +
|
|
248
|
+
`printf '\\n${POST_MORTEM_HEADER}\\nContainer: %s\\nExit Code: %s\\n` +
|
|
249
|
+
`OOMKilled: %s\\nStartedAt: %s\\nFinishedAt: %s\\nLifetime: %s\\nError: %s\\n' ` +
|
|
250
|
+
`${shellQuote(containerName)} "$${v.exitText}" "$${v.oom}" ` +
|
|
251
|
+
`"$${v.started}" "$${v.finished}" "$${v.lifetime}" "$${v.errorText}" >> ${quotedLogPath}`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Shell fragment appending the one-line note for a removed container.
|
|
257
|
+
* @param {string} containerName - Container that was removed
|
|
258
|
+
* @param {string} quotedLogPath - Already shell-quoted log path
|
|
259
|
+
* @returns {string} Shell command
|
|
260
|
+
*/
|
|
261
|
+
function buildDockerRemovalNoteSnippet(containerName, quotedLogPath) {
|
|
262
|
+
const v = SHELL_VARS;
|
|
263
|
+
return (
|
|
264
|
+
`__start_command_signal_note=''; ` +
|
|
265
|
+
`if [ -n "$${v.signal}" ]; then __start_command_signal_note=", $${v.signal}"; fi; ` +
|
|
266
|
+
`printf '\\nContainer removed: %s (exit %s%s, lifetime %s, oomKilled=%s)\\n' ` +
|
|
267
|
+
`${shellQuote(containerName)} "$${v.exit}" "$__start_command_signal_note" ` +
|
|
268
|
+
`"$${v.lifetime}" "$${v.oom}" >> ${quotedLogPath}`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = {
|
|
273
|
+
DOCKER_STATE_INSPECT_FORMAT,
|
|
274
|
+
DOCKER_ZERO_TIME,
|
|
275
|
+
NO_ERROR,
|
|
276
|
+
POST_MORTEM_HEADER,
|
|
277
|
+
SHELL_VARS,
|
|
278
|
+
UNKNOWN,
|
|
279
|
+
buildDockerPostMortemSnippet,
|
|
280
|
+
buildDockerRemovalNoteSnippet,
|
|
281
|
+
buildDockerStateSnippet,
|
|
282
|
+
buildLifetimeSnippet,
|
|
283
|
+
buildSignalDecodeSnippet,
|
|
284
|
+
describeExitCode,
|
|
285
|
+
formatContainerPostMortem,
|
|
286
|
+
formatContainerRemovalNote,
|
|
287
|
+
formatLifetime,
|
|
288
|
+
normalizeDockerTimestamp,
|
|
289
|
+
};
|
|
@@ -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
|
@@ -58,6 +58,9 @@ const EXIT_REASON_MARKERS = [
|
|
|
58
58
|
* Only the signals that actually show up in command logs are named.
|
|
59
59
|
* @type {Object<number, string>}
|
|
60
60
|
*/
|
|
61
|
+
/** Rendered when an exit code is unavailable or not numeric. */
|
|
62
|
+
const UNKNOWN_EXIT_CODE = 'unknown';
|
|
63
|
+
|
|
61
64
|
const SIGNAL_NAMES = {
|
|
62
65
|
1: 'SIGHUP',
|
|
63
66
|
2: 'SIGINT',
|
|
@@ -194,6 +197,31 @@ function signalNameForExitCode(exitCode) {
|
|
|
194
197
|
return SIGNAL_NAMES[exitCode - 128] || null;
|
|
195
198
|
}
|
|
196
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Describe a terminal exit code, decoding the `128+n` signal convention.
|
|
202
|
+
*
|
|
203
|
+
* Single source of truth for issue #171.4: the detached Docker watcher writes
|
|
204
|
+
* this text into the log at completion time (through the shell it generates),
|
|
205
|
+
* and `--status` renders the same text at query time from the stored record.
|
|
206
|
+
* Both used to decode `128+n` on their own, so they could disagree.
|
|
207
|
+
*
|
|
208
|
+
* @param {number|string|null|undefined} exitCode - Terminal exit code
|
|
209
|
+
* @returns {{code: number|null, signal: string|null, text: string}} Description
|
|
210
|
+
*/
|
|
211
|
+
function describeExitCode(exitCode) {
|
|
212
|
+
const code =
|
|
213
|
+
typeof exitCode === 'number' ? exitCode : Number.parseInt(exitCode, 10);
|
|
214
|
+
if (!Number.isFinite(code)) {
|
|
215
|
+
return { code: null, signal: null, text: UNKNOWN_EXIT_CODE };
|
|
216
|
+
}
|
|
217
|
+
const signal = signalNameForExitCode(code);
|
|
218
|
+
return {
|
|
219
|
+
code,
|
|
220
|
+
signal,
|
|
221
|
+
text: signal ? `${code} (${signal} - 128+${code - 128})` : String(code),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
197
225
|
/**
|
|
198
226
|
* Resolve the best available hint for why an execution ended.
|
|
199
227
|
*
|
|
@@ -223,12 +251,14 @@ function resolveExitReason(input) {
|
|
|
223
251
|
|
|
224
252
|
module.exports = {
|
|
225
253
|
EXIT_REASON_MARKERS,
|
|
254
|
+
UNKNOWN_EXIT_CODE,
|
|
226
255
|
MEMORY_EXHAUSTION_PREFIX,
|
|
227
256
|
SIGNAL_NAMES,
|
|
228
257
|
detectExitReason,
|
|
229
258
|
detectMemoryMarker,
|
|
230
259
|
findExitReasonMarker,
|
|
231
260
|
resolveMemoryExhaustion,
|
|
261
|
+
describeExitCode,
|
|
232
262
|
resolveExitReason,
|
|
233
263
|
signalNameForExitCode,
|
|
234
264
|
};
|
|
@@ -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 } = 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
|
|
|
@@ -260,6 +264,9 @@ function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
|
|
|
260
264
|
}
|
|
261
265
|
|
|
262
266
|
module.exports = {
|
|
267
|
+
// Re-exported so the log writers and the status formatter share one decoder
|
|
268
|
+
// for the `128 + n` signal convention (issue #171.4).
|
|
269
|
+
describeExitCode,
|
|
263
270
|
LOG_TAIL_BYTES,
|
|
264
271
|
FATAL_MARKER_TAIL_BYTES,
|
|
265
272
|
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({
|