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.
@@ -0,0 +1,897 @@
1
+ /**
2
+ * Regression tests for issue #176:
3
+ *
4
+ * 1. `$ --resume <id> -- <cmd>` snapshots the stopped container with
5
+ * `docker commit` and starts a new one with `docker run`. `docker commit`
6
+ * does not keep the HostConfig, so the memory / CPU / PIDs limits the old
7
+ * container had (often applied later with `docker update`) were silently
8
+ * dropped. They must be read with `docker inspect` and re-applied.
9
+ * 2. `--on-kill-resume N --recovery-command B` resumes a detached docker
10
+ * session whose main process was killed (exit 137 / OOMKilled) in the same
11
+ * container, under the same execution UUID and log, up to N times.
12
+ */
13
+
14
+ const { describe, it, expect, afterEach } = require('bun:test');
15
+ const { spawnSync } = require('child_process');
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+
20
+ const {
21
+ ExecutionStore,
22
+ ExecutionRecord,
23
+ ExecutionStatus,
24
+ } = require('../src/lib/execution-store');
25
+ const {
26
+ buildDetachedDockerCompletionScript,
27
+ DOCKER_CONTAINER_CLEANUP_POLICY,
28
+ getDockerContainerCleanupPolicy,
29
+ } = require('../src/lib/docker-cleanup');
30
+ const {
31
+ buildResourceLimitsStatusLine,
32
+ formatDockerBytes,
33
+ formatDockerCpus,
34
+ normalizeResourceLimits,
35
+ parseDockerResourceLimits,
36
+ readDockerResourceLimits,
37
+ } = require('../src/lib/docker-resource-limits');
38
+ const {
39
+ buildDockerRuntimeArgs,
40
+ buildDockerRuntimeMetadata,
41
+ buildDockerRuntimeStatusLines,
42
+ } = require('../src/lib/isolation');
43
+ const {
44
+ RECOVERY_ATTEMPT_ENV,
45
+ RECOVERY_MARKER_PATH,
46
+ RECOVERY_SELECTOR,
47
+ buildRecoverySelectorArgs,
48
+ formatRecoverySeparator,
49
+ isKilledExit,
50
+ recoverKilledExecution,
51
+ } = require('../src/lib/execution-recovery');
52
+ const {
53
+ ResumeMode,
54
+ buildResumePlan,
55
+ resumeExecution,
56
+ } = require('../src/lib/execution-resume');
57
+ const {
58
+ ControlAction,
59
+ controlExecution,
60
+ runCommand,
61
+ } = require('../src/lib/execution-control');
62
+ const { SessionState } = require('../src/lib/session-probe');
63
+ const { parseArgs } = require('../src/lib/args-parser');
64
+
65
+ const tempDirs = [];
66
+
67
+ function makeTempDir(prefix) {
68
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
69
+ tempDirs.push(dir);
70
+ return dir;
71
+ }
72
+
73
+ afterEach(() => {
74
+ while (tempDirs.length > 0) {
75
+ const dir = tempDirs.pop();
76
+ fs.rmSync(dir, { recursive: true, force: true });
77
+ }
78
+ });
79
+
80
+ const needsPosixShell = () => {
81
+ if (process.platform !== 'win32') {
82
+ return false;
83
+ }
84
+ console.log(' Skipping: the test needs a POSIX /bin/sh');
85
+ return true;
86
+ };
87
+
88
+ /** HostConfig of the container from the issue after `docker update`. */
89
+ const UPDATED_HOST_CONFIG = {
90
+ Memory: 268435456,
91
+ MemorySwap: 268435456,
92
+ NanoCpus: 500000000,
93
+ CpuQuota: 0,
94
+ CpuPeriod: 0,
95
+ CpuShares: 0,
96
+ CpusetCpus: '',
97
+ PidsLimit: 64,
98
+ ShmSize: 67108864,
99
+ Ulimits: null,
100
+ };
101
+
102
+ const ISSUE_LIMITS = [
103
+ '--memory=256m',
104
+ '--memory-swap=256m',
105
+ '--cpus=0.5',
106
+ '--pids-limit=64',
107
+ ];
108
+
109
+ function ok(stdout = '') {
110
+ return { success: true, stdout, stderr: '', status: 0, error: null };
111
+ }
112
+
113
+ /** Fake `runner` that answers `docker inspect` and records every call. */
114
+ function fakeRunner(hostConfig, failures = {}) {
115
+ const calls = [];
116
+ const runner = (command, args) => {
117
+ calls.push([command, ...args]);
118
+ if (failures[args[0]]) {
119
+ return {
120
+ success: false,
121
+ stdout: '',
122
+ stderr: failures[args[0]],
123
+ status: 1,
124
+ error: null,
125
+ };
126
+ }
127
+ if (args[0] === 'inspect' && args.includes('{{json .HostConfig}}')) {
128
+ return ok(`${JSON.stringify(hostConfig)}\n`);
129
+ }
130
+ return ok('cid\n');
131
+ };
132
+ runner.calls = calls;
133
+ return runner;
134
+ }
135
+
136
+ function stoppedProbe() {
137
+ return {
138
+ backend: 'docker',
139
+ sessionName: 'box',
140
+ state: SessionState.STOPPED,
141
+ alive: false,
142
+ containerStatus: 'exited',
143
+ };
144
+ }
145
+
146
+ describe('issue #176: resource limits read from docker inspect', () => {
147
+ it('formats sizes and CPUs the way docker run accepts them', () => {
148
+ expect(formatDockerBytes(268435456)).toBe('256m');
149
+ expect(formatDockerBytes(2 * 1024 ** 3)).toBe('2g');
150
+ expect(formatDockerBytes(1536 * 1024)).toBe('1536k');
151
+ expect(formatDockerBytes(1000)).toBe('1000');
152
+ expect(formatDockerCpus(500000000)).toBe('0.5');
153
+ expect(formatDockerCpus(1500000000)).toBe('1.5');
154
+ });
155
+
156
+ it('translates the HostConfig from the issue into run flags', () => {
157
+ expect(parseDockerResourceLimits(UPDATED_HOST_CONFIG)).toEqual(
158
+ ISSUE_LIMITS
159
+ );
160
+ });
161
+
162
+ it('emits nothing for an unlimited container', () => {
163
+ expect(
164
+ parseDockerResourceLimits({
165
+ Memory: 0,
166
+ MemorySwap: 0,
167
+ NanoCpus: 0,
168
+ PidsLimit: null,
169
+ ShmSize: 67108864,
170
+ })
171
+ ).toEqual([]);
172
+ expect(parseDockerResourceLimits(null)).toEqual([]);
173
+ });
174
+
175
+ it('covers quota/period, cpusets, shm, storage options and ulimits', () => {
176
+ expect(
177
+ parseDockerResourceLimits({
178
+ Memory: 512 * 1024 ** 2,
179
+ MemorySwap: -1,
180
+ MemoryReservation: 128 * 1024 ** 2,
181
+ CpuQuota: 50000,
182
+ CpuPeriod: 100000,
183
+ CpuShares: 512,
184
+ CpusetCpus: '0,1',
185
+ CpusetMems: '0',
186
+ PidsLimit: -1,
187
+ ShmSize: 256 * 1024 ** 2,
188
+ StorageOpt: { size: '10G' },
189
+ Ulimits: [{ Name: 'nofile', Soft: 1024, Hard: 2048 }],
190
+ })
191
+ ).toEqual([
192
+ '--memory=512m',
193
+ '--memory-swap=-1',
194
+ '--memory-reservation=128m',
195
+ '--cpu-quota=50000',
196
+ '--cpu-period=100000',
197
+ '--cpu-shares=512',
198
+ '--cpuset-cpus=0,1',
199
+ '--cpuset-mems=0',
200
+ '--shm-size=256m',
201
+ '--storage-opt=size=10G',
202
+ '--ulimit=nofile=1024:2048',
203
+ ]);
204
+ });
205
+
206
+ it('never emits --memory-swap without --memory', () => {
207
+ expect(parseDockerResourceLimits({ MemorySwap: 268435456 })).toEqual([]);
208
+ });
209
+
210
+ it('reads the HostConfig with docker inspect', () => {
211
+ const runner = fakeRunner(UPDATED_HOST_CONFIG);
212
+ expect(readDockerResourceLimits('box', runner)).toEqual(ISSUE_LIMITS);
213
+ expect(runner.calls[0].slice(1)).toEqual([
214
+ 'inspect',
215
+ '--format',
216
+ '{{json .HostConfig}}',
217
+ 'box',
218
+ ]);
219
+ expect(
220
+ readDockerResourceLimits('box', fakeRunner({}, { inspect: 'gone' }))
221
+ ).toBeNull();
222
+ });
223
+
224
+ it('keeps stored limits as flags, including commas in cpusets', () => {
225
+ expect(normalizeResourceLimits('--cpuset-cpus=0,1 --memory=1g')).toEqual([
226
+ '--cpuset-cpus=0,1',
227
+ '--memory=1g',
228
+ ]);
229
+ expect(normalizeResourceLimits(['--cpus=2', 'junk'])).toEqual(['--cpus=2']);
230
+ expect(normalizeResourceLimits(null)).toEqual([]);
231
+ expect(buildResourceLimitsStatusLine(ISSUE_LIMITS)).toBe(
232
+ '[Isolation] Resource limits: --memory=256m --memory-swap=256m --cpus=0.5 --pids-limit=64'
233
+ );
234
+ expect(buildResourceLimitsStatusLine([])).toBeNull();
235
+ });
236
+ });
237
+
238
+ describe('issue #176: snapshot resume keeps the resource limits', () => {
239
+ function record(options = {}) {
240
+ return {
241
+ uuid: '11111111-2222-3333-4444-555555555555',
242
+ status: 'executed',
243
+ command: 'npm test',
244
+ logPath: null,
245
+ options: {
246
+ isolated: 'docker',
247
+ isolationMode: 'detached',
248
+ sessionName: 'box',
249
+ image: 'ubuntu:24.04',
250
+ ...options,
251
+ },
252
+ };
253
+ }
254
+
255
+ it('passes the live limits to docker run of the -resume-N container', () => {
256
+ const plan = buildResumePlan(
257
+ record(),
258
+ 'npm run build',
259
+ stoppedProbe(),
260
+ ISSUE_LIMITS
261
+ );
262
+ expect(plan.mode).toBe(ResumeMode.DOCKER_SNAPSHOT);
263
+ expect(plan.resourceLimits).toEqual(ISSUE_LIMITS);
264
+ const runArgs = plan.steps[1].args;
265
+ const imageIndex = runArgs.indexOf('start-command-resume/box:1');
266
+ expect(runArgs.slice(imageIndex - ISSUE_LIMITS.length, imageIndex)).toEqual(
267
+ ISSUE_LIMITS
268
+ );
269
+ });
270
+
271
+ it('falls back to the limits stored in the record', () => {
272
+ const plan = buildResumePlan(
273
+ record({ resourceLimits: ['--memory=1g'] }),
274
+ 'npm run build',
275
+ stoppedProbe(),
276
+ null
277
+ );
278
+ expect(plan.steps[1].args).toContain('--memory=1g');
279
+ });
280
+
281
+ it('carries stored limits into a relaunch', () => {
282
+ const plan = buildResumePlan(
283
+ record({ resourceLimits: ['--pids-limit=64'] }),
284
+ null,
285
+ { backend: 'docker', state: SessionState.MISSING, alive: false }
286
+ );
287
+ expect(plan.mode).toBe(ResumeMode.RELAUNCH);
288
+ expect(plan.launchOptions.resourceLimits).toEqual(['--pids-limit=64']);
289
+ });
290
+
291
+ it('inspects the old container before the commit and records the limits', async () => {
292
+ const saved = [];
293
+ const store = {
294
+ get: () => record(),
295
+ save: (r) => saved.push(JSON.parse(JSON.stringify(r))),
296
+ };
297
+ const runner = fakeRunner(UPDATED_HOST_CONFIG);
298
+ const watchers = [];
299
+ const result = await resumeExecution(store, 'box', {
300
+ command: 'npm run build',
301
+ probe: stoppedProbe,
302
+ runner,
303
+ startWatcher: (...args) => watchers.push(args),
304
+ });
305
+ expect(result.success).toBe(true);
306
+ const verbs = runner.calls.map((call) => call[1]);
307
+ expect(verbs.indexOf('inspect')).toBeLessThan(verbs.indexOf('commit'));
308
+ const run = runner.calls.find((call) => call[1] === 'run');
309
+ for (const flag of ISSUE_LIMITS) {
310
+ expect(run).toContain(flag);
311
+ }
312
+ expect(saved[0].options.resourceLimits).toEqual(ISSUE_LIMITS);
313
+ expect(saved[0].options.sessionName).toBe('box-resume-1');
314
+ expect(result.output).toContain('--pids-limit=64');
315
+ // A snapshot container has no recovery selector.
316
+ expect(watchers[0][4]).toEqual({ recoverOnKill: false });
317
+ });
318
+
319
+ it('shows the limits in [Isolation] lines and stores them as metadata', () => {
320
+ const options = { resourceLimits: ISSUE_LIMITS };
321
+ expect(buildDockerRuntimeArgs(options)).toEqual(ISSUE_LIMITS);
322
+ expect(buildDockerRuntimeStatusLines(options)).toContain(
323
+ `[Isolation] Resource limits: ${ISSUE_LIMITS.join(' ')}`
324
+ );
325
+ expect(buildDockerRuntimeMetadata(options).resourceLimits).toEqual(
326
+ ISSUE_LIMITS
327
+ );
328
+ });
329
+ });
330
+
331
+ describe('issue #176: --on-kill-resume / --recovery-command options', () => {
332
+ const launch = (...flags) =>
333
+ parseArgs(['--isolated', 'docker', '--detached', ...flags, '--', 'solve']);
334
+
335
+ it('parses both options in separate and = forms', () => {
336
+ const { wrapperOptions } = launch(
337
+ '--on-kill-resume',
338
+ '3',
339
+ '--recovery-command',
340
+ 'solve --resume'
341
+ );
342
+ expect(wrapperOptions.onKillResume).toBe(3);
343
+ expect(wrapperOptions.recoveryCommand).toBe('solve --resume');
344
+ const eq = launch(
345
+ '--on-kill-resume=2',
346
+ '--recovery-command=b'
347
+ ).wrapperOptions;
348
+ expect(eq.onKillResume).toBe(2);
349
+ expect(eq.recoveryCommand).toBe('b');
350
+ });
351
+
352
+ it('defaults to one attempt when only a recovery command is given', () => {
353
+ expect(launch('--recovery-command', 'b').wrapperOptions.onKillResume).toBe(
354
+ 1
355
+ );
356
+ });
357
+
358
+ it('rejects invalid counts and missing values', () => {
359
+ expect(() => launch('--on-kill-resume', '0')).toThrow(/positive integer/);
360
+ expect(() => launch('--on-kill-resume=x')).toThrow(/positive integer/);
361
+ expect(() => launch('--recovery-command')).toThrow(/requires a command/);
362
+ expect(() => launch('--recovery-command=')).toThrow(/non-empty/);
363
+ });
364
+
365
+ it('requires a detached docker-only session', () => {
366
+ expect(() =>
367
+ parseArgs(['--isolated', 'docker', '--on-kill-resume', '2', '--', 'a'])
368
+ ).toThrow(/requires --detached/);
369
+ expect(() =>
370
+ parseArgs([
371
+ '--isolated',
372
+ 'screen',
373
+ '--detached',
374
+ '--on-kill-resume',
375
+ '2',
376
+ '--',
377
+ 'a',
378
+ ])
379
+ ).toThrow(/only valid with --isolated docker/);
380
+ expect(() => parseArgs(['--on-kill-resume', '2', '--', 'a'])).toThrow(
381
+ /only valid with --isolated docker/
382
+ );
383
+ });
384
+
385
+ it('describes the recovery in [Isolation] lines and metadata', () => {
386
+ const options = { onKillResume: 3, recoveryCommand: 'solve --resume' };
387
+ expect(buildDockerRuntimeStatusLines(options)).toContain(
388
+ '[Isolation] On kill: resume up to 3 time(s) with solve --resume'
389
+ );
390
+ expect(buildDockerRuntimeMetadata(options)).toMatchObject({
391
+ onKillResume: 3,
392
+ recoveryCommand: 'solve --resume',
393
+ });
394
+ expect(buildDockerRuntimeStatusLines({ onKillResume: 1 })).toContain(
395
+ '[Isolation] On kill: resume up to 1 time(s) with the original command'
396
+ );
397
+ });
398
+ });
399
+
400
+ describe('issue #176: recovery selector inside the container', () => {
401
+ function runSelector(markerContent) {
402
+ const dir = makeTempDir('selector-176-');
403
+ const marker = path.join(dir, 'marker');
404
+ if (markerContent !== null) {
405
+ fs.writeFileSync(marker, markerContent);
406
+ }
407
+ const args = buildRecoverySelectorArgs(['sh', '-c', 'echo main'], {
408
+ shell: 'sh',
409
+ shellFlag: '',
410
+ recoveryCommand: `echo "recovery $${RECOVERY_ATTEMPT_ENV}"`,
411
+ });
412
+ // Point the selector at a temp marker instead of `/` of a container.
413
+ args[2] = args[2].split(RECOVERY_MARKER_PATH).join(marker);
414
+ const result = spawnSync(args[0], args.slice(1), { encoding: 'utf8' });
415
+ return result.stdout.trim();
416
+ }
417
+
418
+ it('runs the main command until a recovery is marked', () => {
419
+ if (needsPosixShell()) {
420
+ return;
421
+ }
422
+ expect(runSelector(null)).toBe('main');
423
+ expect(runSelector('2\n')).toBe('recovery 2');
424
+ });
425
+
426
+ it('wraps the main argv after the recovery parameters', () => {
427
+ expect(
428
+ buildRecoverySelectorArgs(['bash', '-c', 'a'], {
429
+ shell: 'bash',
430
+ shellFlag: '-l',
431
+ recoveryCommand: 'b',
432
+ })
433
+ ).toEqual([
434
+ 'sh',
435
+ '-c',
436
+ RECOVERY_SELECTOR,
437
+ 'start-command',
438
+ 'bash',
439
+ '-l',
440
+ 'b',
441
+ 'bash',
442
+ '-c',
443
+ 'a',
444
+ ]);
445
+ });
446
+ });
447
+
448
+ describe('issue #176: resuming a killed session', () => {
449
+ function makeStore(options = {}) {
450
+ const dir = makeTempDir('recovery-176-');
451
+ const store = new ExecutionStore({
452
+ appFolder: path.join(dir, 'app'),
453
+ useLinks: false,
454
+ });
455
+ const logPath = path.join(dir, 'run.log');
456
+ fs.writeFileSync(logPath, 'main output\n');
457
+ const record = new ExecutionRecord({
458
+ status: ExecutionStatus.EXECUTING,
459
+ command: 'solve',
460
+ logPath,
461
+ options: {
462
+ isolated: 'docker',
463
+ isolationMode: 'detached',
464
+ sessionName: 'box',
465
+ onKillResume: 2,
466
+ recoveryCommand: 'solve --resume',
467
+ ...options,
468
+ },
469
+ });
470
+ store.save(record);
471
+ return { store, record, logPath };
472
+ }
473
+
474
+ function recover(store, record, extra = {}) {
475
+ const runner = extra.runner || fakeRunner(UPDATED_HOST_CONFIG);
476
+ const watchers = [];
477
+ const outcome = recoverKilledExecution({
478
+ store,
479
+ executionId: record.uuid,
480
+ exitCode: '137',
481
+ oomKilled: 'true',
482
+ startedAt: '2026-10-01T10:00:00Z',
483
+ finishedAt: '2026-10-01T10:05:00Z',
484
+ containerError: '',
485
+ runner,
486
+ startWatcher: (...args) => watchers.push(args),
487
+ now: () => new Date('2026-10-01T10:05:01.000Z'),
488
+ ...extra,
489
+ });
490
+ return { outcome, runner, watchers };
491
+ }
492
+
493
+ it('detects a kill by exit 137 or OOMKilled', () => {
494
+ expect(isKilledExit(137, false)).toBe(true);
495
+ expect(isKilledExit('137', 'false')).toBe(true);
496
+ expect(isKilledExit('0', 'true')).toBe(true);
497
+ expect(isKilledExit('1', 'false')).toBe(false);
498
+ });
499
+
500
+ it('formats the [Recovery k/N] separator', () => {
501
+ expect(
502
+ formatRecoverySeparator({
503
+ attempt: 1,
504
+ maxAttempts: 3,
505
+ exitCode: 137,
506
+ oomKilled: true,
507
+ containerName: 'box',
508
+ command: 'solve --resume',
509
+ })
510
+ ).toBe(
511
+ '\n[Recovery 1/3] Main process was killed (exit 137, SIGKILL, oomKilled=true); resuming container box, running recovery command: solve --resume\n'
512
+ );
513
+ });
514
+
515
+ it('marks the container, restarts it and keeps the same UUID and log', () => {
516
+ const { store, record, logPath } = makeStore();
517
+ const { outcome, runner, watchers } = recover(store, record);
518
+ expect(outcome).toEqual({ recovered: true, reason: 'resumed', attempt: 1 });
519
+
520
+ const cp = runner.calls.find((call) => call[1] === 'cp');
521
+ expect(cp[3]).toBe(`box:${RECOVERY_MARKER_PATH}`);
522
+ const verbs = runner.calls.map((call) => call[1]);
523
+ expect(verbs.indexOf('cp')).toBeLessThan(verbs.indexOf('start'));
524
+ expect(runner.calls.find((call) => call[1] === 'start')).toEqual([
525
+ 'docker',
526
+ 'start',
527
+ 'box',
528
+ ]);
529
+
530
+ const log = fs.readFileSync(logPath, 'utf8');
531
+ expect(log.startsWith('main output\n')).toBe(true);
532
+ expect(log).toContain('[Recovery 1/2] Main process was killed (exit 137');
533
+ expect(log).toContain('running recovery command: solve --resume');
534
+ expect(log).toContain(
535
+ `[Isolation] Resource limits: ${ISSUE_LIMITS.join(' ')}`
536
+ );
537
+
538
+ const updated = store.get(record.uuid);
539
+ expect(updated.uuid).toBe(record.uuid);
540
+ expect(updated.status).toBe(ExecutionStatus.EXECUTING);
541
+ expect(updated.options.recoveryAttempts).toBe(1);
542
+ expect(updated.options.recoveryHistory).toEqual([
543
+ '1: exit 137, oomKilled=true, resumed at 2026-10-01T10:05:01.000Z',
544
+ ]);
545
+ expect(updated.options.resourceLimits).toEqual(ISSUE_LIMITS);
546
+
547
+ expect(watchers).toEqual([
548
+ [
549
+ 'box',
550
+ getDockerContainerCleanupPolicy(record.options),
551
+ logPath,
552
+ record.uuid,
553
+ { since: '2026-10-01T10:05:01.000Z', recoverOnKill: true },
554
+ ],
555
+ ]);
556
+ });
557
+
558
+ it('re-runs the original command when no recovery command is set', () => {
559
+ const { store, record, logPath } = makeStore({ recoveryCommand: null });
560
+ const { outcome, runner } = recover(store, record);
561
+ expect(outcome.recovered).toBe(true);
562
+ expect(runner.calls.some((call) => call[1] === 'cp')).toBe(false);
563
+ expect(fs.readFileSync(logPath, 'utf8')).toContain(
564
+ 'running the original command again'
565
+ );
566
+ });
567
+
568
+ it('stops after N attempts', () => {
569
+ const { store, record, logPath } = makeStore({ recoveryAttempts: 2 });
570
+ const { outcome, runner } = recover(store, record);
571
+ expect(outcome).toEqual({
572
+ recovered: false,
573
+ reason: 'attempts-exhausted',
574
+ });
575
+ expect(runner.calls).toEqual([]);
576
+ expect(fs.readFileSync(logPath, 'utf8')).toContain(
577
+ '[Recovery] Not resuming: all 2 recovery attempt(s) used.'
578
+ );
579
+ });
580
+
581
+ it('does not undo a deliberate --stop', () => {
582
+ const { store, record } = makeStore({
583
+ stopRequestedAt: '2026-10-01T10:04:00Z',
584
+ });
585
+ expect(recover(store, record).outcome.reason).toBe('stop-requested');
586
+ });
587
+
588
+ it('ignores ordinary failures and sessions without recovery', () => {
589
+ const { store, record } = makeStore();
590
+ expect(
591
+ recover(store, record, { exitCode: '1', oomKilled: 'false' }).outcome
592
+ .reason
593
+ ).toBe('not-killed');
594
+ const plain = makeStore({ onKillResume: null, recoveryCommand: null });
595
+ expect(recover(plain.store, plain.record).outcome.reason).toBe(
596
+ 'not-configured'
597
+ );
598
+ });
599
+
600
+ it('reports a failed docker start in the log', () => {
601
+ const { store, record, logPath } = makeStore();
602
+ const { outcome } = recover(store, record, {
603
+ runner: fakeRunner(UPDATED_HOST_CONFIG, { start: 'no such container' }),
604
+ });
605
+ expect(outcome.reason).toBe('resume-failed');
606
+ expect(fs.readFileSync(logPath, 'utf8')).toContain(
607
+ '[Recovery 1/2] Failed: docker start failed: no such container'
608
+ );
609
+ expect(store.get(record.uuid).options.recoveryAttempts).toBeUndefined();
610
+ });
611
+
612
+ it('marks a --stop so the watcher does not resume it', () => {
613
+ const { store, record } = makeStore();
614
+ const result = controlExecution(
615
+ store,
616
+ record.uuid,
617
+ ControlAction.STOP,
618
+ () => ok()
619
+ );
620
+ expect(result.success).toBe(true);
621
+ expect(store.get(record.uuid).options.stopRequestedAt).toBeTruthy();
622
+
623
+ const failed = makeStore();
624
+ controlExecution(
625
+ failed.store,
626
+ failed.record.uuid,
627
+ ControlAction.STOP,
628
+ () => ({
629
+ success: false,
630
+ stdout: '',
631
+ stderr: 'boom',
632
+ status: 1,
633
+ })
634
+ );
635
+ expect(
636
+ failed.store.get(failed.record.uuid).options.stopRequestedAt
637
+ ).toBeUndefined();
638
+ });
639
+ });
640
+
641
+ describe('issue #176: completion watcher hands kills to the recovery', () => {
642
+ it('builds the recovery branch only when requested', () => {
643
+ const plain = buildDetachedDockerCompletionScript(
644
+ 'box',
645
+ DOCKER_CONTAINER_CLEANUP_POLICY.KEEP,
646
+ '/tmp/run.log',
647
+ 'uuid-1'
648
+ );
649
+ expect(plain).not.toContain('execution-recovery');
650
+ const script = buildDetachedDockerCompletionScript(
651
+ 'box',
652
+ DOCKER_CONTAINER_CLEANUP_POLICY.KEEP,
653
+ '/tmp/run.log',
654
+ 'uuid-1',
655
+ { since: '2026-10-01T10:05:01.000Z', recoverOnKill: true }
656
+ );
657
+ expect(script).toContain("--since '2026-10-01T10:05:01.000Z'");
658
+ expect(script).toContain('execution-recovery.js');
659
+ expect(script).toContain('= 137 ]');
660
+ // Recovery runs before the cleanup / footer / finalize branch.
661
+ expect(script.indexOf('execution-recovery.js')).toBeLessThan(
662
+ script.indexOf('detached-finalize.js')
663
+ );
664
+ });
665
+
666
+ /**
667
+ * Run the real watcher against a fake docker that reports `state`, and
668
+ * `afterStart` once `docker start` has been called (default: `state`).
669
+ */
670
+ function runWatcher(state, options, afterStart = state) {
671
+ const dir = makeTempDir('watcher-176-');
672
+ const binDir = path.join(dir, 'bin');
673
+ const appFolder = path.join(dir, 'app');
674
+ fs.mkdirSync(binDir);
675
+ const logPath = path.join(dir, 'run.log');
676
+ const dockerPath = path.join(binDir, 'docker');
677
+ fs.writeFileSync(
678
+ dockerPath,
679
+ [
680
+ '#!/bin/sh',
681
+ `dir='${dir}'`,
682
+ 'echo "$*" >> "$dir/calls"',
683
+ 'case "$1" in',
684
+ ' inspect)',
685
+ ' case "$3" in',
686
+ ' *State.Running*) echo false ;;',
687
+ ' *State.Error*) echo "" ;;',
688
+ ' *HostConfig*) echo "{}" ;;',
689
+ ` *) if [ -e "$dir/started" ]; then echo '${afterStart}'; else echo '${state}'; fi ;;`,
690
+ ' esac ;;',
691
+ ' start) : > "$dir/started" ;;',
692
+ ' logs) echo work ;;',
693
+ ' wait) echo 137 ;;',
694
+ 'esac',
695
+ '',
696
+ ].join('\n')
697
+ );
698
+ fs.chmodSync(dockerPath, 0o755);
699
+ const store = new ExecutionStore({ appFolder, useLinks: false });
700
+ const record = new ExecutionRecord({
701
+ status: ExecutionStatus.EXECUTING,
702
+ command: 'solve',
703
+ logPath,
704
+ options: {
705
+ isolated: 'docker',
706
+ isolationMode: 'detached',
707
+ sessionName: 'box',
708
+ onKillResume: 1,
709
+ ...options,
710
+ },
711
+ });
712
+ store.save(record);
713
+ const script = buildDetachedDockerCompletionScript(
714
+ 'box',
715
+ DOCKER_CONTAINER_CLEANUP_POLICY.KEEP,
716
+ logPath,
717
+ record.uuid,
718
+ { recoverOnKill: true }
719
+ );
720
+ spawnSync('/bin/sh', ['-c', script], {
721
+ encoding: 'utf8',
722
+ env: {
723
+ ...process.env,
724
+ PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
725
+ START_APP_FOLDER: appFolder,
726
+ START_DISABLE_TRACKING: '',
727
+ },
728
+ timeout: 20000,
729
+ });
730
+ const read = () => ({
731
+ calls: fs.readFileSync(path.join(dir, 'calls'), 'utf8'),
732
+ log: fs.readFileSync(logPath, 'utf8'),
733
+ record: new ExecutionStore({ appFolder, useLinks: false }).get(
734
+ record.uuid
735
+ ),
736
+ });
737
+ // A resumed run is finalized by the detached watcher the recovery starts.
738
+ const deadline = Date.now() + 15000;
739
+ while (read().record.status !== ExecutionStatus.EXECUTED) {
740
+ if (Date.now() > deadline) {
741
+ break;
742
+ }
743
+ spawnSync('sleep', ['0.2']);
744
+ }
745
+ return read();
746
+ }
747
+
748
+ const KILLED =
749
+ '137 true 2026-10-01T10:00:00.000000000Z 2026-10-01T10:05:00.000000000Z';
750
+ const FAILED =
751
+ '1 false 2026-10-01T10:00:00.000000000Z 2026-10-01T10:05:00.000000000Z';
752
+
753
+ const DONE =
754
+ '0 false 2026-10-01T10:05:01.000000000Z 2026-10-01T10:06:00.000000000Z';
755
+
756
+ it('resumes a killed run through the real recovery entry point', () => {
757
+ if (needsPosixShell()) {
758
+ return;
759
+ }
760
+ // The watcher runs `execution-recovery.js` as the main module, which
761
+ // starts the next watcher; that used to throw on a half-built exports
762
+ // object, so the old watcher finalized the resumed run as exit 137.
763
+ const { calls, log, record } = runWatcher(KILLED, {}, DONE);
764
+ expect(calls).toContain('start box');
765
+ expect(calls).toContain('--since');
766
+ expect(log).toContain('[Recovery 1/1]');
767
+ expect(log).not.toContain('Exit Code: 137');
768
+ expect(record.status).toBe(ExecutionStatus.EXECUTED);
769
+ expect(record.exitCode).toBe(0);
770
+ expect(record.options.recoveryAttempts).toBe(1);
771
+ }, 30000);
772
+
773
+ it('finalizes an ordinary failure without resuming', () => {
774
+ if (needsPosixShell()) {
775
+ return;
776
+ }
777
+ const { calls, log, record } = runWatcher(FAILED, {});
778
+ expect(calls).not.toContain('start box');
779
+ expect(log).not.toContain('[Recovery');
780
+ expect(record.status).toBe(ExecutionStatus.EXECUTED);
781
+ expect(record.exitCode).toBe(1);
782
+ });
783
+
784
+ it('finalizes a stopped session instead of resuming it', () => {
785
+ if (needsPosixShell()) {
786
+ return;
787
+ }
788
+ const { calls, log, record } = runWatcher(KILLED, {
789
+ stopRequestedAt: '2026-10-01T10:04:00Z',
790
+ });
791
+ expect(calls).not.toContain('start box');
792
+ expect(log).toContain('[Recovery] Not resuming: the session was stopped');
793
+ expect(record.status).toBe(ExecutionStatus.EXECUTED);
794
+ expect(record.exitCode).toBe(137);
795
+ });
796
+
797
+ it('finalizes once the attempts are used up', () => {
798
+ if (needsPosixShell()) {
799
+ return;
800
+ }
801
+ const { calls, log, record } = runWatcher(KILLED, {
802
+ recoveryAttempts: 1,
803
+ });
804
+ expect(calls).not.toContain('start box');
805
+ expect(log).toContain('all 1 recovery attempt(s) used');
806
+ expect(record.status).toBe(ExecutionStatus.EXECUTED);
807
+ });
808
+ });
809
+
810
+ /**
811
+ * End-to-end check against a real Docker daemon: a limit applied with
812
+ * `docker update` must be present on the `-resume-1` container. Skipped when
813
+ * Docker cannot run Linux containers here.
814
+ */
815
+ describe('issue #176: real docker snapshot resume', () => {
816
+ const docker = (...args) => runCommand('docker', args);
817
+ const image = 'alpine:3.20';
818
+
819
+ function dockerUsable() {
820
+ if (process.platform === 'win32' || !docker('info').success) {
821
+ return false;
822
+ }
823
+ return (
824
+ docker('image', 'inspect', image).success || docker('pull', image).success
825
+ );
826
+ }
827
+
828
+ it('re-applies docker update limits to the -resume-1 container', async () => {
829
+ if (!dockerUsable()) {
830
+ console.log(' Skipping: docker with Linux containers is unavailable');
831
+ return;
832
+ }
833
+ const name = `start-command-176-${process.pid}`;
834
+ const resumed = `${name}-resume-1`;
835
+ const snapshot = `start-command-resume/${name}:1`;
836
+ try {
837
+ expect(
838
+ docker('run', '-d', '--name', name, image, 'sleep', '1').success
839
+ ).toBe(true);
840
+ expect(docker('update', '--pids-limit', '64', name).success).toBe(true);
841
+ // Memory limits need cgroup support that nested daemons may lack.
842
+ const memory = docker(
843
+ 'update',
844
+ '--memory',
845
+ '256m',
846
+ '--memory-swap',
847
+ '256m',
848
+ name
849
+ ).success;
850
+ docker('wait', name);
851
+
852
+ const dir = makeTempDir('real-176-');
853
+ const store = new ExecutionStore({
854
+ appFolder: path.join(dir, 'app'),
855
+ useLinks: false,
856
+ });
857
+ const record = new ExecutionRecord({
858
+ status: ExecutionStatus.EXECUTED,
859
+ command: 'sleep 1',
860
+ logPath: path.join(dir, 'run.log'),
861
+ options: {
862
+ isolated: 'docker',
863
+ isolationMode: 'detached',
864
+ sessionName: name,
865
+ image,
866
+ },
867
+ });
868
+ store.save(record);
869
+
870
+ const result = await resumeExecution(store, record.uuid, {
871
+ command: 'sleep 30',
872
+ startWatcher: () => {},
873
+ });
874
+ expect(result.success).toBe(true);
875
+
876
+ const inspected = docker(
877
+ 'inspect',
878
+ '--format',
879
+ '{{json .HostConfig}}',
880
+ resumed
881
+ );
882
+ expect(inspected.success).toBe(true);
883
+ const hostConfig = JSON.parse(inspected.stdout);
884
+ expect(hostConfig.PidsLimit).toBe(64);
885
+ if (memory) {
886
+ expect(hostConfig.Memory).toBe(268435456);
887
+ expect(hostConfig.MemorySwap).toBe(268435456);
888
+ }
889
+ expect(store.get(record.uuid).options.resourceLimits).toContain(
890
+ '--pids-limit=64'
891
+ );
892
+ } finally {
893
+ docker('rm', '-f', name, resumed);
894
+ docker('rmi', '-f', snapshot);
895
+ }
896
+ }, 120000);
897
+ });