start-command 0.34.1 → 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.
@@ -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(record, newCommand, probe = {}) {
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 plan = buildResumePlan(record, deps.command || null, probeFn(record));
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
  },
@@ -505,96 +505,12 @@ const {
505
505
  canRunLinuxDockerImages,
506
506
  } = require('./docker-utils');
507
507
 
508
- /**
509
- * Build the docker run runtime argument list contributed by configurable
510
- * container options: privileged mode, environment variables, volumes/bind
511
- * mounts, --mount specs, and network configuration. Returned in a stable order so they can be spliced
512
- * into the `docker run` argv before the image name.
513
- * @param {object} options - Options (privileged, env, volumes, mounts, network, networkAliases)
514
- * @returns {string[]} Docker CLI arguments
515
- */
516
- function buildDockerRuntimeArgs(options = {}) {
517
- const args = [];
518
- if (options.privileged) {
519
- args.push('--privileged');
520
- }
521
- for (const envVar of options.env || []) {
522
- args.push('-e', envVar);
523
- }
524
- for (const volume of options.volumes || []) {
525
- args.push('-v', volume);
526
- }
527
- for (const mount of options.mounts || []) {
528
- args.push('--mount', mount);
529
- }
530
- const [firstNetwork] = getDockerNetworks(options);
531
- if (firstNetwork) {
532
- args.push('--network', firstNetwork);
533
- }
534
- for (const alias of options.networkAliases || []) {
535
- args.push('--network-alias', alias);
536
- }
537
- return args;
538
- }
539
-
540
- /**
541
- * Build the human-readable `[Isolation]` status lines for docker runtime
542
- * options (volumes, mounts, env, privileged). Empty collections and a falsy
543
- * privileged flag contribute no lines.
544
- * @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases)
545
- * @returns {string[]} Status lines for the start block / log header
546
- */
547
- function buildDockerRuntimeStatusLines(options = {}) {
548
- const lines = [];
549
- if (options.volumes && options.volumes.length > 0) {
550
- lines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`);
551
- }
552
- if (options.mounts && options.mounts.length > 0) {
553
- lines.push(`[Isolation] Mounts: ${options.mounts.join(', ')}`);
554
- }
555
- if (options.env && options.env.length > 0) {
556
- lines.push(`[Isolation] Env: ${options.env.join(', ')}`);
557
- }
558
- if (options.privileged) {
559
- lines.push(`[Isolation] Privileged: true`);
560
- }
561
- const networks = getDockerNetworks(options);
562
- if (networks.length > 0) {
563
- lines.push(`[Isolation] Network: ${networks[0]}`);
564
- }
565
- if (networks.length > 1) {
566
- lines.push(`[Isolation] Networks: ${networks.join(', ')}`);
567
- }
568
- if (options.networkAliases && options.networkAliases.length > 0) {
569
- lines.push(
570
- `[Isolation] Network aliases: ${options.networkAliases.join(', ')}`
571
- );
572
- }
573
- return lines;
574
- }
575
-
576
- /**
577
- * Build the execution-record metadata for docker runtime options, normalizing
578
- * empty collections and a falsy privileged flag to `null`.
579
- * @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases)
580
- * @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean, network: ?string, networks: ?string[], networkAliases: ?string[]}}
581
- */
582
- function buildDockerRuntimeMetadata(options = {}) {
583
- const networks = getDockerNetworks(options);
584
- return {
585
- volumes:
586
- options.volumes && options.volumes.length > 0 ? options.volumes : null,
587
- mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
588
- env: options.env && options.env.length > 0 ? options.env : null,
589
- privileged: options.privileged || null,
590
- network: networks[0] || null,
591
- networks: networks.length > 0 ? networks : null,
592
- networkAliases:
593
- options.networkAliases && options.networkAliases.length > 0
594
- ? options.networkAliases
595
- : null,
596
- };
597
- }
508
+ const {
509
+ buildDockerRuntimeArgs,
510
+ buildDockerRuntimeStatusLines,
511
+ buildDockerRuntimeMetadata,
512
+ } = require('./docker-runtime-args');
513
+ const { buildRecoverySelectorArgs } = require('./execution-recovery');
598
514
 
599
515
  /**
600
516
  * Run command in Docker container
@@ -679,11 +595,22 @@ function runInDocker(command, options = {}) {
679
595
  const shellArgs = shellInteractiveFlag
680
596
  ? [shellToUse, shellInteractiveFlag]
681
597
  : [shellToUse];
682
- const cmdArgs = isBareShell
598
+ const mainCmdArgs = isBareShell
683
599
  ? toShellWords(command)
684
600
  : isShellInvocationWithArgs(command)
685
601
  ? buildShellWithArgsCmdArgs(effectiveCommand)
686
602
  : [...shellArgs, '-c', effectiveCommand];
603
+ // A recovery command is selected by a marker that `docker cp` drops
604
+ // into the container before it is started again (issue #176).
605
+ const cmdArgs = options.recoveryCommand
606
+ ? buildRecoverySelectorArgs(mainCmdArgs, {
607
+ shell: shellToUse,
608
+ shellFlag: shellInteractiveFlag,
609
+ recoveryCommand: options.keepAlive
610
+ ? `${options.recoveryCommand}; exec ${shellToUse}`
611
+ : options.recoveryCommand,
612
+ })
613
+ : mainCmdArgs;
687
614
  dockerArgs.push(options.image, ...cmdArgs);
688
615
 
689
616
  if (DEBUG) {
@@ -736,7 +663,8 @@ function runInDocker(command, options = {}) {
736
663
  containerName,
737
664
  cleanupPolicy,
738
665
  options.logPath,
739
- options.executionId || null
666
+ options.executionId || null,
667
+ { recoverOnKill: Boolean(options.onKillResume) }
740
668
  );
741
669
 
742
670
  let message = `Command started in detached docker container: ${containerName}`;
package/src/lib/usage.js CHANGED
@@ -16,6 +16,8 @@ Options:
16
16
  --privileged Run docker container in privileged mode (docker only)
17
17
  --network <name> Connect to a named network (repeatable, docker only)
18
18
  --network-alias <alias> Add alias to the first network (repeatable, docker only)
19
+ --on-kill-resume <N> Resume a killed (exit 137 / OOM) detached docker session up to N times
20
+ --recovery-command <cmd> Command to run in the same container on such a resume
19
21
  --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
20
22
  --isolated-user, -u [name] Create isolated user with same permissions
21
23
  --keep-user Keep isolated user after command completes
@@ -66,6 +68,7 @@ Examples:
66
68
  $ --resume my-docker-session # Restart the stored command in the same container
67
69
  $ --resume my-docker-session -- bash # Run a different command in the same container
68
70
  $ --resume-all # Re-attach every still-running execution
71
+ $ -i docker -d --on-kill-resume 3 --recovery-command 'solve --resume' -- solve
69
72
  $ --use-command-stream echo "Hello" # Use command-stream library`);
70
73
  console.log('');
71
74
  console.log('Piping with $:');
@@ -304,6 +304,9 @@ describe('buildDockerRuntimeMetadata', () => {
304
304
  network: null,
305
305
  networks: null,
306
306
  networkAliases: null,
307
+ resourceLimits: null,
308
+ onKillResume: null,
309
+ recoveryCommand: null,
307
310
  });
308
311
  });
309
312
 
@@ -326,6 +329,9 @@ describe('buildDockerRuntimeMetadata', () => {
326
329
  network: 'hive-formal-ai',
327
330
  networks: ['hive-formal-ai', 'public-egress'],
328
331
  networkAliases: ['formal-ai', 'checker'],
332
+ resourceLimits: null,
333
+ onKillResume: null,
334
+ recoveryCommand: null,
329
335
  }
330
336
  );
331
337
  });