start-command 0.37.8 → 0.38.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +8 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +66 -26
  5. package/src/lib/args-parser-queries.js +90 -9
  6. package/src/lib/args-parser.js +20 -1
  7. package/src/lib/container-removal.js +163 -0
  8. package/src/lib/detached-finalize.js +29 -2
  9. package/src/lib/docker-cleanup.js +7 -4
  10. package/src/lib/docker-command-handoff.js +34 -6
  11. package/src/lib/docker-post-mortem.js +2 -0
  12. package/src/lib/docker-runtime-args.js +7 -0
  13. package/src/lib/docker-snapshot-resume.js +222 -0
  14. package/src/lib/docker-snapshot-safety.js +124 -12
  15. package/src/lib/execution-attempt.js +21 -9
  16. package/src/lib/execution-chain.js +182 -0
  17. package/src/lib/execution-cleanup.js +193 -0
  18. package/src/lib/execution-control.js +2 -1
  19. package/src/lib/execution-env.js +120 -0
  20. package/src/lib/execution-identity.js +180 -0
  21. package/src/lib/execution-recovery.js +19 -0
  22. package/src/lib/execution-resume.js +77 -6
  23. package/src/lib/execution-store.js +63 -22
  24. package/src/lib/isolation.js +25 -3
  25. package/src/lib/launch-identity-options.js +81 -0
  26. package/src/lib/launch-identity.js +411 -0
  27. package/src/lib/log-uploader.js +15 -1
  28. package/src/lib/query-commands.js +34 -6
  29. package/src/lib/screen-isolation.js +11 -2
  30. package/src/lib/snapshot-resume-options.js +174 -0
  31. package/src/lib/spawn-helpers.js +33 -16
  32. package/src/lib/status-formatter.js +64 -35
  33. package/src/lib/status-links-notation.js +97 -0
  34. package/src/lib/store-lock.js +3 -0
  35. package/src/lib/usage.js +15 -1
  36. package/test/args-parser-attach-resume.js +1 -1
  37. package/test/args-parser-control.js +2 -2
  38. package/test/regression-224.js +807 -0
  39. package/test/regression-225.js +415 -0
  40. package/test/regression-226.js +81 -0
  41. package/test/regression-229.js +267 -0
  42. package/test/regression-230.js +234 -0
  43. package/test/regression-231.js +298 -0
  44. package/test/shared-golden.js +35 -0
@@ -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
  }
@@ -12,7 +12,9 @@ function buildCommandHandoffArgs(mainArgs, markerPath) {
12
12
  return [
13
13
  'sh',
14
14
  '-c',
15
- 'p=$1; shift; if [ -f "$p" ]; then exec sh "$p"; fi; exec "$@"',
15
+ // `$p.env` refreshes the START_* variables a `docker start` cannot
16
+ // change (issue #230) before either command runs.
17
+ 'p=$1; shift; if [ -f "$p.env" ]; then . "$p.env"; fi; if [ -f "$p" ]; then exec sh "$p"; fi; exec "$@"',
16
18
  'start-command-handoff',
17
19
  markerPath,
18
20
  ...mainArgs,
@@ -49,27 +51,53 @@ function buildCommandHandoffScript(command, options = {}) {
49
51
  return `exec ${shellQuote(shell)}${interactive} -c ${shellQuote(effective)}\n`;
50
52
  }
51
53
 
52
- function writeCommandHandoff(containerName, command, runner, options = {}) {
54
+ function copyIntoContainer(containerName, target, content, runner) {
53
55
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-handoff-'));
54
56
  const file = path.join(dir, 'command');
55
57
  try {
56
- fs.writeFileSync(file, buildCommandHandoffScript(command, options), {
57
- mode: 0o644,
58
- });
58
+ fs.writeFileSync(file, content, { mode: 0o644 });
59
59
  fs.chmodSync(file, 0o644);
60
60
  return runner(require('./docker-cleanup').getDockerCommand(), [
61
61
  'cp',
62
62
  file,
63
- `${containerName}:${commandHandoffPath(containerName)}`,
63
+ `${containerName}:${target}`,
64
64
  ]);
65
65
  } finally {
66
66
  fs.rmSync(dir, { recursive: true, force: true });
67
67
  }
68
68
  }
69
69
 
70
+ function writeCommandHandoff(containerName, command, runner, options = {}) {
71
+ return copyIntoContainer(
72
+ containerName,
73
+ commandHandoffPath(containerName),
74
+ buildCommandHandoffScript(command, options),
75
+ runner
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Copy the START_* variables of the next attempt into a stopped container
81
+ * that was created with the handoff selector (issue #230).
82
+ * @param {string} containerName - Container name
83
+ * @param {object} env - Variables from `buildExecutionEnv`
84
+ * @param {Function} runner - Command runner
85
+ * @returns {object} Runner result
86
+ */
87
+ function writeExecutionEnvHandoff(containerName, env, runner) {
88
+ const statement = require('./execution-env').buildExecutionEnvExport(env);
89
+ return copyIntoContainer(
90
+ containerName,
91
+ `${commandHandoffPath(containerName)}.env`,
92
+ statement ? `${statement}\n` : '',
93
+ runner
94
+ );
95
+ }
96
+
70
97
  module.exports = {
71
98
  commandHandoffPath,
72
99
  buildCommandHandoffArgs,
73
100
  buildCommandHandoffScript,
74
101
  writeCommandHandoff,
102
+ writeExecutionEnvHandoff,
75
103
  };
@@ -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. */
@@ -42,6 +42,13 @@ function buildDockerRuntimeArgs(options = {}) {
42
42
  for (const envVar of options.env || []) {
43
43
  args.push('-e', envVar);
44
44
  }
45
+ // Execution identity (issue #230); last, so a user `--env` cannot spoof it.
46
+ const executionEnv = require('./execution-env');
47
+ for (const envVar of executionEnv.executionEnvAssignments(
48
+ executionEnv.executionEnvFromOptions(options)
49
+ )) {
50
+ args.push('-e', envVar);
51
+ }
45
52
  for (const label of require('./docker-labels').dockerLabels(options)) {
46
53
  args.push('--label', label);
47
54
  }
@@ -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
+ };
@@ -8,6 +8,30 @@ const GiB = 1024 ** 3;
8
8
  const SNAPSHOT_RESERVE_BYTES = 10 * GiB;
9
9
  const SNAPSHOT_IMAGE_LABEL = 'start-command.snapshot-image';
10
10
 
11
+ /** sysexits.h EX_TEMPFAIL: the resume can succeed when retried later. */
12
+ const EX_TEMPFAIL = 75;
13
+
14
+ function tempfail(message) {
15
+ const error = new Error(message);
16
+ error.exitCode = EX_TEMPFAIL;
17
+ return error;
18
+ }
19
+
20
+ function formatSeconds(seconds) {
21
+ return `${Number(seconds.toFixed(3))}s`;
22
+ }
23
+
24
+ function notifier(options) {
25
+ return options.notify || ((line) => process.stderr.write(`${line}\n`));
26
+ }
27
+
28
+ /**
29
+ * Take the host-wide snapshot lock. With `waitSeconds` above 0 a busy lock is
30
+ * waited for (issue #224); the wait is announced once with the holder's name.
31
+ * @param {object} [options] - {lockPath, holder, waitSeconds, notify}
32
+ * @returns {LockManager} The held lock
33
+ * @throws {Error} With `exitCode` 75 when the lock stays busy
34
+ */
11
35
  function acquireSnapshotLock(options = {}) {
12
36
  const lockPath =
13
37
  options.lockPath ||
@@ -17,6 +41,9 @@ function acquireSnapshotLock(options = {}) {
17
41
  'start-command-docker-snapshot.lock'
18
42
  );
19
43
  const lock = new LockManager(lockPath);
44
+ if (options.holder) {
45
+ lock.metadata = { holder: options.holder };
46
+ }
20
47
  // The ordinary store lock is held only for a brief write. A snapshot can
21
48
  // legitimately take hours, so never reclaim this lock merely for its age.
22
49
  lock.isLockStale = (data) => {
@@ -35,12 +62,28 @@ function acquireSnapshotLock(options = {}) {
35
62
  return error.code === 'ESRCH';
36
63
  }
37
64
  };
38
- if (!lock.acquire(1000)) {
39
- throw new Error(
40
- 'A Docker snapshot resume is in progress on this host. Retry after it finishes.'
65
+ const waitMs = Math.max(0, (options.waitSeconds || 0) * 1000);
66
+ if (lock.acquire(Math.min(1000, waitMs || 1000))) {
67
+ return lock;
68
+ }
69
+ const holder = lock.readLockFile()?.holder;
70
+ const busy = holder
71
+ ? `A Docker snapshot resume of ${holder} is in progress on this host.`
72
+ : 'A Docker snapshot resume is in progress on this host.';
73
+ if (waitMs <= 1000) {
74
+ throw tempfail(
75
+ `${busy} Retry after it finishes, or wait for it with --snapshot-wait <seconds>.`
41
76
  );
42
77
  }
43
- return lock;
78
+ notifier(options)(
79
+ `[Resume] Waiting up to ${formatSeconds(waitMs / 1000)} for the snapshot of ${holder || 'another container'} to finish before snapshotting ${options.holder || 'this container'}.`
80
+ );
81
+ if (lock.acquire(waitMs - 1000)) {
82
+ return lock;
83
+ }
84
+ throw tempfail(
85
+ `${busy} Still busy after waiting ${formatSeconds(waitMs / 1000)}; retry after it finishes.`
86
+ );
44
87
  }
45
88
 
46
89
  function containerdRoot(config = '') {
@@ -73,8 +116,10 @@ function readFreeBytes(root) {
73
116
  return Number(stats.bavail * stats.bsize);
74
117
  }
75
118
 
76
- function preflightSnapshot(containerName, runner, options = {}) {
77
- const docker = require('./docker-cleanup').getDockerCommand();
119
+ function measureWritableLayer(containerName, runner, docker, options) {
120
+ if (options.sizeHint !== null && options.sizeHint !== undefined) {
121
+ return { size: options.sizeHint, source: 'size hint' };
122
+ }
78
123
  const sizeResult = runner(docker, [
79
124
  'inspect',
80
125
  '--size',
@@ -93,6 +138,10 @@ function preflightSnapshot(containerName, runner, options = {}) {
93
138
  'Cannot determine the writable layer size; refusing Docker snapshot resume.'
94
139
  );
95
140
  }
141
+ return { size, source: null };
142
+ }
143
+
144
+ function imageStoreRoots(runner, docker, options) {
96
145
  const infoResult = runner(docker, ['info', '--format', '{{json .}}']);
97
146
  let info;
98
147
  try {
@@ -113,9 +162,12 @@ function preflightSnapshot(containerName, runner, options = {}) {
113
162
  ) {
114
163
  roots.push(options.containerdRoot || defaultContainerdRoot());
115
164
  }
116
- const required = 2 * size + (options.reserveBytes ?? SNAPSHOT_RESERVE_BYTES);
117
- const free = options.freeBytes || readFreeBytes;
118
- for (const root of [...new Set(roots)]) {
165
+ return [...new Set(roots)];
166
+ }
167
+
168
+ /** @returns {?object} The first root without enough space, or null */
169
+ function findShortRoot(roots, required, free) {
170
+ for (const root of roots) {
119
171
  let available;
120
172
  try {
121
173
  available = free(root);
@@ -126,16 +178,75 @@ function preflightSnapshot(containerName, runner, options = {}) {
126
178
  );
127
179
  }
128
180
  if (!Number.isFinite(available) || available < required) {
129
- throw new Error(
130
- `Insufficient disk at ${root}: need ${(required / GiB).toFixed(2)} GiB (2 × writable layer plus reserve), available ${(available / GiB).toFixed(2)} GiB. Docker snapshot resume refused.`
181
+ return { root, available };
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+
187
+ function sleepSync(ms) {
188
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
189
+ }
190
+
191
+ /**
192
+ * Refuse a snapshot that could fill the disk. The writable layer is measured
193
+ * once (or taken from `sizeHint`); with `diskWaitSeconds` the free space is
194
+ * re-checked every `diskPollSeconds` until it suffices (issue #224).
195
+ * @param {object} [options] - {sizeHint, diskWaitSeconds, diskPollSeconds,
196
+ * reserveBytes, freeBytes, containerdRoot, notify, sleep, now}
197
+ * @throws {Error} With `exitCode` 75 when the disk stays short
198
+ */
199
+ function preflightSnapshot(containerName, runner, options = {}) {
200
+ const docker = require('./docker-cleanup').getDockerCommand();
201
+ const { size, source } = measureWritableLayer(
202
+ containerName,
203
+ runner,
204
+ docker,
205
+ options
206
+ );
207
+ const roots = imageStoreRoots(runner, docker, options);
208
+ const required = 2 * size + (options.reserveBytes ?? SNAPSHOT_RESERVE_BYTES);
209
+ const free = options.freeBytes || readFreeBytes;
210
+ const waitMs = Math.max(0, (options.diskWaitSeconds || 0) * 1000);
211
+ const pollMs = Math.max(
212
+ 1,
213
+ (options.diskPollSeconds ??
214
+ Number(process.env.START_SNAPSHOT_DISK_POLL || 10)) * 1000
215
+ );
216
+ const now = options.now || Date.now;
217
+ const sleep = options.sleep || sleepSync;
218
+ const started = now();
219
+ let waited = false;
220
+ for (;;) {
221
+ const short = findShortRoot(roots, required, free);
222
+ if (!short) {
223
+ break;
224
+ }
225
+ const detail = `Insufficient disk at ${short.root}: need ${(required / GiB).toFixed(2)} GiB (2 × writable layer plus reserve), available ${(short.available / GiB).toFixed(2)} GiB.`;
226
+ const left = waitMs - (now() - started);
227
+ if (left <= 0) {
228
+ throw tempfail(
229
+ waited
230
+ ? `${detail} Still short after waiting ${formatSeconds(waitMs / 1000)}; Docker snapshot resume refused.`
231
+ : `${detail} Docker snapshot resume refused.`
232
+ );
233
+ }
234
+ if (!waited) {
235
+ notifier(options)(
236
+ `[Resume] Waiting up to ${formatSeconds(waitMs / 1000)} for disk: ${detail}`
131
237
  );
238
+ waited = true;
132
239
  }
240
+ sleep(Math.min(pollMs, left));
133
241
  }
242
+ const measured = source
243
+ ? `${(size / GiB).toFixed(2)} GiB (${source})`
244
+ : `${(size / GiB).toFixed(2)} GiB`;
134
245
  return {
135
246
  size,
136
247
  required,
137
248
  roots,
138
- message: `[Resume] snapshotting ${(size / GiB).toFixed(2)} GiB; disk preflight passed (${(required / GiB).toFixed(2)} GiB required).`,
249
+ message: `[Resume] snapshotting ${measured}; disk preflight passed (${(required / GiB).toFixed(2)} GiB required).`,
139
250
  };
140
251
  }
141
252
 
@@ -155,6 +266,7 @@ function snapshotImageCleanupSnippet(
155
266
  }
156
267
 
157
268
  module.exports = {
269
+ EX_TEMPFAIL,
158
270
  acquireSnapshotLock,
159
271
  containerdRoot,
160
272
  preflightSnapshot,
@@ -7,6 +7,8 @@ const TERMINAL_FIELDS = [
7
7
  'observedAt',
8
8
  'staleDetectedAt',
9
9
  'containerStartedAt',
10
+ 'containerState',
11
+ 'containerRemovedObservedAt',
10
12
  'exitReason',
11
13
  'oomKilled',
12
14
  'memoryExhausted',
@@ -44,6 +46,21 @@ function readAttemptActivity(record) {
44
46
  return record;
45
47
  }
46
48
 
49
+ /** Number of the record's current attempt: 1 until it is resumed. */
50
+ function currentAttemptNumber(record) {
51
+ return (
52
+ record?.attempt?.number ||
53
+ (Number(record?.options?.resumeCount) || 0) +
54
+ (Number(record?.options?.recoveryAttempts) || 0) +
55
+ 1
56
+ );
57
+ }
58
+
59
+ /** Number the next attempt of `record` gets (`START_ATTEMPT`, issue #230). */
60
+ function nextAttemptNumber(record) {
61
+ return currentAttemptNumber(record) + 1;
62
+ }
63
+
47
64
  function createAttempt(record, plan) {
48
65
  let logOffset = null;
49
66
  if (record.logPath) {
@@ -56,11 +73,7 @@ function createAttempt(record, plan) {
56
73
  }
57
74
  }
58
75
  return {
59
- number:
60
- (record.attempt?.number ||
61
- (Number(record.options?.resumeCount) || 0) +
62
- (Number(record.options?.recoveryAttempts) || 0) +
63
- 1) + 1,
76
+ number: nextAttemptNumber(record),
64
77
  startedAt: new Date().toISOString(),
65
78
  logOffset,
66
79
  lastOutputAt: null,
@@ -76,10 +89,7 @@ function archiveAttempt(record) {
76
89
  readAttemptActivity(record);
77
90
  const snapshot = {
78
91
  ...(record.attempt || {
79
- number:
80
- (Number(record.options?.resumeCount) || 0) +
81
- (Number(record.options?.recoveryAttempts) || 0) +
82
- 1,
92
+ number: currentAttemptNumber(record),
83
93
  startedAt: record.options?.resumedAt || record.startTime,
84
94
  logOffset: null,
85
95
  }),
@@ -174,6 +184,8 @@ module.exports = {
174
184
  appendLifecycle,
175
185
  archiveAttempt,
176
186
  createAttempt,
187
+ currentAttemptNumber,
188
+ nextAttemptNumber,
177
189
  patchAttempt,
178
190
  readAttemptActivity,
179
191
  readAttemptLogTail,