start-command 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +2 -2
- package/src/bin/cli.js +2 -1
- package/src/lib/args-parser.js +10 -0
- package/src/lib/command-stream.js +4 -2
- package/src/lib/detached-finalize.js +31 -7
- package/src/lib/docker-cleanup.js +55 -11
- package/src/lib/docker-post-mortem.js +93 -0
- package/src/lib/docker-recovery-options.js +84 -0
- package/src/lib/docker-resource-limits.js +211 -0
- package/src/lib/docker-runtime-args.js +141 -0
- package/src/lib/execution-control.js +39 -0
- package/src/lib/execution-recovery.js +350 -0
- package/src/lib/execution-resume.js +68 -4
- package/src/lib/exit-reason.js +24 -0
- package/src/lib/isolation-log-utils.js +9 -5
- package/src/lib/isolation.js +20 -92
- package/src/lib/spawn-helpers.js +4 -2
- package/src/lib/usage.js +3 -0
- package/test/docker-runtime-options.js +6 -0
- package/test/regression-170.js +4 -1
- package/test/regression-174.js +397 -0
- package/test/regression-176.js +897 -0
package/src/lib/isolation.js
CHANGED
|
@@ -505,96 +505,12 @@ const {
|
|
|
505
505
|
canRunLinuxDockerImages,
|
|
506
506
|
} = require('./docker-utils');
|
|
507
507
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
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/spawn-helpers.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
const { spawn } = require('child_process');
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
|
+
const { resolveChildExitCode } = require('./exit-reason');
|
|
15
16
|
|
|
16
17
|
function ensureParentDirectory(filePath) {
|
|
17
18
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -282,8 +283,9 @@ function runWithNodeSpawn(options) {
|
|
|
282
283
|
// The 'close' event fires after all stdio streams have been closed, ensuring
|
|
283
284
|
// all stdout/stderr data has been received. The 'exit' event can fire before
|
|
284
285
|
// buffered data is received, causing output loss on macOS (Issue #57).
|
|
285
|
-
child.on('close', (code) => {
|
|
286
|
-
|
|
286
|
+
child.on('close', (code, signal) => {
|
|
287
|
+
// A signal death reports `code === null`: never record it as exit 0.
|
|
288
|
+
const exitCode = resolveChildExitCode(code, signal);
|
|
287
289
|
const durationMs = Date.now() - startTimeMs;
|
|
288
290
|
const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
|
|
289
291
|
|
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
|
});
|
package/test/regression-170.js
CHANGED
|
@@ -301,7 +301,10 @@ describe('issue #170.1: the detached watcher persists the terminal state', () =>
|
|
|
301
301
|
|
|
302
302
|
const stored = store.get(record.uuid);
|
|
303
303
|
expect(stored.status).toBe(ExecutionStatus.EXECUTED);
|
|
304
|
-
|
|
304
|
+
// `ExitCode=0` next to a zero `FinishedAt` is Docker's zero value, not an
|
|
305
|
+
// observed success (issue #174).
|
|
306
|
+
expect(stored.exitCode).toBe(-1);
|
|
307
|
+
expect(stored.exitReason).toBe('watcher-lost-container');
|
|
305
308
|
expect(stored.endTimeSource).toBe('observed-at');
|
|
306
309
|
expect(stored.observedAt).toBe(stored.endTime);
|
|
307
310
|
expect(new Date(stored.endTime).getUTCFullYear()).toBeGreaterThan(2000);
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #174: the detached docker watcher `docker rm -f`ed
|
|
3
|
+
* a still-running container when `docker logs -f` failed (ENOSPC) and
|
|
4
|
+
* finalized it as executed / exit 0.
|
|
5
|
+
*
|
|
6
|
+
* - `docker logs -f C >> LOG` also returns when its own write to LOG fails,
|
|
7
|
+
* and on a dockerd restart; the watcher must wait for the real exit;
|
|
8
|
+
* - a container that is still running must never be removed, get an
|
|
9
|
+
* `Exit Code:` footer, or be finalized;
|
|
10
|
+
* - `ExitCode=0` next to a zero `FinishedAt` is Docker's zero value, never an
|
|
11
|
+
* observed exit 0;
|
|
12
|
+
* - a child killed by a signal (`code === null`) must never become exit 0.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { describe, it, expect, afterEach } = require('bun:test');
|
|
16
|
+
const { spawnSync } = require('child_process');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
ExecutionStore,
|
|
23
|
+
ExecutionRecord,
|
|
24
|
+
ExecutionStatus,
|
|
25
|
+
} = require('../src/lib/execution-store');
|
|
26
|
+
const {
|
|
27
|
+
buildDetachedDockerCompletionScript,
|
|
28
|
+
DOCKER_CONTAINER_CLEANUP_POLICY,
|
|
29
|
+
} = require('../src/lib/docker-cleanup');
|
|
30
|
+
const {
|
|
31
|
+
LOG_CAPTURE_STOPPED_NOTE,
|
|
32
|
+
STILL_RUNNING_NOTE,
|
|
33
|
+
} = require('../src/lib/docker-post-mortem');
|
|
34
|
+
const {
|
|
35
|
+
WATCHER_LOST_CONTAINER,
|
|
36
|
+
finalizeDetachedExecution,
|
|
37
|
+
} = require('../src/lib/detached-finalize');
|
|
38
|
+
const { resolveChildExitCode } = require('../src/lib/exit-reason');
|
|
39
|
+
const { runWithNodeSpawn } = require('../src/lib/spawn-helpers');
|
|
40
|
+
|
|
41
|
+
const tempDirs = [];
|
|
42
|
+
|
|
43
|
+
function makeTempDir(prefix) {
|
|
44
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
45
|
+
tempDirs.push(dir);
|
|
46
|
+
return dir;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
while (tempDirs.length > 0) {
|
|
51
|
+
const dir = tempDirs.pop();
|
|
52
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function detachedDockerRecord(logPath) {
|
|
57
|
+
return new ExecutionRecord({
|
|
58
|
+
status: ExecutionStatus.EXECUTING,
|
|
59
|
+
command: 'work',
|
|
60
|
+
logPath,
|
|
61
|
+
options: {
|
|
62
|
+
isolated: 'docker',
|
|
63
|
+
isolationMode: 'detached',
|
|
64
|
+
sessionName: 'start-command-174',
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The watcher body is a POSIX `sh` script; Windows has no `/bin/sh`.
|
|
70
|
+
const needsPosixShell = () => {
|
|
71
|
+
if (process.platform !== 'win32') {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
console.log(' Skipping: the watcher needs a POSIX /bin/sh');
|
|
75
|
+
return true;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run the real watcher script against a scripted fake `docker`.
|
|
80
|
+
*
|
|
81
|
+
* `running` lists the successive answers to `docker inspect -f
|
|
82
|
+
* '{{.State.Running}}'` (`true`, `false`, or `fail` for an inspect that
|
|
83
|
+
* errors, as during a dockerd restart); the last answer sticks. `docker logs`
|
|
84
|
+
* writes one line and fails, exactly like `docker logs -f C >> LOG` whose write
|
|
85
|
+
* hit ENOSPC. Every call is recorded, and `rm` records whether the container was
|
|
86
|
+
* running at that moment.
|
|
87
|
+
*/
|
|
88
|
+
function runWatcher({ policy, running, state, withLog = true }) {
|
|
89
|
+
const dir = makeTempDir('watcher-174-');
|
|
90
|
+
const binDir = path.join(dir, 'bin');
|
|
91
|
+
const appFolder = path.join(dir, 'app');
|
|
92
|
+
fs.mkdirSync(binDir);
|
|
93
|
+
const logPath = path.join(dir, 'run.log');
|
|
94
|
+
fs.writeFileSync(path.join(dir, 'running'), `${running.join('\n')}\n`);
|
|
95
|
+
fs.writeFileSync(path.join(dir, 'state'), `${state}\n`);
|
|
96
|
+
const calls = path.join(dir, 'calls');
|
|
97
|
+
const dockerPath = path.join(binDir, 'docker');
|
|
98
|
+
fs.writeFileSync(
|
|
99
|
+
dockerPath,
|
|
100
|
+
[
|
|
101
|
+
'#!/bin/sh',
|
|
102
|
+
`dir='${dir}'`,
|
|
103
|
+
'case "$1" in',
|
|
104
|
+
' inspect)',
|
|
105
|
+
' case "$3" in',
|
|
106
|
+
' *State.Running*)',
|
|
107
|
+
' answer=$(head -n 1 "$dir/running")',
|
|
108
|
+
' if [ "$(wc -l < "$dir/running")" -gt 1 ]; then',
|
|
109
|
+
' tail -n +2 "$dir/running" > "$dir/running.next" && mv "$dir/running.next" "$dir/running"',
|
|
110
|
+
' fi',
|
|
111
|
+
' echo "inspect-running $answer" >> "$dir/calls"',
|
|
112
|
+
' [ "$answer" = fail ] && exit 1',
|
|
113
|
+
' echo "$answer" > "$dir/current"',
|
|
114
|
+
' echo "$answer" ;;',
|
|
115
|
+
' *State.Error*) echo "" ;;',
|
|
116
|
+
' *) echo "inspect-state" >> "$dir/calls"; cat "$dir/state" ;;',
|
|
117
|
+
' esac ;;',
|
|
118
|
+
' logs) echo logs >> "$dir/calls"; echo work; exit 1 ;;',
|
|
119
|
+
' wait) echo wait >> "$dir/calls"; echo 0 ;;',
|
|
120
|
+
' rm) echo "rm running=$(cat "$dir/current" 2>/dev/null)" >> "$dir/calls" ;;',
|
|
121
|
+
'esac',
|
|
122
|
+
'',
|
|
123
|
+
].join('\n'),
|
|
124
|
+
'utf8'
|
|
125
|
+
);
|
|
126
|
+
fs.chmodSync(dockerPath, 0o755);
|
|
127
|
+
|
|
128
|
+
const store = new ExecutionStore({ appFolder, useLinks: false });
|
|
129
|
+
const record = detachedDockerRecord(withLog ? logPath : null);
|
|
130
|
+
store.save(record);
|
|
131
|
+
|
|
132
|
+
const script = buildDetachedDockerCompletionScript(
|
|
133
|
+
'demo',
|
|
134
|
+
policy,
|
|
135
|
+
withLog ? logPath : null,
|
|
136
|
+
record.uuid
|
|
137
|
+
);
|
|
138
|
+
const result = spawnSync('/bin/sh', ['-c', script], {
|
|
139
|
+
encoding: 'utf8',
|
|
140
|
+
env: {
|
|
141
|
+
...process.env,
|
|
142
|
+
PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
|
|
143
|
+
START_APP_FOLDER: appFolder,
|
|
144
|
+
START_DISABLE_TRACKING: '',
|
|
145
|
+
},
|
|
146
|
+
timeout: 20000,
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
status: result.status,
|
|
150
|
+
calls: fs.readFileSync(calls, 'utf8').trim().split('\n'),
|
|
151
|
+
log: fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '',
|
|
152
|
+
record: new ExecutionStore({ appFolder, useLinks: false }).get(record.uuid),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const FINISHED_7 =
|
|
157
|
+
'7 false 2026-09-27T10:00:00.000000000Z 2026-09-27T10:00:05.000000000Z';
|
|
158
|
+
const FINISHED_0 =
|
|
159
|
+
'0 false 2026-09-27T10:00:00.000000000Z 2026-09-27T10:00:05.000000000Z';
|
|
160
|
+
const NOT_FINISHED = '0 false 2026-09-27T10:00:00Z 0001-01-01T00:00:00Z';
|
|
161
|
+
|
|
162
|
+
describe('issue #174: docker logs -f returning is not the container exiting', () => {
|
|
163
|
+
it('waits for the real exit after docker logs -f fails (ENOSPC)', () => {
|
|
164
|
+
if (needsPosixShell()) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const run = runWatcher({
|
|
168
|
+
policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
|
|
169
|
+
running: ['true', 'true', 'false'],
|
|
170
|
+
state: FINISHED_7,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(run.status).toBe(0);
|
|
174
|
+
expect(run.calls).toContain('wait');
|
|
175
|
+
expect(run.calls.indexOf('wait')).toBeGreaterThan(
|
|
176
|
+
run.calls.indexOf('logs')
|
|
177
|
+
);
|
|
178
|
+
expect(run.calls.indexOf('inspect-state')).toBeGreaterThan(
|
|
179
|
+
run.calls.lastIndexOf('wait')
|
|
180
|
+
);
|
|
181
|
+
expect(run.log).toContain(LOG_CAPTURE_STOPPED_NOTE);
|
|
182
|
+
expect(run.log).toContain('Exit Code: 7');
|
|
183
|
+
expect(run.log).not.toContain('Exit Code: 0');
|
|
184
|
+
expect(run.record.status).toBe(ExecutionStatus.EXECUTED);
|
|
185
|
+
expect(run.record.exitCode).toBe(7);
|
|
186
|
+
expect(run.record.endTimeSource).toBe('docker-finished-at');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('only removes the container once it has really exited', () => {
|
|
190
|
+
if (needsPosixShell()) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const run = runWatcher({
|
|
194
|
+
policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
|
|
195
|
+
running: ['true', 'true', 'false'],
|
|
196
|
+
state: FINISHED_0,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(run.calls).toContain('rm running=false');
|
|
200
|
+
expect(run.calls).not.toContain('rm running=true');
|
|
201
|
+
expect(run.log).toContain('Exit Code: 0');
|
|
202
|
+
expect(run.record.exitCode).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('keeps waiting across a failed docker inspect (dockerd restart)', () => {
|
|
206
|
+
if (needsPosixShell()) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const run = runWatcher({
|
|
210
|
+
policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
|
|
211
|
+
running: ['true', 'true', 'true', 'false'],
|
|
212
|
+
state: FINISHED_7,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(run.calls.filter((call) => call === 'wait').length).toBe(2);
|
|
216
|
+
expect(run.record.exitCode).toBe(7);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('applies the same wait to the no-log watcher', () => {
|
|
220
|
+
if (needsPosixShell()) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const run = runWatcher({
|
|
224
|
+
policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
|
|
225
|
+
running: ['true', 'false'],
|
|
226
|
+
state: FINISHED_7,
|
|
227
|
+
withLog: false,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(run.calls.filter((call) => call === 'wait').length).toBe(2);
|
|
231
|
+
expect(run.calls).toContain('rm running=false');
|
|
232
|
+
expect(run.calls).not.toContain('rm running=true');
|
|
233
|
+
expect(run.record.exitCode).toBe(7);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('issue #174: a container that is still running is left alone', () => {
|
|
238
|
+
it('never removes, footers or finalizes a running container', () => {
|
|
239
|
+
if (needsPosixShell()) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// logs -f fails -> running; the wait loop's inspect fails (daemon down) ->
|
|
243
|
+
// the loop ends; once the daemon is back the container is still running.
|
|
244
|
+
const run = runWatcher({
|
|
245
|
+
policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
|
|
246
|
+
running: ['true', 'fail', 'true'],
|
|
247
|
+
state: NOT_FINISHED,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(run.calls.some((call) => call.startsWith('rm'))).toBe(false);
|
|
251
|
+
expect(run.log).toContain('Container still running: demo');
|
|
252
|
+
expect(run.log).toContain(STILL_RUNNING_NOTE);
|
|
253
|
+
expect(run.log).not.toContain('Exit Code:');
|
|
254
|
+
expect(run.record.status).toBe(ExecutionStatus.EXECUTING);
|
|
255
|
+
expect(run.record.exitCode).toBeNull();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('never removes a running container without a log either', () => {
|
|
259
|
+
if (needsPosixShell()) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const run = runWatcher({
|
|
263
|
+
policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
|
|
264
|
+
running: ['fail', 'true'],
|
|
265
|
+
state: NOT_FINISHED,
|
|
266
|
+
withLog: false,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(run.calls.some((call) => call.startsWith('rm'))).toBe(false);
|
|
270
|
+
expect(run.record.status).toBe(ExecutionStatus.EXECUTING);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('issue #174: a zero FinishedAt is never an exit 0', () => {
|
|
275
|
+
it('keeps the container and records no success in the watcher', () => {
|
|
276
|
+
if (needsPosixShell()) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const run = runWatcher({
|
|
280
|
+
policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
|
|
281
|
+
running: ['false'],
|
|
282
|
+
state: NOT_FINISHED,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(run.calls.some((call) => call.startsWith('rm'))).toBe(false);
|
|
286
|
+
expect(run.log).toContain('Exit Code: -1');
|
|
287
|
+
expect(run.log).not.toContain('Exit Code: 0');
|
|
288
|
+
expect(run.record.status).toBe(ExecutionStatus.EXECUTED);
|
|
289
|
+
expect(run.record.exitCode).toBe(-1);
|
|
290
|
+
expect(run.record.exitReason).toBe(WATCHER_LOST_CONTAINER);
|
|
291
|
+
expect(run.record.endTimeSource).toBe('observed-at');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('refuses to finalize a record whose container is still running', () => {
|
|
295
|
+
const appFolder = makeTempDir('finalize-174-running-');
|
|
296
|
+
const store = new ExecutionStore({ appFolder, useLinks: false });
|
|
297
|
+
const record = detachedDockerRecord(null);
|
|
298
|
+
store.save(record);
|
|
299
|
+
|
|
300
|
+
const outcome = finalizeDetachedExecution({
|
|
301
|
+
store,
|
|
302
|
+
executionId: record.uuid,
|
|
303
|
+
exitCode: '0',
|
|
304
|
+
oomKilled: 'false',
|
|
305
|
+
startedAt: '2026-09-27T10:00:00Z',
|
|
306
|
+
finishedAt: '0001-01-01T00:00:00Z',
|
|
307
|
+
containerError: '',
|
|
308
|
+
running: 'true',
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(outcome).toEqual({ updated: false, reason: 'still-running' });
|
|
312
|
+
expect(store.get(record.uuid).status).toBe(ExecutionStatus.EXECUTING);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('does not trust exit 0 without a FinishedAt', () => {
|
|
316
|
+
const appFolder = makeTempDir('finalize-174-zero-');
|
|
317
|
+
const store = new ExecutionStore({ appFolder, useLinks: false });
|
|
318
|
+
const record = detachedDockerRecord(null);
|
|
319
|
+
store.save(record);
|
|
320
|
+
|
|
321
|
+
finalizeDetachedExecution({
|
|
322
|
+
store,
|
|
323
|
+
executionId: record.uuid,
|
|
324
|
+
exitCode: '0',
|
|
325
|
+
oomKilled: 'false',
|
|
326
|
+
startedAt: '2026-09-27T10:00:00Z',
|
|
327
|
+
finishedAt: '0001-01-01T00:00:00Z',
|
|
328
|
+
running: 'false',
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const stored = store.get(record.uuid);
|
|
332
|
+
expect(stored.exitCode).toBe(-1);
|
|
333
|
+
expect(stored.exitReason).toBe(WATCHER_LOST_CONTAINER);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('still records a real exit 0', () => {
|
|
337
|
+
const appFolder = makeTempDir('finalize-174-real-zero-');
|
|
338
|
+
const store = new ExecutionStore({ appFolder, useLinks: false });
|
|
339
|
+
const record = detachedDockerRecord(null);
|
|
340
|
+
store.save(record);
|
|
341
|
+
|
|
342
|
+
finalizeDetachedExecution({
|
|
343
|
+
store,
|
|
344
|
+
executionId: record.uuid,
|
|
345
|
+
exitCode: '0',
|
|
346
|
+
oomKilled: 'false',
|
|
347
|
+
startedAt: '2026-09-27T10:00:00Z',
|
|
348
|
+
finishedAt: '2026-09-27T10:00:05Z',
|
|
349
|
+
running: 'false',
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const stored = store.get(record.uuid);
|
|
353
|
+
expect(stored.exitCode).toBe(0);
|
|
354
|
+
expect(stored.exitReason).toBeUndefined();
|
|
355
|
+
expect(stored.endTimeSource).toBe('docker-finished-at');
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe('issue #174: a signal death is never exit 0', () => {
|
|
360
|
+
it('maps a null code with a signal to 128+n', () => {
|
|
361
|
+
expect(resolveChildExitCode(null, 'SIGKILL')).toBe(137);
|
|
362
|
+
expect(resolveChildExitCode(null, 'SIGTERM')).toBe(143);
|
|
363
|
+
expect(resolveChildExitCode(null, 'SIGINT')).toBe(130);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('maps a missing code without a signal to a failure', () => {
|
|
367
|
+
expect(resolveChildExitCode(null)).toBe(1);
|
|
368
|
+
expect(resolveChildExitCode(undefined)).toBe(1);
|
|
369
|
+
expect(resolveChildExitCode(null, 'SIGNOTREAL')).toBe(1);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('keeps real exit codes, including 0', () => {
|
|
373
|
+
expect(resolveChildExitCode(0)).toBe(0);
|
|
374
|
+
expect(resolveChildExitCode(3, null)).toBe(3);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('records a self-killed child as 137 in the node spawn path', async () => {
|
|
378
|
+
if (needsPosixShell()) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const dir = makeTempDir('spawn-174-');
|
|
382
|
+
const logFilePath = path.join(dir, 'run.log');
|
|
383
|
+
const exitCode = await new Promise((resolve) => {
|
|
384
|
+
runWithNodeSpawn({
|
|
385
|
+
shell: '/bin/sh',
|
|
386
|
+
shellArgs: ['-c', 'kill -KILL $$'],
|
|
387
|
+
logFilePath,
|
|
388
|
+
startTimeMs: Date.now(),
|
|
389
|
+
onComplete: (code) => resolve(code),
|
|
390
|
+
onError: () => resolve('error'),
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
expect(exitCode).toBe(137);
|
|
395
|
+
expect(fs.readFileSync(logFilePath, 'utf8')).toContain('Exit Code: 137');
|
|
396
|
+
});
|
|
397
|
+
});
|