start-command 0.37.8 → 0.37.9

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # start-command
2
2
 
3
+ ## 0.37.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 55ed66d: A Docker snapshot resume can now be supervised safely. `--snapshot-wait <seconds>` (or `START_SNAPSHOT_LOCK_WAIT`) waits for another resume's snapshot on the host and says once which container it is waiting for. `--snapshot-disk-wait <seconds>` (or `START_SNAPSHOT_DISK_WAIT`) re-checks free disk instead of refusing at once. A busy lock and disk that is still short now exit 75 (`EX_TEMPFAIL`), so a caller can tell "retry later" from a real failure. `--remove-original-after <seconds>` removes the original only if the replacement is still running after the delay. `--snapshot-size-hint <bytes>` skips the `docker inspect --size` walk, and `--snapshot-prepare <command>` runs a cleanup command in the started original before the commit. A long `docker commit` writes its elapsed time to the log every minute. The `start-command.snapshot-image` label is documented as a stable interface, and `$ --cleanup <uuid-or-session-name>` removes an execution's containers and then their snapshot images, never forcing a removal (#224).
8
+
9
+ `$ --status` now reports `containerState removed` and `containerRemovedObservedAt` when the docker container of a finished detached execution no longer exists, instead of showing the old `containerId` as if the container still held its disk. The removal is recorded when start's own cleanup policy removes the container and when `--status` finds it gone; `containerId` is kept for history, and a docker daemon that cannot be reached is not mistaken for a removed container (#225).
10
+
11
+ `$ --status` no longer prints `[object Object]` for nested option values such as `launchOwner`, `exitEvidence`, `resolvedLimits` and `resourceLimitSpecs`: the links-notation output nests them like other objects and the text output prints them as compact JSON (#226).
12
+
3
13
  ## 0.37.8
4
14
 
5
15
  ### Patch Changes
package/README.md CHANGED
@@ -40,6 +40,11 @@ checked examples.
40
40
  containers support copy-free command handoff; legacy snapshots require capacity
41
41
  preflight. Log uploads are private and sanitized; manual `--no-sanitize` opts out.
42
42
 
43
+ Snapshot resumes take `--snapshot-wait`, `--snapshot-disk-wait`,
44
+ `--remove-original-after`, `--snapshot-size-hint` and `--snapshot-prepare`, and
45
+ exit 75 when they should be retried later; `--cleanup <id>` removes one
46
+ execution's containers and snapshot images without forcing.
47
+
43
48
  ## Development
44
49
 
45
50
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.37.8",
3
+ "version": "0.37.9",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
package/src/bin/cli.js CHANGED
@@ -549,6 +549,10 @@ async function runWithIsolation(
549
549
  if (mode !== 'detached') {
550
550
  executionRecord.complete(exitCode);
551
551
  }
552
+ if (result.containerRemoved) {
553
+ // The cleanup policy removed the container (issue #225).
554
+ require('../lib/container-removal').markContainerRemoved(executionRecord);
555
+ }
552
556
  try {
553
557
  store.save(
554
558
  mode === 'detached'
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * Query modes: --status, --list [--running]
9
9
  * Control modes: --upload-log, --stop, --terminate, --attach [--read-only],
10
- * --resume [-- <command>], --resume-all, --cleanup
10
+ * --resume [-- <command>], --resume-all,
11
+ * --cleanup [<uuid-or-session-name>]
11
12
  */
12
13
 
13
14
  /**
@@ -37,6 +38,7 @@ function createQueryOptionDefaults() {
37
38
  resumeAll: false, // Re-attach/resume every execution still marked running
38
39
  cleanup: false, // Clean up stale "executing" records
39
40
  cleanupDryRun: false, // Show what would be cleaned without cleaning
41
+ cleanupTarget: null, // UUID/session name whose containers and images --cleanup removes
40
42
  };
41
43
  }
42
44
 
@@ -75,15 +77,45 @@ const FLAG_OPTIONS = {
75
77
  '--resume-all': (options) => {
76
78
  options.resumeAll = true;
77
79
  },
78
- '--cleanup': (options) => {
79
- options.cleanup = true;
80
- },
81
- '--cleanup-dry-run': (options) => {
82
- options.cleanup = true;
83
- options.cleanupDryRun = true;
84
- },
85
80
  };
86
81
 
82
+ /**
83
+ * `--cleanup` and `--cleanup-dry-run` take an optional execution: without one
84
+ * they clean up stale records, with one they remove that execution's Docker
85
+ * containers and snapshot images (issue #224).
86
+ */
87
+ const CLEANUP_OPTIONS = {
88
+ '--cleanup': false,
89
+ '--cleanup-dry-run': true,
90
+ };
91
+
92
+ function parseCleanupOption(args, index, options) {
93
+ const arg = args[index];
94
+ const equals = arg.indexOf('=');
95
+ const name = equals === -1 ? arg : arg.slice(0, equals);
96
+ if (!(name in CLEANUP_OPTIONS)) {
97
+ return 0;
98
+ }
99
+ options.cleanup = true;
100
+ options.cleanupDryRun = options.cleanupDryRun || CLEANUP_OPTIONS[name];
101
+ if (equals !== -1) {
102
+ const value = arg.slice(equals + 1);
103
+ if (!value) {
104
+ throw new Error(
105
+ `Option ${name} requires a UUID or session name argument`
106
+ );
107
+ }
108
+ options.cleanupTarget = value;
109
+ return 1;
110
+ }
111
+ const next = args[index + 1];
112
+ if (next !== undefined && !next.startsWith('-')) {
113
+ options.cleanupTarget = next;
114
+ return 2;
115
+ }
116
+ return 1;
117
+ }
118
+
87
119
  function hasValue(value) {
88
120
  return value !== null && value !== undefined;
89
121
  }
@@ -98,6 +130,11 @@ function hasValue(value) {
98
130
  function parseQueryOption(args, index, options) {
99
131
  const arg = args[index];
100
132
 
133
+ const cleanupConsumed = parseCleanupOption(args, index, options);
134
+ if (cleanupConsumed > 0) {
135
+ return cleanupConsumed;
136
+ }
137
+
101
138
  const flag = FLAG_OPTIONS[arg];
102
139
  if (flag) {
103
140
  flag(options);
@@ -37,7 +37,7 @@
37
37
  * --read-only Follow --attach output without forwarding stdin
38
38
  * --resume <uuid-or-session> [-- cmd] Resume a stored execution (optionally with a new command)
39
39
  * --resume-all Re-attach or reconcile every execution still marked running
40
- * --cleanup Clean up stale "executing" records (processes that crashed or were killed)
40
+ * --cleanup [<uuid-or-session>] Clean up stale "executing" records, or one execution's containers and snapshot images
41
41
  * --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
42
42
  */
43
43
 
@@ -46,6 +46,7 @@ const dockerNetworkOptions = require('./docker-network-options');
46
46
  const dockerRecoveryOptions = require('./docker-recovery-options');
47
47
  const cpuPenalty = require('./cpu-penalty');
48
48
  const dockerResourceOptions = require('./docker-resource-options');
49
+ const snapshotResumeOptions = require('./snapshot-resume-options');
49
50
  const { parseSequence, isSequence } = require('./sequence-parser');
50
51
  const { buildCommandString } = require('./shell-utils');
51
52
  const {
@@ -215,6 +216,7 @@ function parseArgs(args) {
215
216
  shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
216
217
  useCommandStream: false, // Use command-stream library for command execution
217
218
  ...createQueryOptionDefaults(),
219
+ ...snapshotResumeOptions.createSnapshotOptionDefaults(),
218
220
  };
219
221
 
220
222
  let commandArgs = [];
@@ -566,6 +568,12 @@ function parseOption(args, index, options) {
566
568
  return 1;
567
569
  }
568
570
 
571
+ // Snapshot resume options (--snapshot-wait, --remove-original-after, ...)
572
+ const snapshotConsumed = snapshotResumeOptions.parse(args, index, options);
573
+ if (snapshotConsumed > 0) {
574
+ return snapshotConsumed;
575
+ }
576
+
569
577
  // Query/control options (--status, --list, --attach, --resume, ...)
570
578
  const queryConsumed = parseQueryOption(args, index, options);
571
579
  if (queryConsumed > 0) {
@@ -820,6 +828,7 @@ function validateOptions(options) {
820
828
  throw new Error('--keep-user option is only valid with --isolated-user');
821
829
  }
822
830
 
831
+ snapshotResumeOptions.validate(options);
823
832
  validateQueryOptions(options);
824
833
 
825
834
  // Validate shell option
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Removed-container bookkeeping for `--status` (issue #225).
3
+ *
4
+ * A kept docker container can be removed later (by hand, by an orchestrator,
5
+ * or by start-command's own cleanup policy). `docker inspect` then exits 1
6
+ * with `No such object` / `No such container`, which used to be folded into
7
+ * the same `null` as "docker could not be queried at all", so `--status` kept
8
+ * reporting the old `containerId` as if the container still held its disk.
9
+ *
10
+ * This module tells the two failures apart and records the removal on the
11
+ * execution record as `containerState: removed` plus
12
+ * `containerRemovedObservedAt`. `containerId` is kept for history.
13
+ */
14
+
15
+ const { spawnSync } = require('child_process');
16
+
17
+ /** `containerState` of a record whose container no longer exists. */
18
+ const CONTAINER_STATE_REMOVED = 'removed';
19
+
20
+ /**
21
+ * Docker's answer for a container that does not exist:
22
+ * `Error: No such object: <name>` (`docker inspect`, Docker 29 prints it
23
+ * lower-case) and `Error response from daemon: No such container: <name>`
24
+ * (`docker container inspect`). A daemon that cannot be reached prints
25
+ * "Cannot connect" / "failed to connect", which must not match.
26
+ */
27
+ const DOCKER_NOT_FOUND_PATTERN = /no such (object|container)/i;
28
+
29
+ /**
30
+ * Environment variable the detached watcher sets for the finalizer when its
31
+ * own `docker rm -f` removed the container.
32
+ */
33
+ const CONTAINER_REMOVED_ENV = 'START_INTERNAL_CONTAINER_REMOVED';
34
+
35
+ /**
36
+ * @param {{status?: number|null, stderr?: string|Buffer, error?: Error}} result
37
+ * `spawnSync` result of a `docker inspect`
38
+ * @returns {boolean} True only when docker answered that the object is unknown
39
+ */
40
+ function isDockerNotFound(result) {
41
+ if (!result || result.error || result.status === 0) {
42
+ return false;
43
+ }
44
+ return DOCKER_NOT_FOUND_PATTERN.test(String(result.stderr || ''));
45
+ }
46
+
47
+ /**
48
+ * Run `docker inspect -f <format> <target>`.
49
+ * @returns {{stdout: string|null, notFound: boolean}} `stdout` is null when
50
+ * the inspect failed; `notFound` says the failure was "no such container"
51
+ */
52
+ function dockerInspect(dockerCommand, spawnOptions, format, target) {
53
+ const result = spawnSync(
54
+ dockerCommand,
55
+ ['inspect', '-f', format, target],
56
+ spawnOptions
57
+ );
58
+ if (result.error || result.status !== 0 || !result.stdout) {
59
+ return { stdout: null, notFound: isDockerNotFound(result) };
60
+ }
61
+ return { stdout: String(result.stdout), notFound: false };
62
+ }
63
+
64
+ /**
65
+ * Set `containerState: removed` on a record, keeping an earlier observation
66
+ * time when there is one.
67
+ * @param {Object} record - Execution record (mutated)
68
+ * @param {string} [observedAt] - When the removal was observed
69
+ * @returns {Object} The same record
70
+ */
71
+ function markContainerRemoved(record, observedAt = new Date().toISOString()) {
72
+ record.containerState = CONTAINER_STATE_REMOVED;
73
+ record.containerRemovedObservedAt =
74
+ record.containerRemovedObservedAt || observedAt;
75
+ return record;
76
+ }
77
+
78
+ /**
79
+ * Whether a finished detached docker record should be checked for removal.
80
+ * Executing records are never touched: a container that is not visible yet
81
+ * on a slow Docker-in-Docker host is not a removed one (issue #136).
82
+ */
83
+ function shouldProbeRemoval(record) {
84
+ const opts = (record && record.options) || {};
85
+ return (
86
+ record.status === 'executed' &&
87
+ opts.isolated === 'docker' &&
88
+ opts.isolationMode === 'detached' &&
89
+ Boolean(opts.sessionName || opts.containerId) &&
90
+ record.containerState !== CONTAINER_STATE_REMOVED
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Attach `containerState: removed` to a finished detached docker record whose
96
+ * container no longer exists. The container id is preferred over the session
97
+ * name, so a later container that reuses the name is not mistaken for it.
98
+ *
99
+ * @param {Object} record - Enriched execution record
100
+ * @param {{dockerCommand: string, spawnOptions: Object,
101
+ * sessionNotFound?: boolean}} probe - How to run docker, and whether the
102
+ * status probe by session name already answered "no such container"
103
+ * @returns {Object} The record, or a copy carrying the removal
104
+ */
105
+ function attachContainerRemoval(record, probe) {
106
+ if (!shouldProbeRemoval(record)) {
107
+ return record;
108
+ }
109
+ const containerId = record.options.containerId;
110
+ const notFound = containerId
111
+ ? dockerInspect(
112
+ probe.dockerCommand,
113
+ probe.spawnOptions,
114
+ '{{.Id}}',
115
+ containerId
116
+ ).notFound
117
+ : probe.sessionNotFound === true;
118
+ if (!notFound) {
119
+ return record;
120
+ }
121
+ const copy = Object.assign(
122
+ Object.create(Object.getPrototypeOf(record)),
123
+ record
124
+ );
125
+ return markContainerRemoved(copy);
126
+ }
127
+
128
+ /**
129
+ * Persist a removal `--status` observed, best effort: a read-only query must
130
+ * never fail because the store could not be written.
131
+ * @param {Object} store - ExecutionStore
132
+ * @param {Object} stored - Record as read from the store
133
+ * @param {Object} enriched - Record as reported
134
+ */
135
+ function persistContainerRemoval(store, stored, enriched) {
136
+ if (
137
+ !store ||
138
+ enriched.containerState !== CONTAINER_STATE_REMOVED ||
139
+ stored.containerState === CONTAINER_STATE_REMOVED ||
140
+ typeof store.markContainerRemoved !== 'function'
141
+ ) {
142
+ return;
143
+ }
144
+ try {
145
+ store.markContainerRemoved(
146
+ stored.uuid,
147
+ enriched.containerRemovedObservedAt
148
+ );
149
+ } catch {
150
+ // The removal is still reported; it is simply observed again next time.
151
+ }
152
+ }
153
+
154
+ module.exports = {
155
+ CONTAINER_REMOVED_ENV,
156
+ CONTAINER_STATE_REMOVED,
157
+ DOCKER_NOT_FOUND_PATTERN,
158
+ attachContainerRemoval,
159
+ dockerInspect,
160
+ isDockerNotFound,
161
+ markContainerRemoved,
162
+ persistContainerRemoval,
163
+ };
@@ -63,6 +63,8 @@ const WATCHER_LOST_CONTAINER = 'watcher-lost-container';
63
63
  * @param {boolean|string|null} [options.running] - `.State.Running`
64
64
  * @param {string|null} [options.cgroupMemory] - Last cgroup v2 sample of the
65
65
  * watcher, `<memory.max> <memory.peak> <oom> <oom_kill>` (issue #182)
66
+ * @param {boolean|string|null} [options.containerRemoved] - The watcher's own
67
+ * `docker rm -f` removed the container (issue #225)
66
68
  * @returns {{updated: boolean, reason?: string, record?: Object}} Outcome
67
69
  */
68
70
  function finalizeDetachedExecution(options = {}) {
@@ -96,9 +98,23 @@ function finalizeDetachedExecution(options = {}) {
96
98
  ) {
97
99
  return { updated: false, reason: 'stale-attempt' };
98
100
  }
101
+ const containerRemoved = normalizeBoolean(options.containerRemoved) === true;
99
102
  if (record.status === 'executed' && record.endTime) {
100
103
  // Already finalized (e.g. a `--status` query got there first, or the
101
- // watcher ran twice after a resume). Nothing to correct.
104
+ // watcher ran twice after a resume). Only the removal can still be new.
105
+ if (containerRemoved && record.containerState !== 'removed') {
106
+ try {
107
+ const marked = store.markContainerRemoved(
108
+ record.uuid,
109
+ new Date().toISOString()
110
+ );
111
+ if (marked) {
112
+ return { updated: true, reason: 'container-removed', record: marked };
113
+ }
114
+ } catch {
115
+ // Bookkeeping only: see the exit-0 contract of `main`.
116
+ }
117
+ }
102
118
  return { updated: false, reason: 'already-final', record };
103
119
  }
104
120
 
@@ -171,6 +187,11 @@ function finalizeDetachedExecution(options = {}) {
171
187
  if (containerError) {
172
188
  record.options = { ...(record.options || {}), containerError };
173
189
  }
190
+ if (containerRemoved) {
191
+ // The cleanup policy removed the container: say so, and keep its id as
192
+ // history (issue #225).
193
+ require('./container-removal').markContainerRemoved(record);
194
+ }
174
195
 
175
196
  const {
176
197
  appendLifecycle,
@@ -291,7 +312,9 @@ function reconcileFinalizedRecord(store, record) {
291
312
  */
292
313
  function buildDetachedFinalizeSnippet(executionId, attemptNumber = null) {
293
314
  const v = SHELL_VARS;
315
+ const { CONTAINER_REMOVED_ENV } = require('./container-removal');
294
316
  return (
317
+ `${CONTAINER_REMOVED_ENV}="$${v.removed}" ` +
295
318
  `${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
296
319
  `${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
297
320
  `"$${v.started}" "$${v.finished}" "$${v.error}" "$${v.running}" ` +
@@ -301,7 +324,9 @@ function buildDetachedFinalizeSnippet(executionId, attemptNumber = null) {
301
324
 
302
325
  /**
303
326
  * Entry point used by the detached watcher:
304
- * <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error> <running> <cgroup>
327
+ * <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error> <running> <cgroup> [attempt]
328
+ * with `START_INTERNAL_CONTAINER_REMOVED=true` when the watcher removed the
329
+ * container (issue #225).
305
330
  * Always exits 0 — a bookkeeping failure must never turn into a visible error
306
331
  * in a log the user is reading for the command's own output.
307
332
  * @param {string[]} argv - Positional arguments
@@ -340,6 +365,8 @@ function main(argv) {
340
365
  running,
341
366
  cgroupMemory,
342
367
  attemptNumber: attemptNumber || null,
368
+ containerRemoved:
369
+ process.env[require('./container-removal').CONTAINER_REMOVED_ENV],
343
370
  });
344
371
  } catch {
345
372
  // Deliberately silent: see the exit-0 contract above.
@@ -381,7 +381,10 @@ function buildDetachedDockerCompletionScript(
381
381
  }
382
382
  // Everything that assumes the container has exited: cleanup, footer and
383
383
  // finalization. Guarded as a whole by `.State.Running` below.
384
- const exited = [];
384
+ // `removed` tells the finalizer whether the cleanup policy removed the
385
+ // container (issue #225).
386
+ const removed = SHELL_VARS.removed;
387
+ const exited = [`${removed}=false`];
385
388
 
386
389
  if (logPath) {
387
390
  const quotedLogPath = shellQuote(logPath);
@@ -394,7 +397,7 @@ function buildDetachedDockerCompletionScript(
394
397
  quotedLogPath
395
398
  );
396
399
  const memory = buildCgroupMemoryLogSnippet(quotedLogPath);
397
- const remove = `${imageCleanup.capture}; if docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1; then ${imageCleanup.remove}; fi; ${removalNote}; ${memory}`;
400
+ const remove = `${imageCleanup.capture}; if docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1; then ${removed}=true; ${imageCleanup.remove}; fi; ${removalNote}; ${memory}`;
398
401
  const keep = `${postMortem}; ${memory}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
399
402
 
400
403
  const since = watcherOptions.since
@@ -444,14 +447,14 @@ function buildDetachedDockerCompletionScript(
444
447
  parts.push(buildDockerStateSnippet(containerName));
445
448
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
446
449
  exited.push(
447
- `${imageCleanup.capture}; if docker rm -f ${quotedName} >/dev/null 2>&1; then ${imageCleanup.remove}; fi`
450
+ `${imageCleanup.capture}; if docker rm -f ${quotedName} >/dev/null 2>&1; then ${removed}=true; ${imageCleanup.remove}; fi`
448
451
  );
449
452
  } else if (
450
453
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
451
454
  policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
452
455
  ) {
453
456
  exited.push(
454
- `if ${buildSuccessfulNonOomCondition()}; then ${imageCleanup.capture}; if docker rm -f ${quotedName} >/dev/null 2>&1; then ${imageCleanup.remove}; fi; fi`
457
+ `if ${buildSuccessfulNonOomCondition()}; then ${imageCleanup.capture}; if docker rm -f ${quotedName} >/dev/null 2>&1; then ${removed}=true; ${imageCleanup.remove}; fi; fi`
455
458
  );
456
459
  }
457
460
  }
@@ -139,6 +139,8 @@ const SHELL_VARS = {
139
139
  exitText: '__start_command_exit_text',
140
140
  lifetime: '__start_command_lifetime',
141
141
  running: '__start_command_running',
142
+ /** `true` once the watcher's own `docker rm -f` succeeded (issue #225). */
143
+ removed: '__start_command_removed',
142
144
  };
143
145
 
144
146
  /** `docker inspect -f` template collecting every post-mortem fact at once. */
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Steps around the `docker commit` of a snapshot resume (issue #224):
3
+ *
4
+ * - `--snapshot-prepare <command>` runs in the stopped original before the
5
+ * commit: `docker start`, `docker exec ... sh -c <command>`, and always a
6
+ * `docker kill` afterwards. Starting the original runs its own command
7
+ * again, so this is only for containers whose start is known to be safe.
8
+ * - Progress lines while `docker commit` runs, which prints nothing itself.
9
+ * - `--remove-original-after <seconds>`: remove the stopped original only if
10
+ * the replacement is still running after the delay.
11
+ */
12
+ const { spawn } = require('child_process');
13
+ const { setInterval, clearInterval, setTimeout } = globalThis;
14
+ const { appendLogFile } = require('./isolation-log-utils');
15
+ const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
16
+
17
+ const DEFAULT_PROGRESS_INTERVAL_SECONDS = 60;
18
+ /** Upper bound for one `--snapshot-prepare`; the original is killed after. */
19
+ const DEFAULT_PREPARE_TIMEOUT_SECONDS = 600;
20
+
21
+ /**
22
+ * Format a duration the way the progress lines print it: "45s", "12 min".
23
+ * @param {number} seconds - Elapsed seconds
24
+ * @returns {string} Human-readable duration
25
+ */
26
+ function formatElapsed(seconds) {
27
+ const whole = Math.max(0, Math.floor(seconds));
28
+ if (whole < 60) {
29
+ return `${whole}s`;
30
+ }
31
+ const minutes = Math.floor(whole / 60);
32
+ const rest = whole % 60;
33
+ return rest ? `${minutes} min ${rest}s` : `${minutes} min`;
34
+ }
35
+
36
+ function positiveSeconds(value, fallback) {
37
+ const seconds = Number(value);
38
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback;
39
+ }
40
+
41
+ function progressIntervalSeconds(env = process.env) {
42
+ return positiveSeconds(
43
+ env.START_SNAPSHOT_PROGRESS_INTERVAL,
44
+ DEFAULT_PROGRESS_INTERVAL_SECONDS
45
+ );
46
+ }
47
+
48
+ /** Report one line to the execution log and through `notify`. */
49
+ function report(context, line) {
50
+ if (context.logPath) {
51
+ appendLogFile(context.logPath, `${line}\n`);
52
+ }
53
+ context.notify(line);
54
+ }
55
+
56
+ /**
57
+ * Run a command without blocking the event loop.
58
+ * @returns {Promise<{success, stdout, stderr, status, error}>}
59
+ */
60
+ function runCommandAsync(command, args) {
61
+ return new Promise((resolve) => {
62
+ const options = { stdio: ['ignore', 'pipe', 'pipe'] };
63
+ const child = spawn(
64
+ command,
65
+ args,
66
+ command === getDockerCommand() ? getDockerSpawnOptions(options) : options
67
+ );
68
+ let stdout = '';
69
+ let stderr = '';
70
+ child.stdout.on('data', (chunk) => {
71
+ stdout += chunk;
72
+ });
73
+ child.stderr.on('data', (chunk) => {
74
+ stderr += chunk;
75
+ });
76
+ child.on('error', (error) => {
77
+ resolve({
78
+ success: false,
79
+ stdout,
80
+ stderr,
81
+ status: null,
82
+ error: error.message,
83
+ });
84
+ });
85
+ child.on('close', (status) => {
86
+ resolve({ success: status === 0, stdout, stderr, status, error: null });
87
+ });
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Run `docker commit` and report the elapsed time every interval.
93
+ * @param {{command: string, args: string[]}} step - The commit step
94
+ * @param {object} context - {sessionName, logPath, notify, runAsync,
95
+ * intervalSeconds}
96
+ * @returns {Promise<object>} The runner result
97
+ */
98
+ async function commitWithProgress(step, context) {
99
+ const started = Date.now();
100
+ const intervalSeconds = context.intervalSeconds ?? progressIntervalSeconds();
101
+ const timer = setInterval(() => {
102
+ const elapsed = formatElapsed((Date.now() - started) / 1000);
103
+ report(
104
+ context,
105
+ `[Resume] snapshotting ${context.sessionName}: ${elapsed} elapsed (docker commit reports no progress)`
106
+ );
107
+ }, intervalSeconds * 1000);
108
+ let result;
109
+ try {
110
+ result = await context.runAsync(step.command, step.args);
111
+ } finally {
112
+ clearInterval(timer);
113
+ }
114
+ if (result && result.success) {
115
+ report(
116
+ context,
117
+ `[Resume] snapshot of ${context.sessionName} committed after ${formatElapsed((Date.now() - started) / 1000)}`
118
+ );
119
+ }
120
+ return result;
121
+ }
122
+
123
+ function detail(result, fallback) {
124
+ return (
125
+ String(result?.stderr || '').trim() ||
126
+ result?.error ||
127
+ (result?.status !== undefined && result?.status !== null
128
+ ? `exit code ${result.status}`
129
+ : fallback)
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Run `--snapshot-prepare` in the stopped original. A failure is reported and
135
+ * the snapshot continues with the files as they are.
136
+ * @param {string} sessionName - Original container
137
+ * @param {string} prepare - Shell command run with `sh -c`
138
+ * @param {Function} runner - Sync command runner
139
+ * @param {object} context - {logPath, notify}
140
+ * @returns {{success: boolean, message: string}}
141
+ */
142
+ function runSnapshotPrepare(sessionName, prepare, runner, context) {
143
+ const docker = getDockerCommand();
144
+ const started = runner(docker, ['start', sessionName]);
145
+ if (!started.success) {
146
+ const message = `[Resume] snapshot prepare skipped: could not start ${sessionName}: ${detail(started, 'docker start failed')}`;
147
+ report(context, message);
148
+ return { success: false, message };
149
+ }
150
+ let result;
151
+ try {
152
+ const timeout =
153
+ positiveSeconds(
154
+ process.env.START_SNAPSHOT_PREPARE_TIMEOUT,
155
+ DEFAULT_PREPARE_TIMEOUT_SECONDS
156
+ ) * 1000;
157
+ result = runner(docker, ['exec', sessionName, 'sh', '-c', prepare], {
158
+ timeout,
159
+ });
160
+ } catch (error) {
161
+ result = { success: false, error: error.message };
162
+ } finally {
163
+ // Never commit a running original, and never leave it running.
164
+ runner(docker, ['kill', sessionName]);
165
+ runner(docker, ['wait', sessionName]);
166
+ }
167
+ const success = Boolean(result.success);
168
+ const message = success
169
+ ? `[Resume] snapshot prepare finished in ${sessionName}`
170
+ : `[Resume] snapshot prepare failed in ${sessionName} (${detail(result, 'docker exec failed')}); snapshotting the files as they are`;
171
+ report(context, message);
172
+ return { success, message };
173
+ }
174
+
175
+ function sleep(ms) {
176
+ return new Promise((resolve) => setTimeout(resolve, ms));
177
+ }
178
+
179
+ /**
180
+ * Remove the stopped original once the replacement has survived the delay.
181
+ * @param {object} plan - Resume plan with sessionName and newSessionName
182
+ * @param {number} delaySeconds - Startup window
183
+ * @param {Function} runner - Sync command runner
184
+ * @param {object} context - {logPath, notify, sleep}
185
+ * @returns {Promise<string>} The note describing what happened
186
+ */
187
+ async function removeOriginalAfter(plan, delaySeconds, runner, context) {
188
+ report(
189
+ context,
190
+ `[Resume] Checking ${plan.newSessionName} again in ${formatElapsed(delaySeconds)} before removing ${plan.sessionName}`
191
+ );
192
+ await (context.sleep || sleep)(delaySeconds * 1000);
193
+ const docker = getDockerCommand();
194
+ const running = runner(docker, [
195
+ 'inspect',
196
+ '-f',
197
+ '{{.State.Running}}',
198
+ plan.newSessionName,
199
+ ]);
200
+ let note;
201
+ if (running.success && String(running.stdout).trim() === 'true') {
202
+ const removed = runner(docker, ['rm', plan.sessionName]);
203
+ note = removed.success
204
+ ? `Stopped original container removed: ${plan.sessionName}`
205
+ : `Original container retained: ${detail(removed, 'docker rm failed')}`;
206
+ } else {
207
+ note = `Original container retained: the snapshot container is not running ${formatElapsed(delaySeconds)} after launch.`;
208
+ }
209
+ if (context.logPath) {
210
+ appendLogFile(context.logPath, `${note}\n`);
211
+ }
212
+ return note;
213
+ }
214
+
215
+ module.exports = {
216
+ commitWithProgress,
217
+ formatElapsed,
218
+ progressIntervalSeconds,
219
+ removeOriginalAfter,
220
+ runCommandAsync,
221
+ runSnapshotPrepare,
222
+ };