start-command 0.34.0 → 0.35.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 +12 -0
- package/package.json +2 -2
- package/src/bin/cli.js +2 -1
- package/src/lib/args-parser.js +10 -0
- package/src/lib/command-stream.js +4 -2
- package/src/lib/detached-finalize.js +31 -7
- package/src/lib/docker-cleanup.js +55 -11
- package/src/lib/docker-post-mortem.js +93 -0
- package/src/lib/docker-recovery-options.js +84 -0
- package/src/lib/docker-resource-limits.js +211 -0
- package/src/lib/docker-runtime-args.js +141 -0
- package/src/lib/execution-control.js +39 -0
- package/src/lib/execution-recovery.js +350 -0
- package/src/lib/execution-resume.js +68 -4
- package/src/lib/exit-reason.js +24 -0
- package/src/lib/isolation-log-utils.js +9 -5
- package/src/lib/isolation.js +20 -92
- package/src/lib/spawn-helpers.js +4 -2
- package/src/lib/usage.js +3 -0
- package/test/docker-runtime-options.js +6 -0
- package/test/regression-170.js +4 -1
- package/test/regression-174.js +397 -0
- package/test/regression-176.js +897 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch-time recovery for killed detached docker sessions (issue #176).
|
|
3
|
+
*
|
|
4
|
+
* `$ --isolated docker --detached --on-kill-resume 3 --recovery-command B -- A`
|
|
5
|
+
* runs `A`; when the main process is killed (exit 137 / `OOMKilled`), the
|
|
6
|
+
* detached completion watcher hands the inspected facts to this module, which
|
|
7
|
+
* restarts the *same* container so `B` continues on the same filesystem, with
|
|
8
|
+
* the same resource limits (they live in the container's HostConfig), under
|
|
9
|
+
* the same execution UUID and appending to the same log file.
|
|
10
|
+
*
|
|
11
|
+
* How `B` replaces `A` inside the same container: a container launched with a
|
|
12
|
+
* recovery command runs a tiny selector as its entrypoint command. The
|
|
13
|
+
* selector runs `A` normally, but runs `B` when the marker file exists. Before
|
|
14
|
+
* `docker start`, the marker (holding the attempt number) is copied into the
|
|
15
|
+
* stopped container with `docker cp`. Without a recovery command the original
|
|
16
|
+
* command is simply started again.
|
|
17
|
+
*
|
|
18
|
+
* Like `detached-finalize.js`, the entry point runs as a short-lived child of
|
|
19
|
+
* the watcher on a host whose CLI invocation is long gone, so it never throws.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
getDockerCommand,
|
|
28
|
+
getDockerContainerCleanupPolicy,
|
|
29
|
+
startDetachedDockerCompletionWatcher,
|
|
30
|
+
} = require('./docker-cleanup');
|
|
31
|
+
const {
|
|
32
|
+
SHELL_VARS,
|
|
33
|
+
describeExitCode,
|
|
34
|
+
formatContainerPostMortem,
|
|
35
|
+
} = require('./docker-post-mortem');
|
|
36
|
+
const {
|
|
37
|
+
buildResourceLimitsStatusLine,
|
|
38
|
+
readDockerResourceLimits,
|
|
39
|
+
} = require('./docker-resource-limits');
|
|
40
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
41
|
+
|
|
42
|
+
/** File whose presence makes the selector run the recovery command. */
|
|
43
|
+
const RECOVERY_MARKER_PATH = '/.start-command-recovery';
|
|
44
|
+
|
|
45
|
+
/** Environment variable carrying the attempt number into the recovery command. */
|
|
46
|
+
const RECOVERY_ATTEMPT_ENV = 'START_COMMAND_RECOVERY_ATTEMPT';
|
|
47
|
+
|
|
48
|
+
/** Exit code of a process killed with SIGKILL (the OOM killer's signal). */
|
|
49
|
+
const KILLED_EXIT_CODE = 137;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* POSIX selector run as the container command when a recovery command is set:
|
|
53
|
+
* sh -c SELECTOR start-command <shell> <flag> <recovery> <main argv...>
|
|
54
|
+
*/
|
|
55
|
+
const RECOVERY_SELECTOR =
|
|
56
|
+
's=$1; f=$2; r=$3; shift 3; ' +
|
|
57
|
+
`if [ -e ${RECOVERY_MARKER_PATH} ]; then ` +
|
|
58
|
+
`${RECOVERY_ATTEMPT_ENV}=$(cat ${RECOVERY_MARKER_PATH} 2>/dev/null); ` +
|
|
59
|
+
`export ${RECOVERY_ATTEMPT_ENV}; ` +
|
|
60
|
+
'if [ -n "$f" ]; then exec "$s" "$f" -c "$r"; fi; exec "$s" -c "$r"; fi; ' +
|
|
61
|
+
'exec "$@"';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Wrap the container command so a later `docker start` can switch to the
|
|
65
|
+
* recovery command.
|
|
66
|
+
* @param {string[]} mainArgs - Container command for the main run
|
|
67
|
+
* @param {object} params - {shell, shellFlag, recoveryCommand}
|
|
68
|
+
* @returns {string[]} Container command
|
|
69
|
+
*/
|
|
70
|
+
function buildRecoverySelectorArgs(mainArgs, params) {
|
|
71
|
+
return [
|
|
72
|
+
'sh',
|
|
73
|
+
'-c',
|
|
74
|
+
RECOVERY_SELECTOR,
|
|
75
|
+
'start-command',
|
|
76
|
+
params.shell || 'sh',
|
|
77
|
+
params.shellFlag || '',
|
|
78
|
+
params.recoveryCommand,
|
|
79
|
+
...mainArgs,
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {number|string|null} exitCode - `.State.ExitCode`
|
|
85
|
+
* @param {boolean|string|null} oomKilled - `.State.OOMKilled`
|
|
86
|
+
* @returns {boolean} Whether the main process was killed
|
|
87
|
+
*/
|
|
88
|
+
function isKilledExit(exitCode, oomKilled) {
|
|
89
|
+
return (
|
|
90
|
+
describeExitCode(exitCode).code === KILLED_EXIT_CODE ||
|
|
91
|
+
oomKilled === true ||
|
|
92
|
+
oomKilled === 'true'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Shell condition run by the completion watcher: true when the container was
|
|
98
|
+
* killed and the recovery entry point resumed it.
|
|
99
|
+
* @param {string} executionId - Execution UUID
|
|
100
|
+
* @returns {string} POSIX shell condition
|
|
101
|
+
*/
|
|
102
|
+
function buildRecoverySnippet(executionId) {
|
|
103
|
+
const v = SHELL_VARS;
|
|
104
|
+
return (
|
|
105
|
+
`{ [ "$${v.exit}" = ${KILLED_EXIT_CODE} ] || [ "$${v.oom}" = true ]; } && ` +
|
|
106
|
+
`${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
|
|
107
|
+
`${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
|
|
108
|
+
`"$${v.started}" "$${v.finished}" "$${v.error}" >/dev/null 2>&1`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The `[Recovery k/N]` separator written into the session log.
|
|
114
|
+
* @param {object} params - {attempt, maxAttempts, exitCode, oomKilled, containerName, command}
|
|
115
|
+
* @returns {string} Separator line, newline terminated
|
|
116
|
+
*/
|
|
117
|
+
function formatRecoverySeparator(params) {
|
|
118
|
+
const described = describeExitCode(params.exitCode);
|
|
119
|
+
const signal = described.signal ? `, ${described.signal}` : '';
|
|
120
|
+
const what = params.command
|
|
121
|
+
? `running recovery command: ${params.command}`
|
|
122
|
+
: 'running the original command again';
|
|
123
|
+
return (
|
|
124
|
+
`\n[Recovery ${params.attempt}/${params.maxAttempts}] ` +
|
|
125
|
+
`Main process was killed (exit ${described.code}${signal}, ` +
|
|
126
|
+
`oomKilled=${params.oomKilled === true || params.oomKilled === 'true'}); ` +
|
|
127
|
+
`resuming container ${params.containerName}, ${what}\n`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function appendToLog(logPath, text) {
|
|
132
|
+
if (!logPath) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
fs.appendFileSync(logPath, text);
|
|
137
|
+
} catch {
|
|
138
|
+
// The log is best-effort here: recovery itself must still proceed.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function writeRecoveryMarker(containerName, attempt, runner) {
|
|
143
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-recovery-'));
|
|
144
|
+
const file = path.join(dir, 'marker');
|
|
145
|
+
try {
|
|
146
|
+
fs.writeFileSync(file, `${attempt}\n`, { mode: 0o644 });
|
|
147
|
+
fs.chmodSync(file, 0o644);
|
|
148
|
+
return runner(getDockerCommand(), [
|
|
149
|
+
'cp',
|
|
150
|
+
file,
|
|
151
|
+
`${containerName}:${RECOVERY_MARKER_PATH}`,
|
|
152
|
+
]);
|
|
153
|
+
} finally {
|
|
154
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function failureDetail(result) {
|
|
159
|
+
return (
|
|
160
|
+
(result.stderr || '').trim() || result.error || `exit code ${result.status}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Resume a killed execution in its own container.
|
|
166
|
+
* @param {object} params - {store, executionId, exitCode, oomKilled, startedAt,
|
|
167
|
+
* finishedAt, containerError, runner, startWatcher, now}
|
|
168
|
+
* @returns {{recovered: boolean, reason: string, attempt?: number}} Outcome
|
|
169
|
+
*/
|
|
170
|
+
function recoverKilledExecution(params = {}) {
|
|
171
|
+
const { store, executionId } = params;
|
|
172
|
+
if (!store || !executionId) {
|
|
173
|
+
return { recovered: false, reason: 'missing-arguments' };
|
|
174
|
+
}
|
|
175
|
+
const record = store.get(executionId);
|
|
176
|
+
if (!record) {
|
|
177
|
+
return { recovered: false, reason: 'record-not-found' };
|
|
178
|
+
}
|
|
179
|
+
const opts = record.options || {};
|
|
180
|
+
const maxAttempts = Number(opts.onKillResume) || 0;
|
|
181
|
+
const containerName = opts.sessionName;
|
|
182
|
+
if (opts.isolated !== 'docker' || !containerName || maxAttempts < 1) {
|
|
183
|
+
return { recovered: false, reason: 'not-configured' };
|
|
184
|
+
}
|
|
185
|
+
if (!isKilledExit(params.exitCode, params.oomKilled)) {
|
|
186
|
+
return { recovered: false, reason: 'not-killed' };
|
|
187
|
+
}
|
|
188
|
+
const log = (text) => appendToLog(record.logPath, text);
|
|
189
|
+
if (opts.stopRequestedAt) {
|
|
190
|
+
log('\n[Recovery] Not resuming: the session was stopped on request.\n');
|
|
191
|
+
return { recovered: false, reason: 'stop-requested' };
|
|
192
|
+
}
|
|
193
|
+
const used = Number(opts.recoveryAttempts) || 0;
|
|
194
|
+
if (used >= maxAttempts) {
|
|
195
|
+
log(
|
|
196
|
+
`\n[Recovery] Not resuming: all ${maxAttempts} recovery attempt(s) used.\n`
|
|
197
|
+
);
|
|
198
|
+
return { recovered: false, reason: 'attempts-exhausted' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const runner = params.runner || require('./execution-control').runCommand;
|
|
202
|
+
const now = params.now || (() => new Date());
|
|
203
|
+
const attempt = used + 1;
|
|
204
|
+
const giveUp = (reason) => {
|
|
205
|
+
log(`[Recovery ${attempt}/${maxAttempts}] Failed: ${reason}\n`);
|
|
206
|
+
return { recovered: false, reason: 'resume-failed', attempt };
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
log(
|
|
210
|
+
`\n${formatContainerPostMortem({
|
|
211
|
+
containerName,
|
|
212
|
+
exitCode: params.exitCode,
|
|
213
|
+
oomKilled: params.oomKilled,
|
|
214
|
+
startedAt: params.startedAt,
|
|
215
|
+
finishedAt: params.finishedAt,
|
|
216
|
+
error: params.containerError,
|
|
217
|
+
})}`
|
|
218
|
+
);
|
|
219
|
+
log(
|
|
220
|
+
formatRecoverySeparator({
|
|
221
|
+
attempt,
|
|
222
|
+
maxAttempts,
|
|
223
|
+
exitCode: params.exitCode,
|
|
224
|
+
oomKilled: params.oomKilled,
|
|
225
|
+
containerName,
|
|
226
|
+
command: opts.recoveryCommand || null,
|
|
227
|
+
})
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Docker keeps `docker update` limits in the HostConfig across restarts;
|
|
231
|
+
// they are read back so the log and `--status` show what the resumed run
|
|
232
|
+
// is held to, and so a later relaunch can re-apply them.
|
|
233
|
+
const resourceLimits = readDockerResourceLimits(containerName, runner);
|
|
234
|
+
const limitsLine = buildResourceLimitsStatusLine(resourceLimits);
|
|
235
|
+
if (limitsLine) {
|
|
236
|
+
log(`${limitsLine}\n`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (opts.recoveryCommand) {
|
|
240
|
+
let copied;
|
|
241
|
+
try {
|
|
242
|
+
copied = writeRecoveryMarker(containerName, attempt, runner);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
copied = { success: false, error: err.message };
|
|
245
|
+
}
|
|
246
|
+
if (!copied.success) {
|
|
247
|
+
return giveUp(`could not mark the container: ${failureDetail(copied)}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const since = now().toISOString();
|
|
252
|
+
const started = runner(getDockerCommand(), ['start', containerName]);
|
|
253
|
+
if (!started.success) {
|
|
254
|
+
return giveUp(`docker start failed: ${failureDetail(started)}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const described = describeExitCode(params.exitCode);
|
|
258
|
+
record.options = {
|
|
259
|
+
...opts,
|
|
260
|
+
recoveryAttempts: attempt,
|
|
261
|
+
recoveryHistory: [
|
|
262
|
+
...(Array.isArray(opts.recoveryHistory) ? opts.recoveryHistory : []),
|
|
263
|
+
`${attempt}: exit ${described.code}, oomKilled=${params.oomKilled === true || params.oomKilled === 'true'}, resumed at ${since}`,
|
|
264
|
+
],
|
|
265
|
+
lastRecoveryAt: since,
|
|
266
|
+
...(resourceLimits && resourceLimits.length > 0 ? { resourceLimits } : {}),
|
|
267
|
+
};
|
|
268
|
+
record.status = 'executing';
|
|
269
|
+
record.exitCode = null;
|
|
270
|
+
record.endTime = null;
|
|
271
|
+
record.exitReason = undefined;
|
|
272
|
+
record.oomKilled = undefined;
|
|
273
|
+
try {
|
|
274
|
+
store.save(record);
|
|
275
|
+
} catch {
|
|
276
|
+
// The container is already running again; the new watcher still follows
|
|
277
|
+
// it and finalizes the record when it ends.
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const startWatcher =
|
|
281
|
+
params.startWatcher || startDetachedDockerCompletionWatcher;
|
|
282
|
+
startWatcher(
|
|
283
|
+
containerName,
|
|
284
|
+
getDockerContainerCleanupPolicy(opts),
|
|
285
|
+
record.logPath || null,
|
|
286
|
+
record.uuid,
|
|
287
|
+
{ since, recoverOnKill: true }
|
|
288
|
+
);
|
|
289
|
+
return { recovered: true, reason: 'resumed', attempt };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Entry point used by the detached watcher:
|
|
294
|
+
* <runtime> execution-recovery.js <uuid> <exit> <oom> <started> <finished> <error>
|
|
295
|
+
* Exits 0 only when the container was resumed; any other outcome lets the
|
|
296
|
+
* watcher continue with its normal cleanup, footer and finalization.
|
|
297
|
+
* @param {string[]} argv - Positional arguments
|
|
298
|
+
* @returns {number} Process exit code
|
|
299
|
+
*/
|
|
300
|
+
function main(argv) {
|
|
301
|
+
const [executionId, exitCode, oomKilled, startedAt, finishedAt, error] = argv;
|
|
302
|
+
if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
|
|
303
|
+
return 1;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
const { ExecutionStore } = require('./execution-store');
|
|
307
|
+
const store = new ExecutionStore(
|
|
308
|
+
process.env.START_APP_FOLDER
|
|
309
|
+
? { appFolder: path.resolve(process.env.START_APP_FOLDER) }
|
|
310
|
+
: {}
|
|
311
|
+
);
|
|
312
|
+
const outcome = recoverKilledExecution({
|
|
313
|
+
store,
|
|
314
|
+
executionId,
|
|
315
|
+
exitCode,
|
|
316
|
+
oomKilled,
|
|
317
|
+
startedAt,
|
|
318
|
+
finishedAt,
|
|
319
|
+
containerError: error,
|
|
320
|
+
});
|
|
321
|
+
return outcome.recovered ? 0 : 1;
|
|
322
|
+
} catch (err) {
|
|
323
|
+
// The watcher discards this output; START_DEBUG=1 shows it when the
|
|
324
|
+
// entry point is run by hand.
|
|
325
|
+
if (process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true') {
|
|
326
|
+
console.error(`[DEBUG] execution-recovery failed: ${err.stack || err}`);
|
|
327
|
+
}
|
|
328
|
+
return 1;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = {
|
|
333
|
+
KILLED_EXIT_CODE,
|
|
334
|
+
RECOVERY_ATTEMPT_ENV,
|
|
335
|
+
RECOVERY_MARKER_PATH,
|
|
336
|
+
RECOVERY_SELECTOR,
|
|
337
|
+
buildRecoverySelectorArgs,
|
|
338
|
+
buildRecoverySnippet,
|
|
339
|
+
formatRecoverySeparator,
|
|
340
|
+
isKilledExit,
|
|
341
|
+
main,
|
|
342
|
+
recoverKilledExecution,
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// After `module.exports`: run as the watcher's entry point, recovery starts the
|
|
346
|
+
// next watcher through `docker-cleanup.js`, which requires this module back
|
|
347
|
+
// and needs its exports complete.
|
|
348
|
+
if (require.main === module) {
|
|
349
|
+
process.exitCode = main(process.argv.slice(2));
|
|
350
|
+
}
|
|
@@ -25,6 +25,12 @@ const {
|
|
|
25
25
|
startDetachedDockerCompletionWatcher,
|
|
26
26
|
} = require('./docker-cleanup');
|
|
27
27
|
const { escapeForLinksNotation } = require('./output-blocks');
|
|
28
|
+
const {
|
|
29
|
+
buildResourceLimitsStatusLine,
|
|
30
|
+
normalizeResourceLimits,
|
|
31
|
+
readDockerResourceLimits,
|
|
32
|
+
} = require('./docker-resource-limits');
|
|
33
|
+
const { appendLogFile } = require('./isolation-log-utils');
|
|
28
34
|
const { runCommand } = require('./execution-control');
|
|
29
35
|
const { SessionState, probeSession } = require('./session-probe');
|
|
30
36
|
const { getDockerNetworks } = require('./docker-network-lifecycle');
|
|
@@ -95,6 +101,8 @@ function buildLaunchOptions(record) {
|
|
|
95
101
|
mounts: opts.mounts || [],
|
|
96
102
|
networks: opts.networks || [],
|
|
97
103
|
networkAliases: opts.networkAliases || [],
|
|
104
|
+
// Limits captured from the container on an earlier resume (issue #176).
|
|
105
|
+
resourceLimits: normalizeResourceLimits(opts.resourceLimits),
|
|
98
106
|
// Append to the same log so one logical session keeps one gap-free record.
|
|
99
107
|
logPath: record.logPath || null,
|
|
100
108
|
};
|
|
@@ -152,9 +160,16 @@ function buildSnapshotStartSteps({
|
|
|
152
160
|
* @param {object} record - Execution record
|
|
153
161
|
* @param {?string} newCommand - Replacement command (from `-- <command>`)
|
|
154
162
|
* @param {object} probe - Result of `probeSession`
|
|
163
|
+
* @param {?string[]} [liveResourceLimits] - Limits read from the stopped
|
|
164
|
+
* container with `docker inspect`; falls back to the stored ones when null
|
|
155
165
|
* @returns {object} Resume plan, or {error}
|
|
156
166
|
*/
|
|
157
|
-
function buildResumePlan(
|
|
167
|
+
function buildResumePlan(
|
|
168
|
+
record,
|
|
169
|
+
newCommand,
|
|
170
|
+
probe = {},
|
|
171
|
+
liveResourceLimits = null
|
|
172
|
+
) {
|
|
158
173
|
const opts = (record && record.options) || {};
|
|
159
174
|
const backend = opts.isolated;
|
|
160
175
|
const sessionName = opts.sessionName;
|
|
@@ -208,6 +223,11 @@ function buildResumePlan(record, newCommand, probe = {}) {
|
|
|
208
223
|
// Lazily required: isolation.js pulls in this module's siblings, so a
|
|
209
224
|
// top-level require would create a cycle.
|
|
210
225
|
const { buildDockerRuntimeArgs } = require('./isolation');
|
|
226
|
+
// `docker commit` keeps the filesystem but not the HostConfig, so the
|
|
227
|
+
// limits must be passed to the new container explicitly (issue #176).
|
|
228
|
+
const resourceLimits = normalizeResourceLimits(
|
|
229
|
+
liveResourceLimits || opts.resourceLimits
|
|
230
|
+
);
|
|
211
231
|
const snapshotImage = buildSnapshotImageName(sessionName, attempt);
|
|
212
232
|
const newSessionName = buildResumedSessionName(sessionName, attempt);
|
|
213
233
|
return {
|
|
@@ -218,6 +238,7 @@ function buildResumePlan(record, newCommand, probe = {}) {
|
|
|
218
238
|
snapshotImage,
|
|
219
239
|
command,
|
|
220
240
|
attempt,
|
|
241
|
+
resourceLimits,
|
|
221
242
|
steps: [
|
|
222
243
|
{
|
|
223
244
|
command: getDockerCommand(),
|
|
@@ -229,7 +250,7 @@ function buildResumePlan(record, newCommand, probe = {}) {
|
|
|
229
250
|
'--name',
|
|
230
251
|
newSessionName,
|
|
231
252
|
...(opts.user ? ['--user', opts.user] : []),
|
|
232
|
-
...buildDockerRuntimeArgs(opts),
|
|
253
|
+
...buildDockerRuntimeArgs({ ...opts, resourceLimits }),
|
|
233
254
|
snapshotImage,
|
|
234
255
|
'sh',
|
|
235
256
|
'-c',
|
|
@@ -280,6 +301,11 @@ function formatResumeResultAsLinksNotation(result) {
|
|
|
280
301
|
` snapshotImage ${escapeForLinksNotation(result.snapshotImage)}`
|
|
281
302
|
);
|
|
282
303
|
}
|
|
304
|
+
if (result.resourceLimits && result.resourceLimits.length > 0) {
|
|
305
|
+
lines.push(
|
|
306
|
+
` resourceLimits ${escapeForLinksNotation(result.resourceLimits.join(' '))}`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
283
309
|
lines.push(` command ${escapeForLinksNotation(result.command)}`);
|
|
284
310
|
lines.push(` message ${escapeForLinksNotation(result.message)}`);
|
|
285
311
|
return lines.join('\n');
|
|
@@ -296,6 +322,9 @@ function formatResumeResult(result, outputFormat) {
|
|
|
296
322
|
`Backend: ${result.backend}`,
|
|
297
323
|
`Session Name: ${result.sessionName}`,
|
|
298
324
|
`Command: ${result.command}`,
|
|
325
|
+
...(result.resourceLimits && result.resourceLimits.length > 0
|
|
326
|
+
? [`Resource Limits: ${result.resourceLimits.join(' ')}`]
|
|
327
|
+
: []),
|
|
299
328
|
result.message,
|
|
300
329
|
].join('\n');
|
|
301
330
|
}
|
|
@@ -314,6 +343,11 @@ function applyResumeToRecord(record, plan, containerId) {
|
|
|
314
343
|
const options = { ...(record.options || {}) };
|
|
315
344
|
options.resumeCount = plan.attempt;
|
|
316
345
|
options.resumedAt = new Date().toISOString();
|
|
346
|
+
// A resume is a new deliberate start: launch-time recovery applies again.
|
|
347
|
+
delete options.stopRequestedAt;
|
|
348
|
+
if (plan.resourceLimits && plan.resourceLimits.length > 0) {
|
|
349
|
+
options.resourceLimits = plan.resourceLimits;
|
|
350
|
+
}
|
|
317
351
|
|
|
318
352
|
if (plan.newSessionName) {
|
|
319
353
|
options.sessionNameHistory = [
|
|
@@ -365,7 +399,25 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
365
399
|
|
|
366
400
|
const runner = deps.runner || runCommand;
|
|
367
401
|
const probeFn = deps.probe || ((r) => probeSession(r, runner));
|
|
368
|
-
const
|
|
402
|
+
const probe = probeFn(record);
|
|
403
|
+
const opts = record.options || {};
|
|
404
|
+
// Only a snapshot resume creates a new container, so only it needs the old
|
|
405
|
+
// container's limits (issue #176).
|
|
406
|
+
const liveResourceLimits =
|
|
407
|
+
deps.command &&
|
|
408
|
+
opts.isolated === 'docker' &&
|
|
409
|
+
opts.sessionName &&
|
|
410
|
+
probe &&
|
|
411
|
+
!probe.alive &&
|
|
412
|
+
probe.state === SessionState.STOPPED
|
|
413
|
+
? readDockerResourceLimits(opts.sessionName, runner)
|
|
414
|
+
: null;
|
|
415
|
+
const plan = buildResumePlan(
|
|
416
|
+
record,
|
|
417
|
+
deps.command || null,
|
|
418
|
+
probe,
|
|
419
|
+
liveResourceLimits
|
|
420
|
+
);
|
|
369
421
|
if (plan.error) {
|
|
370
422
|
return { success: false, error: plan.error };
|
|
371
423
|
}
|
|
@@ -404,6 +456,11 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
404
456
|
containerId = (result.stdout || '').trim() || containerId;
|
|
405
457
|
}
|
|
406
458
|
|
|
459
|
+
const limitsLine = buildResourceLimitsStatusLine(plan.resourceLimits);
|
|
460
|
+
if (limitsLine && record.logPath) {
|
|
461
|
+
appendLogFile(record.logPath, `${limitsLine}\n`);
|
|
462
|
+
}
|
|
463
|
+
|
|
407
464
|
// The completion watcher died with the previous run (or with the
|
|
408
465
|
// supervisor), so a new one must follow the resumed container.
|
|
409
466
|
const startWatcher =
|
|
@@ -412,7 +469,13 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
412
469
|
activeSessionName(plan),
|
|
413
470
|
getDockerContainerCleanupPolicy(record.options || {}),
|
|
414
471
|
record.logPath || null,
|
|
415
|
-
record.uuid || null
|
|
472
|
+
record.uuid || null,
|
|
473
|
+
// `docker start` re-runs the launch-time selector, so the session keeps
|
|
474
|
+
// its kill recovery (issue #176); a snapshot container has no selector.
|
|
475
|
+
{
|
|
476
|
+
recoverOnKill:
|
|
477
|
+
plan.mode === ResumeMode.DOCKER_START && Boolean(opts.onKillResume),
|
|
478
|
+
}
|
|
416
479
|
);
|
|
417
480
|
}
|
|
418
481
|
|
|
@@ -431,6 +494,7 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
431
494
|
sessionName: updated.options.sessionName,
|
|
432
495
|
previousSessionName,
|
|
433
496
|
snapshotImage: plan.snapshotImage || null,
|
|
497
|
+
resourceLimits: plan.resourceLimits || null,
|
|
434
498
|
command: plan.command,
|
|
435
499
|
message: plan.message,
|
|
436
500
|
},
|
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
|
|