start-command 0.29.0 → 0.29.2
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 +14 -0
- package/package.json +1 -1
- package/src/lib/docker-utils.js +133 -6
- package/src/lib/isolation.js +3 -1
- package/src/lib/status-formatter.js +105 -15
- package/test/regression-138.js +199 -0
- package/test/session-name-status.js +243 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.29.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 8518034: Record the docker image-preparation phase in the session log (issue #138). When a `--isolated docker` run needs to `docker pull` an image, the pull output is now teed into the session-log file (`/tmp/start-command/logs/isolation/docker/<uuid>.log`) in real time and bracketed with `Preparing image <name>…` / `Image ready (<duration>)` markers (or `Image preparation failed` on error). Previously the minutes spent pulling a (potentially multi-GB) image left no trace in the log, so operators tailing `$ --upload-log <uuid>` during startup saw only the header. The single session-log file is now a gap-free record of the run, including the longest, most failure-prone phase.
|
|
8
|
+
|
|
9
|
+
## 0.29.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Fix detached docker `--status`/`--list` reporting a terminal status (`executed`) with the `-1` sentinel while the container is still running (or not visible yet on a slow Docker-in-Docker host). `isDetachedSessionAlive()` now treats a failed `docker inspect` as "unknown" (returns `null`) instead of "stopped", so a session whose container has not appeared yet stays `executing` rather than being marked finished. When a container has genuinely stopped, `enrichDetachedStatus()` resolves the real exit code from the `Exit Code:` log footer and then `docker inspect .State.ExitCode`, only falling back to `-1` when no real code can be obtained.
|
|
14
|
+
|
|
15
|
+
Fix `--status` for detached executions resurrecting a completed (killed) record. `enrichDetachedStatus()` no longer flips an already-`executed` record back to `executing` (and nulls its exit code) just because `screen -ls`/`tmux`/`docker` still lists a same-named session — a lingering shell can outlive a SIGKILLed command (e.g. OOM, exit 137). The recorded exit code and the `Exit Code:` log footer that `start` itself writes are now treated as authoritative; the record only flips to `executing` when there is no recorded exit code and no terminal footer in the log.
|
|
16
|
+
|
|
3
17
|
## 0.29.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/package.json
CHANGED
package/src/lib/docker-utils.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
10
12
|
const { execSync, spawnSync } = require('child_process');
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -102,31 +104,141 @@ function dockerImageExists(image) {
|
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Run `docker pull <image>` while teeing its output to the session log file.
|
|
109
|
+
*
|
|
110
|
+
* When a logPath is given (and tee is available, i.e. non-Windows), the pull
|
|
111
|
+
* output is streamed to BOTH the console and the log file in real time so the
|
|
112
|
+
* image-preparation phase is captured in the single session log (issue #138).
|
|
113
|
+
* docker's own exit code is recovered via a sentinel status file because the
|
|
114
|
+
* exit status of a `cmd | tee` pipeline reflects tee, not docker.
|
|
115
|
+
*
|
|
116
|
+
* Without a logPath, it falls back to the previous behavior: inherited stdio
|
|
117
|
+
* for real-time console output, with no log capture. On Windows (no portable
|
|
118
|
+
* shell `tee`) but with a logPath, the pull output is captured, echoed to the
|
|
119
|
+
* console, and appended to the log after the pull so the session log still
|
|
120
|
+
* records the image-preparation phase (issue #138) — just not streamed live.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} image - Docker image to pull
|
|
123
|
+
* @param {string|null} logPath - Session log file to append pull output to
|
|
124
|
+
* @returns {{status: number, error?: Error}} Spawn result (status = docker exit code)
|
|
125
|
+
*/
|
|
126
|
+
function runDockerPull(image, logPath) {
|
|
127
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
128
|
+
|
|
129
|
+
// Without a log target, keep the original inherited-stdio behavior for
|
|
130
|
+
// fancy real-time console output.
|
|
131
|
+
if (!logPath) {
|
|
132
|
+
return spawnSync('docker', ['pull', image], {
|
|
133
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Windows has no portable shell `tee`, so capture the pull output, echo it to
|
|
138
|
+
// the console, and append it to the log so the image-preparation phase is
|
|
139
|
+
// still recorded in the session log (issue #138), just not streamed live.
|
|
140
|
+
if (process.platform === 'win32') {
|
|
141
|
+
const { appendLogFile } = require('./isolation-log-utils');
|
|
142
|
+
const result = spawnSync('docker', ['pull', image], {
|
|
143
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
144
|
+
encoding: 'utf8',
|
|
145
|
+
});
|
|
146
|
+
const combined = `${result.stdout || ''}${result.stderr || ''}`;
|
|
147
|
+
if (combined) {
|
|
148
|
+
process.stdout.write(combined);
|
|
149
|
+
try {
|
|
150
|
+
appendLogFile(
|
|
151
|
+
logPath,
|
|
152
|
+
combined.endsWith('\n') ? combined : `${combined}\n`
|
|
153
|
+
);
|
|
154
|
+
} catch {
|
|
155
|
+
// best-effort: log capture must never break the pull
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Tee docker pull output to both the console and the log file. docker writes
|
|
162
|
+
// to a pipe here, so it emits plain progress lines (ideal for a log). The
|
|
163
|
+
// sentinel file captures docker's real exit code (the pipeline's own exit
|
|
164
|
+
// status would be tee's).
|
|
165
|
+
const statusFile = path.join(
|
|
166
|
+
os.tmpdir(),
|
|
167
|
+
`start-docker-pull-${process.pid}-${Date.now()}.status`
|
|
168
|
+
);
|
|
169
|
+
try {
|
|
170
|
+
const pipeline =
|
|
171
|
+
`{ docker pull ${shellQuote(image)} 2>&1; echo $? > ${shellQuote(statusFile)}; } ` +
|
|
172
|
+
`| tee -a ${shellQuote(logPath)}`;
|
|
173
|
+
const result = spawnSync('sh', ['-c', pipeline], {
|
|
174
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
175
|
+
});
|
|
176
|
+
if (result.error) {
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
let status = result.status;
|
|
180
|
+
try {
|
|
181
|
+
const recorded = fs.readFileSync(statusFile, 'utf8').trim();
|
|
182
|
+
if (recorded !== '') {
|
|
183
|
+
status = parseInt(recorded, 10);
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// Sentinel missing (e.g. sh/tee failed before docker ran); keep pipeline status.
|
|
187
|
+
}
|
|
188
|
+
return { ...result, status };
|
|
189
|
+
} finally {
|
|
190
|
+
try {
|
|
191
|
+
fs.unlinkSync(statusFile);
|
|
192
|
+
} catch {
|
|
193
|
+
// best-effort cleanup
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
105
198
|
/**
|
|
106
199
|
* Pull a Docker image with output streaming
|
|
107
|
-
* Displays the pull operation as a virtual command in the timeline
|
|
200
|
+
* Displays the pull operation as a virtual command in the timeline.
|
|
201
|
+
*
|
|
202
|
+
* When a logPath is provided, the image-preparation phase (the `docker pull`)
|
|
203
|
+
* is also recorded in the session log so the single log file is a gap-free
|
|
204
|
+
* record of everything that ran (issue #138): a `Preparing image …` marker with
|
|
205
|
+
* a timestamp is written before the pull, the pull output is teed into the log,
|
|
206
|
+
* and an `Image ready (<duration>)` marker is written afterwards.
|
|
207
|
+
*
|
|
108
208
|
* @param {string} image - Docker image to pull
|
|
209
|
+
* @param {string|null} logPath - Optional session log file to append output to
|
|
109
210
|
* @returns {{success: boolean, output: string}} Pull result
|
|
110
211
|
*/
|
|
111
|
-
function dockerPullImage(image) {
|
|
212
|
+
function dockerPullImage(image, logPath = null) {
|
|
112
213
|
const {
|
|
113
214
|
createVirtualCommandBlock,
|
|
114
215
|
createVirtualCommandResult,
|
|
115
216
|
createTimelineSeparator,
|
|
116
217
|
} = require('./output-blocks');
|
|
218
|
+
const { appendLogFile, getTimestamp } = require('./isolation-log-utils');
|
|
117
219
|
|
|
118
220
|
// Print the virtual command line followed by empty line for visual separation
|
|
119
221
|
console.log(createVirtualCommandBlock(`docker pull ${image}`));
|
|
120
222
|
console.log();
|
|
121
223
|
|
|
224
|
+
// Record the start of the image-preparation phase in the session log so
|
|
225
|
+
// operators tailing the log see progress instead of a header-only file.
|
|
226
|
+
const prepStartMs = Date.now();
|
|
227
|
+
if (logPath) {
|
|
228
|
+
appendLogFile(
|
|
229
|
+
logPath,
|
|
230
|
+
`$ docker pull ${image}\nPreparing image ${image}… (${getTimestamp()})\n`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
122
234
|
let output = '';
|
|
123
235
|
let success;
|
|
124
236
|
|
|
125
237
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
238
|
+
const result = runDockerPull(image, logPath);
|
|
239
|
+
if (result.error) {
|
|
240
|
+
throw result.error;
|
|
241
|
+
}
|
|
130
242
|
|
|
131
243
|
success = result.status === 0;
|
|
132
244
|
|
|
@@ -140,6 +252,21 @@ function dockerPullImage(image) {
|
|
|
140
252
|
console.error(`Failed to run docker pull: ${err.message}`);
|
|
141
253
|
output = err.message;
|
|
142
254
|
success = false;
|
|
255
|
+
if (logPath) {
|
|
256
|
+
appendLogFile(logPath, `Failed to run docker pull: ${err.message}\n`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Record the end of the image-preparation phase with elapsed duration so the
|
|
261
|
+
// prep time is visible even when full progress is unavailable (issue #138).
|
|
262
|
+
if (logPath) {
|
|
263
|
+
const durationSec = ((Date.now() - prepStartMs) / 1000).toFixed(1);
|
|
264
|
+
appendLogFile(
|
|
265
|
+
logPath,
|
|
266
|
+
success
|
|
267
|
+
? `Image ready (${durationSec}s)\n`
|
|
268
|
+
: `Image preparation failed (${durationSec}s)\n`
|
|
269
|
+
);
|
|
143
270
|
}
|
|
144
271
|
|
|
145
272
|
// Print empty line before result marker for visual separation (issue #73)
|
package/src/lib/isolation.js
CHANGED
|
@@ -588,7 +588,9 @@ function runInDocker(command, options = {}) {
|
|
|
588
588
|
|
|
589
589
|
const containerName = options.session || generateSessionName('docker');
|
|
590
590
|
if (!dockerImageExists(options.image)) {
|
|
591
|
-
|
|
591
|
+
// Pass logPath so the image-preparation phase (docker pull) is recorded in
|
|
592
|
+
// the session log, keeping it a gap-free record of the run (issue #138).
|
|
593
|
+
const pullResult = dockerPullImage(options.image, options.logPath);
|
|
592
594
|
if (!pullResult.success) {
|
|
593
595
|
return Promise.resolve({
|
|
594
596
|
success: false,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - Text: Human-readable text format
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
const { execSync } = require('child_process');
|
|
10
|
+
const { execSync, spawnSync } = require('child_process');
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const {
|
|
13
13
|
escapeForLinksNotation,
|
|
@@ -15,6 +15,58 @@ const {
|
|
|
15
15
|
} = require('./output-blocks');
|
|
16
16
|
const { collectProcessIds } = require('./execution-control');
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Inspect the live state of a detached docker container by name.
|
|
20
|
+
*
|
|
21
|
+
* Distinguishes three outcomes that matter for status reporting:
|
|
22
|
+
* - the container exists and is running,
|
|
23
|
+
* - the container exists but has stopped (with a real exit code), and
|
|
24
|
+
* - the container cannot be inspected at all.
|
|
25
|
+
*
|
|
26
|
+
* The last case is crucial on slow Docker-in-Docker hosts (issue #136): right
|
|
27
|
+
* after `docker run -d` returns, `docker inspect <name>` can transiently fail
|
|
28
|
+
* because the container is not visible yet. A failed inspect must NOT be read
|
|
29
|
+
* as "stopped"; it means "unknown", so callers can keep the session running
|
|
30
|
+
* instead of fabricating a terminal `-1` result.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} sessionName - Container name
|
|
33
|
+
* @returns {{running: boolean, exitCode: number|null}|null} State, or null when
|
|
34
|
+
* the container cannot be inspected (not found yet, removed, or docker error)
|
|
35
|
+
*/
|
|
36
|
+
function inspectDockerState(sessionName) {
|
|
37
|
+
const result = spawnSync(
|
|
38
|
+
'docker',
|
|
39
|
+
['inspect', '-f', '{{.State.Running}} {{.State.ExitCode}}', sessionName],
|
|
40
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
41
|
+
);
|
|
42
|
+
if (result.error || result.status !== 0 || !result.stdout) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const [runningRaw, exitRaw] = result.stdout.trim().split(/\s+/);
|
|
46
|
+
const exitCode = Number.parseInt(exitRaw, 10);
|
|
47
|
+
return {
|
|
48
|
+
running: runningRaw === 'true',
|
|
49
|
+
exitCode: Number.isFinite(exitCode) ? exitCode : null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Best-effort terminal exit code reported by the isolation backend itself
|
|
55
|
+
* (currently docker via `docker inspect .State.ExitCode`). Returns null when
|
|
56
|
+
* the backend cannot provide a real code, so callers never surface the `-1`
|
|
57
|
+
* sentinel for a session whose real exit code is simply not available yet.
|
|
58
|
+
* @param {Object} record - Execution record
|
|
59
|
+
* @returns {number|null}
|
|
60
|
+
*/
|
|
61
|
+
function readBackendExitCode(record) {
|
|
62
|
+
const opts = record.options || {};
|
|
63
|
+
if (opts.isolated !== 'docker' || !opts.sessionName) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const state = inspectDockerState(opts.sessionName);
|
|
67
|
+
return state && !state.running ? state.exitCode : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
18
70
|
/**
|
|
19
71
|
* Check if a detached isolation session is still running
|
|
20
72
|
* @param {Object} record - Execution record
|
|
@@ -46,11 +98,11 @@ function isDetachedSessionAlive(record) {
|
|
|
46
98
|
return true;
|
|
47
99
|
}
|
|
48
100
|
case 'docker': {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
);
|
|
53
|
-
return
|
|
101
|
+
// A failed inspect means the container is not visible yet (still being
|
|
102
|
+
// created on a slow DinD host) or already removed — not "stopped".
|
|
103
|
+
// Return null (unknown) so the session is not falsely marked finished.
|
|
104
|
+
const state = inspectDockerState(sessionName);
|
|
105
|
+
return state === null ? null : state.running;
|
|
54
106
|
}
|
|
55
107
|
case 'ssh': {
|
|
56
108
|
// For SSH, check if the PID is still running on remote would require
|
|
@@ -100,24 +152,62 @@ function readExitCodeFromLog(logPath) {
|
|
|
100
152
|
*/
|
|
101
153
|
function enrichDetachedStatus(record) {
|
|
102
154
|
const alive = isDetachedSessionAlive(record);
|
|
155
|
+
const footerExit = readExitCodeFromLog(record.logPath);
|
|
156
|
+
|
|
157
|
+
// Create a shallow copy to avoid mutating the original
|
|
158
|
+
const cloneRecord = () => {
|
|
159
|
+
const enriched = Object.create(Object.getPrototypeOf(record));
|
|
160
|
+
Object.assign(enriched, record);
|
|
161
|
+
return enriched;
|
|
162
|
+
};
|
|
163
|
+
|
|
103
164
|
if (alive === null) {
|
|
165
|
+
// Liveness is unknown: the backend could not be probed (e.g. a detached
|
|
166
|
+
// docker container that is not visible yet on a slow Docker-in-Docker host,
|
|
167
|
+
// or one that has already been removed). Honor a terminal `Exit Code:`
|
|
168
|
+
// footer if the command wrote one; otherwise leave the record untouched
|
|
169
|
+
// (still executing) rather than fabricating a `-1` terminal result that
|
|
170
|
+
// orchestrators misread as a finished/failed run (issue #136).
|
|
171
|
+
const isDetached =
|
|
172
|
+
record.options && record.options.isolationMode === 'detached';
|
|
173
|
+
if (isDetached && record.status === 'executing' && footerExit !== null) {
|
|
174
|
+
const enriched = cloneRecord();
|
|
175
|
+
enriched.status = 'executed';
|
|
176
|
+
enriched.exitCode = footerExit;
|
|
177
|
+
if (!enriched.endTime) {
|
|
178
|
+
enriched.endTime = new Date().toISOString();
|
|
179
|
+
}
|
|
180
|
+
return enriched;
|
|
181
|
+
}
|
|
104
182
|
return record;
|
|
105
183
|
}
|
|
106
184
|
|
|
107
|
-
|
|
108
|
-
const enriched = Object.create(Object.getPrototypeOf(record));
|
|
109
|
-
Object.assign(enriched, record);
|
|
185
|
+
const enriched = cloneRecord();
|
|
110
186
|
|
|
111
187
|
if (alive && enriched.status === 'executed') {
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
188
|
+
// A live `screen -ls` (or `tmux`/`docker`) session does NOT mean the command
|
|
189
|
+
// is still running: a lingering shell can outlive a killed command (e.g. the
|
|
190
|
+
// OOM killer sends SIGKILL, exit 137, but the login shell stays up for a
|
|
191
|
+
// window after `start` already wrote the terminal footer). The footer/recorded
|
|
192
|
+
// exit code is authoritative. Only flip back to 'executing' when there is NO
|
|
193
|
+
// recorded terminal exit code AND no `Exit Code:` footer in the log.
|
|
194
|
+
const hasRecordedExit =
|
|
195
|
+
enriched.exitCode !== null && enriched.exitCode !== undefined;
|
|
196
|
+
if (!hasRecordedExit && footerExit === null) {
|
|
197
|
+
// Session still running and no terminal record - correct it
|
|
198
|
+
enriched.status = 'executing';
|
|
199
|
+
enriched.exitCode = null;
|
|
200
|
+
enriched.endTime = null;
|
|
201
|
+
}
|
|
202
|
+
// Otherwise keep the recorded/footer exit code - the command has finished.
|
|
116
203
|
} else if (!alive && enriched.status === 'executing') {
|
|
117
|
-
// Session ended but record says executing - correct it
|
|
204
|
+
// Session ended but record says executing - correct it. Resolve a real exit
|
|
205
|
+
// code: prefer the log footer, then the backend's own record (e.g.
|
|
206
|
+
// `docker inspect .State.ExitCode`), and only fall back to the `-1` sentinel
|
|
207
|
+
// as a last resort when no real code can be obtained (issue #136).
|
|
118
208
|
enriched.status = 'executed';
|
|
119
209
|
if (enriched.exitCode === null || enriched.exitCode === undefined) {
|
|
120
|
-
enriched.exitCode =
|
|
210
|
+
enriched.exitCode = footerExit ?? readBackendExitCode(enriched) ?? -1;
|
|
121
211
|
}
|
|
122
212
|
if (!enriched.endTime) {
|
|
123
213
|
enriched.endTime = new Date().toISOString();
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Regression tests for issue #138:
|
|
4
|
+
* "Detached docker session log omits the image-preparation phase
|
|
5
|
+
* (docker pull / dind boot) — $ does not preserve the full log in one file"
|
|
6
|
+
*
|
|
7
|
+
* When a command is launched with `--isolated docker`, the image-preparation
|
|
8
|
+
* phase (the `docker pull`) used to be printed to the console only and never
|
|
9
|
+
* written to the session log file. An operator tailing the session log (e.g.
|
|
10
|
+
* via `$ --upload-log <uuid>`) during a multi-GB pull would see only the
|
|
11
|
+
* header — the minutes spent pulling left no trace in the log.
|
|
12
|
+
*
|
|
13
|
+
* The fix tees the pull output into the session log and brackets it with
|
|
14
|
+
* `Preparing image …` / `Image ready (<duration>)` markers so the single
|
|
15
|
+
* session-log file is a gap-free record of everything that ran.
|
|
16
|
+
*
|
|
17
|
+
* Reference: https://github.com/link-foundation/start/issues/138
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { describe, it, beforeEach, afterEach } = require('node:test');
|
|
21
|
+
const assert = require('assert');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
dockerPullImage,
|
|
28
|
+
dockerImageExists,
|
|
29
|
+
isDockerAvailable,
|
|
30
|
+
} = require('../src/lib/docker-utils');
|
|
31
|
+
|
|
32
|
+
function makeTempLog() {
|
|
33
|
+
const logPath = path.join(
|
|
34
|
+
os.tmpdir(),
|
|
35
|
+
`start-138-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.log`
|
|
36
|
+
);
|
|
37
|
+
fs.writeFileSync(logPath, '=== Start Command Log ===\n');
|
|
38
|
+
return logPath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('docker pull is recorded in the session log (issue #138)', () => {
|
|
42
|
+
// Silence the virtual-command console output during these tests.
|
|
43
|
+
let originalLog;
|
|
44
|
+
let originalError;
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
originalLog = console.log;
|
|
47
|
+
originalError = console.error;
|
|
48
|
+
console.log = () => {};
|
|
49
|
+
console.error = () => {};
|
|
50
|
+
});
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
console.log = originalLog;
|
|
53
|
+
console.error = originalError;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('writes prep markers and the docker error into the log on a failed pull', () => {
|
|
57
|
+
if (!isDockerAvailable()) {
|
|
58
|
+
console.log = originalLog;
|
|
59
|
+
console.log(' Skipping: docker daemon not available');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const logPath = makeTempLog();
|
|
64
|
+
try {
|
|
65
|
+
// An invalid reference format fails fast without any network access.
|
|
66
|
+
const result = dockerPullImage('invalid..badname', logPath);
|
|
67
|
+
const contents = fs.readFileSync(logPath, 'utf8');
|
|
68
|
+
|
|
69
|
+
assert.strictEqual(
|
|
70
|
+
result.success,
|
|
71
|
+
false,
|
|
72
|
+
'pull of invalid ref must fail'
|
|
73
|
+
);
|
|
74
|
+
assert.ok(
|
|
75
|
+
contents.includes('Preparing image invalid..badname'),
|
|
76
|
+
'log must contain the "Preparing image …" start marker'
|
|
77
|
+
);
|
|
78
|
+
assert.ok(
|
|
79
|
+
contents.includes('Image preparation failed'),
|
|
80
|
+
'log must contain the failure marker with elapsed duration'
|
|
81
|
+
);
|
|
82
|
+
// The docker error itself (teed pull output) must be in the log, not just
|
|
83
|
+
// on the console — this is the core of issue #138.
|
|
84
|
+
assert.ok(
|
|
85
|
+
contents.includes('invalid reference format'),
|
|
86
|
+
'log must capture the teed docker pull error output'
|
|
87
|
+
);
|
|
88
|
+
} finally {
|
|
89
|
+
fs.rmSync(logPath, { force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('tees real pull output and an "Image ready" marker into the log', () => {
|
|
94
|
+
if (!isDockerAvailable()) {
|
|
95
|
+
console.log = originalLog;
|
|
96
|
+
console.log(' Skipping: docker daemon not available');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (process.platform === 'win32') {
|
|
100
|
+
// Windows Docker runners pull Linux images slowly/unreliably and the
|
|
101
|
+
// capture path is exercised by the failed-pull test above; skip to avoid
|
|
102
|
+
// network-dependent timeouts. The real-pull streaming path is Unix-only.
|
|
103
|
+
console.log = originalLog;
|
|
104
|
+
console.log(' Skipping: real pull is unreliable on Windows Docker');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Use a tiny image and force a real pull by removing it first.
|
|
109
|
+
const image = 'hello-world:latest';
|
|
110
|
+
try {
|
|
111
|
+
require('child_process').execSync(`docker rmi ${image}`, {
|
|
112
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
// image not present locally — that's fine, the pull below still runs
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const logPath = makeTempLog();
|
|
119
|
+
try {
|
|
120
|
+
const result = dockerPullImage(image, logPath);
|
|
121
|
+
if (!result.success) {
|
|
122
|
+
// No network in this environment — cannot exercise the success path.
|
|
123
|
+
console.log = originalLog;
|
|
124
|
+
console.log(' Skipping: docker pull failed (no registry access?)');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const contents = fs.readFileSync(logPath, 'utf8');
|
|
129
|
+
assert.ok(
|
|
130
|
+
contents.includes(`Preparing image ${image}`),
|
|
131
|
+
'log must contain the "Preparing image …" start marker'
|
|
132
|
+
);
|
|
133
|
+
assert.ok(
|
|
134
|
+
/Image ready \(\d+\.\d+s\)/.test(contents),
|
|
135
|
+
'log must contain "Image ready (<duration>)" marker'
|
|
136
|
+
);
|
|
137
|
+
assert.ok(
|
|
138
|
+
contents.includes('Pulling from') ||
|
|
139
|
+
contents.includes('Status:') ||
|
|
140
|
+
contents.includes('Pull complete'),
|
|
141
|
+
'log must capture the teed docker pull progress output'
|
|
142
|
+
);
|
|
143
|
+
assert.ok(
|
|
144
|
+
dockerImageExists(image),
|
|
145
|
+
'image should exist locally after a successful pull'
|
|
146
|
+
);
|
|
147
|
+
} finally {
|
|
148
|
+
fs.rmSync(logPath, { force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not write prep markers when no logPath is given (backward compat)', () => {
|
|
153
|
+
if (!isDockerAvailable()) {
|
|
154
|
+
console.log = originalLog;
|
|
155
|
+
console.log(' Skipping: docker daemon not available');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Without a logPath, dockerPullImage must still return the {success, output}
|
|
160
|
+
// shape and must not throw. We only assert the contract here.
|
|
161
|
+
const result = dockerPullImage('invalid..badname');
|
|
162
|
+
assert.strictEqual(result.success, false);
|
|
163
|
+
assert.strictEqual(typeof result.output, 'string');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('runInDocker threads logPath into dockerPullImage (issue #138)', () => {
|
|
168
|
+
it('source passes options.logPath to dockerPullImage', () => {
|
|
169
|
+
const isolationSrc = fs.readFileSync(
|
|
170
|
+
path.join(__dirname, '../src/lib/isolation.js'),
|
|
171
|
+
'utf8'
|
|
172
|
+
);
|
|
173
|
+
assert.ok(
|
|
174
|
+
/dockerPullImage\(options\.image,\s*options\.logPath\)/.test(
|
|
175
|
+
isolationSrc
|
|
176
|
+
),
|
|
177
|
+
'runInDocker must pass options.logPath to dockerPullImage'
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('dockerPullImage tees output and writes prep markers in source', () => {
|
|
182
|
+
const dockerUtilsSrc = fs.readFileSync(
|
|
183
|
+
path.join(__dirname, '../src/lib/docker-utils.js'),
|
|
184
|
+
'utf8'
|
|
185
|
+
);
|
|
186
|
+
assert.ok(
|
|
187
|
+
dockerUtilsSrc.includes('Preparing image'),
|
|
188
|
+
'docker-utils must write a "Preparing image …" marker'
|
|
189
|
+
);
|
|
190
|
+
assert.ok(
|
|
191
|
+
dockerUtilsSrc.includes('Image ready'),
|
|
192
|
+
'docker-utils must write an "Image ready (<duration>)" marker'
|
|
193
|
+
);
|
|
194
|
+
assert.ok(
|
|
195
|
+
dockerUtilsSrc.includes('tee -a'),
|
|
196
|
+
'docker-utils must tee docker pull output into the log file'
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -306,6 +306,249 @@ describe('Issue #101: Detached status enrichment', () => {
|
|
|
306
306
|
expect(enriched.endTime).not.toBeNull();
|
|
307
307
|
}
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
// Issue #134: a lingering screen session must NOT resurrect a completed
|
|
311
|
+
// (killed, exit 137) record back to 'executing' / null exit code.
|
|
312
|
+
describe('Issue #134: completed record with a lingering live session', () => {
|
|
313
|
+
const screenAvailable = (() => {
|
|
314
|
+
const probe = spawnSync('screen', ['-v'], { encoding: 'utf8' });
|
|
315
|
+
return probe.status === 0 || /Screen version/.test(probe.stdout || '');
|
|
316
|
+
})();
|
|
317
|
+
|
|
318
|
+
let sessionName;
|
|
319
|
+
let logPath;
|
|
320
|
+
|
|
321
|
+
beforeEach(() => {
|
|
322
|
+
if (!screenAvailable) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
sessionName = `enrich-134-${process.pid}-${Date.now()}`;
|
|
326
|
+
logPath = path.join(TEST_APP_FOLDER, `${sessionName}.log`);
|
|
327
|
+
if (!fs.existsSync(TEST_APP_FOLDER)) {
|
|
328
|
+
fs.mkdirSync(TEST_APP_FOLDER, { recursive: true });
|
|
329
|
+
}
|
|
330
|
+
// Footer exactly as `start` writes it for a SIGKILLed command.
|
|
331
|
+
fs.writeFileSync(
|
|
332
|
+
logPath,
|
|
333
|
+
`Killed\n\n${'='.repeat(50)}\nFinished: 2026-06-14 19:10:49.822\nExit Code: 137\n`
|
|
334
|
+
);
|
|
335
|
+
// A shell that outlives the (already-finished) command.
|
|
336
|
+
spawnSync('screen', ['-dmS', sessionName, 'sh', '-c', 'sleep 30'], {
|
|
337
|
+
encoding: 'utf8',
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
afterEach(() => {
|
|
342
|
+
if (!screenAvailable) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
spawnSync('screen', ['-S', sessionName, '-X', 'quit'], {
|
|
346
|
+
stdio: 'ignore',
|
|
347
|
+
});
|
|
348
|
+
if (logPath && fs.existsSync(logPath)) {
|
|
349
|
+
fs.unlinkSync(logPath);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('keeps the recorded exit code when the session is still listed', () => {
|
|
354
|
+
if (!screenAvailable) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const record = new ExecutionRecord({
|
|
358
|
+
command: 'sleep 60',
|
|
359
|
+
logPath,
|
|
360
|
+
options: {
|
|
361
|
+
sessionName,
|
|
362
|
+
isolated: 'screen',
|
|
363
|
+
isolationMode: 'detached',
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
record.complete(137);
|
|
367
|
+
|
|
368
|
+
// Sanity: the session must actually be alive for this test to be meaningful.
|
|
369
|
+
expect(isDetachedSessionAlive(record)).toBe(true);
|
|
370
|
+
|
|
371
|
+
const enriched = enrichDetachedStatus(record);
|
|
372
|
+
expect(enriched.status).toBe('executed');
|
|
373
|
+
expect(enriched.exitCode).toBe(137);
|
|
374
|
+
expect(enriched.endTime).not.toBeNull();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('honors the log footer exit code even without a recorded exit code', () => {
|
|
378
|
+
if (!screenAvailable) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const record = new ExecutionRecord({
|
|
382
|
+
command: 'sleep 60',
|
|
383
|
+
logPath,
|
|
384
|
+
options: {
|
|
385
|
+
sessionName,
|
|
386
|
+
isolated: 'screen',
|
|
387
|
+
isolationMode: 'detached',
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
// Record was never reconciled: status 'executed' but exitCode still null.
|
|
391
|
+
record.status = 'executed';
|
|
392
|
+
record.exitCode = null;
|
|
393
|
+
record.endTime = null;
|
|
394
|
+
|
|
395
|
+
expect(isDetachedSessionAlive(record)).toBe(true);
|
|
396
|
+
|
|
397
|
+
const enriched = enrichDetachedStatus(record);
|
|
398
|
+
// Footer says 137, so it must stay finished, not flip to executing.
|
|
399
|
+
expect(enriched.status).toBe('executed');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('flips to executing only when there is no terminal record at all', () => {
|
|
403
|
+
if (!screenAvailable) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// Log with NO Exit Code footer and no recorded exit code.
|
|
407
|
+
fs.writeFileSync(logPath, 'still running, no footer yet\n');
|
|
408
|
+
const record = new ExecutionRecord({
|
|
409
|
+
command: 'sleep 60',
|
|
410
|
+
logPath,
|
|
411
|
+
options: {
|
|
412
|
+
sessionName,
|
|
413
|
+
isolated: 'screen',
|
|
414
|
+
isolationMode: 'detached',
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
record.status = 'executed';
|
|
418
|
+
record.exitCode = null;
|
|
419
|
+
record.endTime = null;
|
|
420
|
+
|
|
421
|
+
expect(isDetachedSessionAlive(record)).toBe(true);
|
|
422
|
+
|
|
423
|
+
const enriched = enrichDetachedStatus(record);
|
|
424
|
+
expect(enriched.status).toBe('executing');
|
|
425
|
+
expect(enriched.exitCode).toBeNull();
|
|
426
|
+
expect(enriched.endTime).toBeNull();
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Issue #136: a detached docker session must not be reported with a terminal
|
|
433
|
+
// status (`executed`) and the `-1` sentinel while its container is still
|
|
434
|
+
// running (or not visible yet on a slow Docker-in-Docker host).
|
|
435
|
+
describe('Issue #136: detached docker session liveness', () => {
|
|
436
|
+
// Use the repo's own probe: `docker` may be installed yet unable to run Linux
|
|
437
|
+
// images (e.g. Windows runners in Windows-containers mode, where `alpine`
|
|
438
|
+
// never starts). In that case `docker inspect` fails and liveness is `null`
|
|
439
|
+
// (unknown) rather than `false`, which would break the stopped-container
|
|
440
|
+
// assertions below.
|
|
441
|
+
const { canRunLinuxDockerImages } = require('../src/lib/isolation');
|
|
442
|
+
const dockerAvailable = canRunLinuxDockerImages();
|
|
443
|
+
|
|
444
|
+
// Whether the container actually exists (was created) per `docker inspect`.
|
|
445
|
+
function dockerContainerExists(name) {
|
|
446
|
+
const probe = spawnSync('docker', ['inspect', name], { stdio: 'ignore' });
|
|
447
|
+
return probe.status === 0;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function makeDockerRecord(sessionName, extra = {}) {
|
|
451
|
+
return new ExecutionRecord({
|
|
452
|
+
command: 'sleep 120; echo done',
|
|
453
|
+
options: {
|
|
454
|
+
sessionName,
|
|
455
|
+
isolated: 'docker',
|
|
456
|
+
isolationMode: 'detached',
|
|
457
|
+
},
|
|
458
|
+
...extra,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function dockerRm(name) {
|
|
463
|
+
spawnSync('docker', ['rm', '-f', name], { stdio: 'ignore' });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
it('reports unknown (null) — not false — when the container is not visible yet', () => {
|
|
467
|
+
if (!dockerAvailable) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const record = makeDockerRecord('issue136-container-does-not-exist-yet');
|
|
471
|
+
expect(isDetachedSessionAlive(record)).toBeNull();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('keeps the record executing while the container is not visible yet', () => {
|
|
475
|
+
if (!dockerAvailable) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const record = makeDockerRecord('issue136-container-not-visible');
|
|
479
|
+
// Defaults to executing with a null exit code.
|
|
480
|
+
const enriched = enrichDetachedStatus(record);
|
|
481
|
+
expect(enriched.status).toBe('executing');
|
|
482
|
+
expect(enriched.exitCode).toBeNull();
|
|
483
|
+
expect(enriched.endTime).toBeNull();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('keeps a running container executing', () => {
|
|
487
|
+
if (!dockerAvailable) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const name = `issue136-running-${process.pid}`;
|
|
491
|
+
dockerRm(name);
|
|
492
|
+
const started = spawnSync('docker', [
|
|
493
|
+
'run',
|
|
494
|
+
'-d',
|
|
495
|
+
'--name',
|
|
496
|
+
name,
|
|
497
|
+
'alpine',
|
|
498
|
+
'sh',
|
|
499
|
+
'-c',
|
|
500
|
+
'sleep 30',
|
|
501
|
+
]);
|
|
502
|
+
if (started.status !== 0) {
|
|
503
|
+
dockerRm(name);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
const record = makeDockerRecord(name);
|
|
508
|
+
expect(isDetachedSessionAlive(record)).toBe(true);
|
|
509
|
+
const enriched = enrichDetachedStatus(record);
|
|
510
|
+
expect(enriched.status).toBe('executing');
|
|
511
|
+
expect(enriched.exitCode).toBeNull();
|
|
512
|
+
} finally {
|
|
513
|
+
dockerRm(name);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('resolves a stopped container to its real exit code, never the -1 sentinel', () => {
|
|
518
|
+
if (!dockerAvailable) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const name = `issue136-stopped-${process.pid}`;
|
|
522
|
+
dockerRm(name);
|
|
523
|
+
const ran = spawnSync('docker', [
|
|
524
|
+
'run',
|
|
525
|
+
'--name',
|
|
526
|
+
name,
|
|
527
|
+
'alpine',
|
|
528
|
+
'sh',
|
|
529
|
+
'-c',
|
|
530
|
+
'exit 1',
|
|
531
|
+
]);
|
|
532
|
+
// `docker run` exits with the container's code (1 here); treat spawn errors
|
|
533
|
+
// (no daemon) or a container that never materialized (e.g. the Linux image
|
|
534
|
+
// could not be pulled) as a skip — there is nothing stopped to inspect.
|
|
535
|
+
if (ran.error || !dockerContainerExists(name)) {
|
|
536
|
+
dockerRm(name);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
// No log footer: force exit-code resolution through `docker inspect`.
|
|
541
|
+
const record = makeDockerRecord(name, {
|
|
542
|
+
logPath: '/nonexistent-issue136.log',
|
|
543
|
+
});
|
|
544
|
+
expect(isDetachedSessionAlive(record)).toBe(false);
|
|
545
|
+
const enriched = enrichDetachedStatus(record);
|
|
546
|
+
expect(enriched.status).toBe('executed');
|
|
547
|
+
expect(enriched.exitCode).toBe(1);
|
|
548
|
+
expect(enriched.endTime).not.toBeNull();
|
|
549
|
+
} finally {
|
|
550
|
+
dockerRm(name);
|
|
551
|
+
}
|
|
309
552
|
});
|
|
310
553
|
});
|
|
311
554
|
|