start-command 0.32.1 → 0.34.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 +27 -0
- package/README.md +3 -0
- package/eslint.config.mjs +8 -0
- package/package.json +7 -3
- package/src/bin/cli.js +32 -137
- package/src/lib/args-parser-queries.js +228 -0
- package/src/lib/args-parser.js +38 -179
- package/src/lib/detached-finalize.js +281 -0
- package/src/lib/docker-cleanup.js +240 -39
- package/src/lib/docker-post-mortem.js +289 -0
- package/src/lib/execution-attach.js +245 -0
- package/src/lib/execution-resume-all.js +211 -0
- package/src/lib/execution-resume.js +455 -0
- package/src/lib/execution-store.js +93 -6
- package/src/lib/exit-reason.js +264 -0
- package/src/lib/failure-handler.js +43 -41
- package/src/lib/isolation-log-utils.js +66 -1
- package/src/lib/isolation.js +28 -15
- package/src/lib/query-commands.js +202 -0
- package/src/lib/screen-isolation.js +2 -1
- package/src/lib/session-probe.js +121 -0
- package/src/lib/shell-utils.js +164 -10
- package/src/lib/status-formatter.js +233 -69
- package/src/lib/usage.js +13 -1
- package/src/lib/user-manager.js +18 -1
- package/test/args-parser-attach-resume.js +159 -0
- package/test/args-parser-control.js +3 -3
- package/test/args-parser.js +1 -1
- package/test/check-file-size.js +91 -0
- package/test/ci-workflow-invariants.js +191 -0
- package/test/echo-integration.js +20 -13
- package/test/execution-attach.js +221 -0
- package/test/execution-resume.js +433 -0
- package/test/execution-store.js +57 -2
- package/test/exit-reason.js +209 -0
- package/test/failure-handler.js +107 -0
- package/test/load-command-stream.mjs +83 -0
- package/test/regression-162.js +320 -0
- package/test/regression-164.js +308 -0
- package/test/regression-165.js +310 -0
- package/test/regression-170.js +321 -0
- package/test/regression-171.js +562 -0
- package/test/regression-84.js +8 -3
- package/test/release-name.mjs +57 -0
- package/test/session-probe.js +150 -0
- package/test/user-manager.js +23 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #165.
|
|
3
|
+
*
|
|
4
|
+
* A runtime that aborts on its own heap limit (`FATAL ERROR: Reached heap limit
|
|
5
|
+
* ...`) dies below the container limit, so every container signal `start`
|
|
6
|
+
* consults is correct and useless: `State.OOMKilled` is `false` and the cgroup
|
|
7
|
+
* `oom_kill` counter is `0`. The only evidence is what the runtime printed into
|
|
8
|
+
* the log, which is why `--status` now carries `memoryExhausted` /
|
|
9
|
+
* `memoryExhaustedReason` alongside `oomKilled`, and why the kept-container
|
|
10
|
+
* footer no longer asserts a bare `oomKilled=false` next to a fatal marker.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { describe, it, expect, beforeEach, afterEach } = require('bun:test');
|
|
14
|
+
const { spawnSync } = require('child_process');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
ExecutionRecord,
|
|
21
|
+
ExecutionStore,
|
|
22
|
+
} = require('../src/lib/execution-store');
|
|
23
|
+
const {
|
|
24
|
+
buildAttachedDockerKeptMessage,
|
|
25
|
+
buildDetachedDockerCompletionScript,
|
|
26
|
+
buildDockerKeptReasonSnippet,
|
|
27
|
+
} = require('../src/lib/docker-cleanup');
|
|
28
|
+
|
|
29
|
+
const TEST_APP_FOLDER = path.join(
|
|
30
|
+
os.tmpdir(),
|
|
31
|
+
`regression-165-${process.pid}-${Date.now()}`
|
|
32
|
+
);
|
|
33
|
+
const CLI_PATH = path.join(__dirname, '../src/bin/cli.js');
|
|
34
|
+
|
|
35
|
+
const HEAP_LIMIT_MARKER =
|
|
36
|
+
'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory';
|
|
37
|
+
|
|
38
|
+
function cleanupTestDir() {
|
|
39
|
+
fs.rmSync(TEST_APP_FOLDER, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function runCli(args) {
|
|
43
|
+
const result = spawnSync(process.execPath, [CLI_PATH, ...args], {
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
env: { ...process.env, START_APP_FOLDER: TEST_APP_FOLDER },
|
|
46
|
+
timeout: 20000,
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
stdout: result.stdout || '',
|
|
50
|
+
stderr: result.stderr || '',
|
|
51
|
+
exitCode: result.status,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write a log that ends the way the incident log does: command output, the
|
|
57
|
+
* dying runtime's fatal marker, an optional native stack trace, and the
|
|
58
|
+
* terminal footer `start` appends itself.
|
|
59
|
+
*/
|
|
60
|
+
function writeOomLog(
|
|
61
|
+
name,
|
|
62
|
+
{ stackTraceBytes = 0, marker = HEAP_LIMIT_MARKER }
|
|
63
|
+
) {
|
|
64
|
+
const logPath = path.join(TEST_APP_FOLDER, name);
|
|
65
|
+
const stackFrame =
|
|
66
|
+
' 1: 0x757658 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [node]\n';
|
|
67
|
+
const frames = stackTraceBytes
|
|
68
|
+
? stackFrame.repeat(Math.ceil(stackTraceBytes / stackFrame.length))
|
|
69
|
+
: '';
|
|
70
|
+
fs.writeFileSync(
|
|
71
|
+
logPath,
|
|
72
|
+
[
|
|
73
|
+
'building...',
|
|
74
|
+
'',
|
|
75
|
+
'<--- Last few GCs --->',
|
|
76
|
+
'[1:0xa485000] 790 ms: Mark-Compact 66.6 (99.8) -> 64.4 (194.2) MB',
|
|
77
|
+
'',
|
|
78
|
+
marker,
|
|
79
|
+
'----- Native stack trace -----',
|
|
80
|
+
frames,
|
|
81
|
+
'Docker container "docker-165" exited with code 139',
|
|
82
|
+
'Container kept because the command failed.',
|
|
83
|
+
'',
|
|
84
|
+
'==================================================',
|
|
85
|
+
'Finished: 2026-09-03 08:14:20.707',
|
|
86
|
+
'Exit Code: 139',
|
|
87
|
+
'',
|
|
88
|
+
].join('\n'),
|
|
89
|
+
'utf8'
|
|
90
|
+
);
|
|
91
|
+
return logPath;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function saveRecord(store, overrides = {}) {
|
|
95
|
+
const record = new ExecutionRecord({
|
|
96
|
+
command: overrides.command || 'node build.mjs',
|
|
97
|
+
pid: 4242,
|
|
98
|
+
logPath: overrides.logPath || null,
|
|
99
|
+
workingDirectory: '/tmp',
|
|
100
|
+
shell: '/bin/bash',
|
|
101
|
+
options: overrides.options || {},
|
|
102
|
+
});
|
|
103
|
+
if (overrides.exitCode !== undefined) {
|
|
104
|
+
record.complete(overrides.exitCode);
|
|
105
|
+
}
|
|
106
|
+
store.save(record);
|
|
107
|
+
return record;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function statusJson(uuid) {
|
|
111
|
+
const result = runCli(['--status', uuid, '--output-format', 'json']);
|
|
112
|
+
expect(result.stderr).toBe('');
|
|
113
|
+
expect(result.exitCode).toBe(0);
|
|
114
|
+
return JSON.parse(result.stdout);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe('Issue #165: memory exhaustion observed from the log', () => {
|
|
118
|
+
let store;
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
cleanupTestDir();
|
|
122
|
+
fs.mkdirSync(TEST_APP_FOLDER, { recursive: true });
|
|
123
|
+
store = new ExecutionStore({
|
|
124
|
+
appFolder: TEST_APP_FOLDER,
|
|
125
|
+
useLinks: false,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
cleanupTestDir();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('reports memoryExhausted for an attached docker session that self-aborted', () => {
|
|
134
|
+
const record = saveRecord(store, {
|
|
135
|
+
logPath: writeOomLog('attached.log', {}),
|
|
136
|
+
exitCode: 139,
|
|
137
|
+
options: { isolated: 'docker', isolationMode: 'attached' },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const parsed = statusJson(record.uuid);
|
|
141
|
+
|
|
142
|
+
expect(parsed.exitCode).toBe(139);
|
|
143
|
+
// The container flag is still absent/false — the observation does not
|
|
144
|
+
// contradict it, it complements it.
|
|
145
|
+
expect(parsed.oomKilled).toBeUndefined();
|
|
146
|
+
expect(parsed.memoryExhausted).toBe(true);
|
|
147
|
+
expect(parsed.memoryExhaustedReason).toBe(HEAP_LIMIT_MARKER);
|
|
148
|
+
expect(parsed.exitReason).toBe('memory-exhaustion (v8-heap-limit)');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('finds a marker pushed far from EOF by the native stack trace', () => {
|
|
152
|
+
const logPath = writeOomLog('long-trace.log', {
|
|
153
|
+
stackTraceBytes: 40 * 1024,
|
|
154
|
+
});
|
|
155
|
+
expect(fs.statSync(logPath).size).toBeGreaterThan(32 * 1024);
|
|
156
|
+
const record = saveRecord(store, { logPath, exitCode: 134 });
|
|
157
|
+
|
|
158
|
+
const parsed = statusJson(record.uuid);
|
|
159
|
+
|
|
160
|
+
expect(parsed.memoryExhausted).toBe(true);
|
|
161
|
+
expect(parsed.memoryExhaustedReason).toBe(HEAP_LIMIT_MARKER);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('detects a Rust allocation failure', () => {
|
|
165
|
+
const record = saveRecord(store, {
|
|
166
|
+
logPath: writeOomLog('rust.log', {
|
|
167
|
+
marker: 'memory allocation of 1073741824 bytes failed',
|
|
168
|
+
}),
|
|
169
|
+
exitCode: 134,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const parsed = statusJson(record.uuid);
|
|
173
|
+
|
|
174
|
+
expect(parsed.memoryExhausted).toBe(true);
|
|
175
|
+
expect(parsed.memoryExhaustedReason).toBe(
|
|
176
|
+
'memory allocation of 1073741824 bytes failed'
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('never turns a clean run into a reported memory failure', () => {
|
|
181
|
+
// The marker is present in the output — the command merely printed it —
|
|
182
|
+
// but the run succeeded, so there is nothing to explain (issue #151's rule:
|
|
183
|
+
// an observation, never a verdict).
|
|
184
|
+
const logPath = path.join(TEST_APP_FOLDER, 'quoted.log');
|
|
185
|
+
fs.writeFileSync(
|
|
186
|
+
logPath,
|
|
187
|
+
[
|
|
188
|
+
`grep found: ${HEAP_LIMIT_MARKER}`,
|
|
189
|
+
'==================================================',
|
|
190
|
+
'Finished: 2026-09-03 08:14:20.707',
|
|
191
|
+
'Exit Code: 0',
|
|
192
|
+
'',
|
|
193
|
+
].join('\n'),
|
|
194
|
+
'utf8'
|
|
195
|
+
);
|
|
196
|
+
const record = saveRecord(store, { logPath, exitCode: 0 });
|
|
197
|
+
|
|
198
|
+
const parsed = statusJson(record.uuid);
|
|
199
|
+
|
|
200
|
+
expect(parsed.exitCode).toBe(0);
|
|
201
|
+
expect(parsed.memoryExhausted).toBeUndefined();
|
|
202
|
+
expect(parsed.memoryExhaustedReason).toBeUndefined();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('leaves an ordinary failure without a memory observation', () => {
|
|
206
|
+
const logPath = path.join(TEST_APP_FOLDER, 'plain-failure.log');
|
|
207
|
+
fs.writeFileSync(logPath, 'error: missing file\n', 'utf8');
|
|
208
|
+
const record = saveRecord(store, { logPath, exitCode: 1 });
|
|
209
|
+
|
|
210
|
+
const parsed = statusJson(record.uuid);
|
|
211
|
+
|
|
212
|
+
expect(parsed.memoryExhausted).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('shows the observation in the human-readable and links-notation output', () => {
|
|
216
|
+
const record = saveRecord(store, {
|
|
217
|
+
logPath: writeOomLog('formats.log', {}),
|
|
218
|
+
exitCode: 139,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const text = runCli(['--status', record.uuid, '--output-format', 'text']);
|
|
222
|
+
expect(text.exitCode).toBe(0);
|
|
223
|
+
expect(text.stdout).toContain('Memory Exhausted: true');
|
|
224
|
+
expect(text.stdout).toContain(`Memory Evidence: ${HEAP_LIMIT_MARKER}`);
|
|
225
|
+
|
|
226
|
+
const lino = runCli(['--status', record.uuid]);
|
|
227
|
+
expect(lino.exitCode).toBe(0);
|
|
228
|
+
expect(lino.stdout).toContain('memoryExhausted true');
|
|
229
|
+
expect(lino.stdout).toContain('memoryExhaustedReason');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('Issue #165: kept-container footer', () => {
|
|
234
|
+
const evaluateReason = (exitCode, oomKilled) => {
|
|
235
|
+
const snippet = buildDockerKeptReasonSnippet();
|
|
236
|
+
const result = spawnSync(
|
|
237
|
+
'sh',
|
|
238
|
+
[
|
|
239
|
+
'-c',
|
|
240
|
+
`__start_command_exit=${exitCode}; __start_command_oom=${oomKilled}; ${snippet}; printf '%s' "$__start_command_reason"`,
|
|
241
|
+
],
|
|
242
|
+
{ encoding: 'utf8' }
|
|
243
|
+
);
|
|
244
|
+
expect(result.status).toBe(0);
|
|
245
|
+
return result.stdout;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
it('does not assert a bare oomKilled=false for a self-abort exit code', () => {
|
|
249
|
+
for (const exitCode of [134, 139]) {
|
|
250
|
+
const reason = evaluateReason(exitCode, 'false');
|
|
251
|
+
expect(reason).toContain(`exitCode=${exitCode} oomKilled=false`);
|
|
252
|
+
expect(reason).toContain('invisible to this flag');
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('keeps the plain reason for other exit codes and for a real OOM kill', () => {
|
|
257
|
+
expect(evaluateReason(1, 'false')).toBe('exitCode=1 oomKilled=false');
|
|
258
|
+
expect(evaluateReason(137, 'true')).toBe('exitCode=137 oomKilled=true');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('embeds the reason computation in the detached completion script', () => {
|
|
262
|
+
const script = buildDetachedDockerCompletionScript(
|
|
263
|
+
'container-165',
|
|
264
|
+
'default',
|
|
265
|
+
'/tmp/regression-165.log'
|
|
266
|
+
);
|
|
267
|
+
expect(script).toContain('__start_command_reason=');
|
|
268
|
+
expect(script).toContain('Reason: %s');
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('Issue #165: attached kept-container message', () => {
|
|
273
|
+
beforeEach(() => {
|
|
274
|
+
cleanupTestDir();
|
|
275
|
+
fs.mkdirSync(TEST_APP_FOLDER, { recursive: true });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
afterEach(() => {
|
|
279
|
+
cleanupTestDir();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('names the evidence the runtime printed into the log', () => {
|
|
283
|
+
const message = buildAttachedDockerKeptMessage({
|
|
284
|
+
containerName: 'container-165',
|
|
285
|
+
exitCode: 139,
|
|
286
|
+
oomKilled: false,
|
|
287
|
+
logPath: writeOomLog('attached-message.log', {}),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
expect(message).toContain('Container kept because the command failed.');
|
|
291
|
+
expect(message).toContain(
|
|
292
|
+
`Memory exhaustion detected in the log: ${HEAP_LIMIT_MARKER}`
|
|
293
|
+
);
|
|
294
|
+
expect(message).toContain('Remove when done: docker rm -f container-165');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('stays silent for an ordinary failure', () => {
|
|
298
|
+
const logPath = path.join(TEST_APP_FOLDER, 'attached-plain.log');
|
|
299
|
+
fs.writeFileSync(logPath, 'error: missing file\n', 'utf8');
|
|
300
|
+
|
|
301
|
+
const message = buildAttachedDockerKeptMessage({
|
|
302
|
+
containerName: 'container-165',
|
|
303
|
+
exitCode: 1,
|
|
304
|
+
oomKilled: false,
|
|
305
|
+
logPath,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(message).not.toContain('Memory exhaustion');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #170: detached Docker executions never persist a
|
|
3
|
+
* terminal state, and `--status` fabricates `endTime` with `new Date()`.
|
|
4
|
+
*
|
|
5
|
+
* The three defects covered here:
|
|
6
|
+
* 170.1 the detached completion watcher must write the terminal state back
|
|
7
|
+
* into the store instead of leaving the record `executing` forever;
|
|
8
|
+
* 170.2 `--status` must derive `endTime` from a real clock
|
|
9
|
+
* (`State.FinishedAt` -> log footer `Finished:`) and mark the
|
|
10
|
+
* provenance via `endTimeSource` when it falls back to observation time;
|
|
11
|
+
* 170.3 `cleanupStale()` must record `staleDetectedAt`, not a fabricated
|
|
12
|
+
* finish time.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { describe, it, expect, afterEach } = require('bun:test');
|
|
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 { resolveDetachedStatus } = require('../src/lib/status-formatter');
|
|
26
|
+
const {
|
|
27
|
+
buildDetachedDockerCompletionScript,
|
|
28
|
+
} = require('../src/lib/docker-cleanup');
|
|
29
|
+
const { finalizeDetachedExecution } = require('../src/lib/detached-finalize');
|
|
30
|
+
|
|
31
|
+
const tempDirs = [];
|
|
32
|
+
|
|
33
|
+
function makeTempDir(prefix) {
|
|
34
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
35
|
+
tempDirs.push(dir);
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createExecutable(filePath, content) {
|
|
40
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
41
|
+
fs.chmodSync(filePath, 0o755);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A fake `docker` that answers `inspect` with a fixed state line, whatever the
|
|
46
|
+
* `-f` template asks for. The template is echoed verbatim by the caller, so the
|
|
47
|
+
* test controls exactly how many fields the formatter has to cope with.
|
|
48
|
+
*
|
|
49
|
+
* Windows gets a `.cmd` batch stub: `spawnSync()` there cannot execute an
|
|
50
|
+
* extensionless `#!/bin/sh` script, and `--status` resolves the docker facts
|
|
51
|
+
* with a plain `spawnSync()` on every platform.
|
|
52
|
+
*/
|
|
53
|
+
function withFakeDockerInspect(stateLine, fn) {
|
|
54
|
+
const fakeBin = makeTempDir('fake-docker-170-');
|
|
55
|
+
const isWindows = process.platform === 'win32';
|
|
56
|
+
const dockerPath = path.join(fakeBin, isWindows ? 'docker.cmd' : 'docker');
|
|
57
|
+
createExecutable(
|
|
58
|
+
dockerPath,
|
|
59
|
+
isWindows
|
|
60
|
+
? [
|
|
61
|
+
'@echo off',
|
|
62
|
+
'if not "%1"=="inspect" exit /b 1',
|
|
63
|
+
`echo ${stateLine}`,
|
|
64
|
+
'exit /b 0',
|
|
65
|
+
'',
|
|
66
|
+
].join('\r\n')
|
|
67
|
+
: [
|
|
68
|
+
'#!/bin/sh',
|
|
69
|
+
'[ "$1" = "inspect" ] || exit 1',
|
|
70
|
+
`echo "${stateLine}"`,
|
|
71
|
+
'',
|
|
72
|
+
].join('\n')
|
|
73
|
+
);
|
|
74
|
+
const originalPath = process.env.PATH;
|
|
75
|
+
const originalDockerBin = process.env.START_DOCKER_BIN;
|
|
76
|
+
process.env.PATH = `${fakeBin}${path.delimiter}${originalPath || ''}`;
|
|
77
|
+
process.env.START_DOCKER_BIN = dockerPath;
|
|
78
|
+
try {
|
|
79
|
+
return fn();
|
|
80
|
+
} finally {
|
|
81
|
+
process.env.PATH = originalPath;
|
|
82
|
+
if (originalDockerBin === undefined) {
|
|
83
|
+
delete process.env.START_DOCKER_BIN;
|
|
84
|
+
} else {
|
|
85
|
+
process.env.START_DOCKER_BIN = originalDockerBin;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function withFakeDockerMissingContainer(fn) {
|
|
91
|
+
const fakeBin = makeTempDir('fake-docker-gone-170-');
|
|
92
|
+
const dockerPath = path.join(fakeBin, 'docker');
|
|
93
|
+
createExecutable(dockerPath, ['#!/bin/sh', 'exit 1', ''].join('\n'));
|
|
94
|
+
const originalPath = process.env.PATH;
|
|
95
|
+
const originalDockerBin = process.env.START_DOCKER_BIN;
|
|
96
|
+
process.env.PATH = `${fakeBin}${path.delimiter}${originalPath || ''}`;
|
|
97
|
+
process.env.START_DOCKER_BIN = dockerPath;
|
|
98
|
+
try {
|
|
99
|
+
return fn();
|
|
100
|
+
} finally {
|
|
101
|
+
process.env.PATH = originalPath;
|
|
102
|
+
if (originalDockerBin === undefined) {
|
|
103
|
+
delete process.env.START_DOCKER_BIN;
|
|
104
|
+
} else {
|
|
105
|
+
process.env.START_DOCKER_BIN = originalDockerBin;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function detachedDockerRecord(overrides = {}) {
|
|
111
|
+
return new ExecutionRecord({
|
|
112
|
+
pid: null,
|
|
113
|
+
status: ExecutionStatus.EXECUTING,
|
|
114
|
+
exitCode: null,
|
|
115
|
+
command: 'sleep 100',
|
|
116
|
+
startTime: '2026-09-15T22:21:40.000Z',
|
|
117
|
+
endTime: null,
|
|
118
|
+
options: {
|
|
119
|
+
isolated: 'docker',
|
|
120
|
+
isolationMode: 'detached',
|
|
121
|
+
sessionName: 'start-command-170',
|
|
122
|
+
},
|
|
123
|
+
...overrides,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
afterEach(() => {
|
|
128
|
+
while (tempDirs.length > 0) {
|
|
129
|
+
const dir = tempDirs.pop();
|
|
130
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('issue #170.2: endTime is never fabricated silently', () => {
|
|
135
|
+
it('derives endTime from docker State.FinishedAt for a stopped container', () => {
|
|
136
|
+
const record = detachedDockerRecord();
|
|
137
|
+
const enriched = withFakeDockerInspect(
|
|
138
|
+
'false 137 false 2026-09-15T22:21:40.942007645Z 2026-09-15T22:21:46.740817278Z',
|
|
139
|
+
() => resolveDetachedStatus(record, null)
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(enriched.status).toBe('executed');
|
|
143
|
+
expect(enriched.exitCode).toBe(137);
|
|
144
|
+
expect(enriched.endTimeSource).toBe('docker-finished-at');
|
|
145
|
+
expect(new Date(enriched.endTime).toISOString()).toBe(
|
|
146
|
+
'2026-09-15T22:21:46.740Z'
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('falls back to the log footer Finished: line when docker is gone', () => {
|
|
151
|
+
const record = detachedDockerRecord();
|
|
152
|
+
const tail = [
|
|
153
|
+
'output line',
|
|
154
|
+
'='.repeat(50),
|
|
155
|
+
'Finished: 2026-09-15 22:21:46.740',
|
|
156
|
+
'Exit Code: 137',
|
|
157
|
+
'',
|
|
158
|
+
].join('\n');
|
|
159
|
+
|
|
160
|
+
const enriched = withFakeDockerMissingContainer(() =>
|
|
161
|
+
resolveDetachedStatus(record, tail)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(enriched.status).toBe('executed');
|
|
165
|
+
expect(enriched.exitCode).toBe(137);
|
|
166
|
+
expect(enriched.endTimeSource).toBe('log-footer');
|
|
167
|
+
expect(new Date(enriched.endTime).toISOString()).toBe(
|
|
168
|
+
'2026-09-15T22:21:46.740Z'
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('marks the observation-time fallback instead of pretending it is a finish time', () => {
|
|
173
|
+
const record = detachedDockerRecord();
|
|
174
|
+
const tail = [
|
|
175
|
+
'='.repeat(50),
|
|
176
|
+
'Finished: not-a-timestamp',
|
|
177
|
+
'Exit Code: 3',
|
|
178
|
+
'',
|
|
179
|
+
].join('\n');
|
|
180
|
+
|
|
181
|
+
const enriched = withFakeDockerMissingContainer(() =>
|
|
182
|
+
resolveDetachedStatus(record, tail)
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
expect(enriched.status).toBe('executed');
|
|
186
|
+
expect(enriched.exitCode).toBe(3);
|
|
187
|
+
expect(enriched.endTimeSource).toBe('observed-at');
|
|
188
|
+
expect(enriched.observedAt).toBe(enriched.endTime);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('keeps an already recorded endTime and marks it as persisted', () => {
|
|
192
|
+
const record = detachedDockerRecord({
|
|
193
|
+
status: ExecutionStatus.EXECUTED,
|
|
194
|
+
exitCode: 0,
|
|
195
|
+
endTime: '2026-09-15T22:21:46.740Z',
|
|
196
|
+
endTimeSource: 'docker-finished-at',
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const enriched = withFakeDockerInspect('false 0 false', () =>
|
|
200
|
+
resolveDetachedStatus(record, null)
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
expect(enriched.endTime).toBe('2026-09-15T22:21:46.740Z');
|
|
204
|
+
expect(enriched.endTimeSource).toBe('docker-finished-at');
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('issue #170.3: cleanupStale records the detection time', () => {
|
|
209
|
+
it('sets staleDetectedAt and leaves endTime unknown', () => {
|
|
210
|
+
const appFolder = makeTempDir('stale-170-');
|
|
211
|
+
const store = new ExecutionStore({ appFolder, useLinks: false });
|
|
212
|
+
|
|
213
|
+
const record = new ExecutionRecord({
|
|
214
|
+
pid: 999999,
|
|
215
|
+
status: ExecutionStatus.EXECUTING,
|
|
216
|
+
command: 'sleep 1000',
|
|
217
|
+
startTime: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
|
|
218
|
+
});
|
|
219
|
+
store.save(record);
|
|
220
|
+
|
|
221
|
+
const result = store.cleanupStale({ maxAge: 1000 });
|
|
222
|
+
expect(result.cleaned).toBe(1);
|
|
223
|
+
|
|
224
|
+
const cleaned = store.get(record.uuid);
|
|
225
|
+
expect(cleaned.status).toBe(ExecutionStatus.EXECUTED);
|
|
226
|
+
expect(cleaned.exitCode).toBe(-1);
|
|
227
|
+
expect(cleaned.endTime).toBeNull();
|
|
228
|
+
expect(typeof cleaned.staleDetectedAt).toBe('string');
|
|
229
|
+
expect(Number.isNaN(Date.parse(cleaned.staleDetectedAt))).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('issue #170.1: the detached watcher persists the terminal state', () => {
|
|
234
|
+
it('invokes the finalizer with the inspected docker facts', () => {
|
|
235
|
+
const script = buildDetachedDockerCompletionScript(
|
|
236
|
+
'start-command-170',
|
|
237
|
+
'default',
|
|
238
|
+
'/tmp/start-command-170.log',
|
|
239
|
+
'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(script).toContain('detached-finalize');
|
|
243
|
+
expect(script).toContain('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
|
|
244
|
+
expect(script).toContain('$__start_command_exit');
|
|
245
|
+
expect(script).toContain('$__start_command_finished');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('stays backward compatible when no execution id is supplied', () => {
|
|
249
|
+
const script = buildDetachedDockerCompletionScript(
|
|
250
|
+
'start-command-170',
|
|
251
|
+
'default',
|
|
252
|
+
'/tmp/start-command-170.log'
|
|
253
|
+
);
|
|
254
|
+
expect(script).not.toContain('detached-finalize');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('writes status, exitCode, endTime and provenance into the store', () => {
|
|
258
|
+
const appFolder = makeTempDir('finalize-170-');
|
|
259
|
+
const store = new ExecutionStore({ appFolder, useLinks: false });
|
|
260
|
+
const record = detachedDockerRecord();
|
|
261
|
+
store.save(record);
|
|
262
|
+
|
|
263
|
+
const outcome = finalizeDetachedExecution({
|
|
264
|
+
store,
|
|
265
|
+
executionId: record.uuid,
|
|
266
|
+
exitCode: 137,
|
|
267
|
+
oomKilled: false,
|
|
268
|
+
startedAt: '2026-09-15T22:21:40.942007645Z',
|
|
269
|
+
finishedAt: '2026-09-15T22:21:46.740817278Z',
|
|
270
|
+
containerError: '',
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(outcome.updated).toBe(true);
|
|
274
|
+
|
|
275
|
+
const stored = store.get(record.uuid);
|
|
276
|
+
expect(stored.status).toBe(ExecutionStatus.EXECUTED);
|
|
277
|
+
expect(stored.exitCode).toBe(137);
|
|
278
|
+
expect(stored.oomKilled).toBe(false);
|
|
279
|
+
expect(stored.endTimeSource).toBe('docker-finished-at');
|
|
280
|
+
expect(new Date(stored.endTime).toISOString()).toBe(
|
|
281
|
+
'2026-09-15T22:21:46.740Z'
|
|
282
|
+
);
|
|
283
|
+
expect(stored.exitReason).toContain('SIGKILL');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('falls back to observation time when docker reports no finish time', () => {
|
|
287
|
+
const appFolder = makeTempDir('finalize-170-zero-');
|
|
288
|
+
const store = new ExecutionStore({ appFolder, useLinks: false });
|
|
289
|
+
const record = detachedDockerRecord();
|
|
290
|
+
store.save(record);
|
|
291
|
+
|
|
292
|
+
finalizeDetachedExecution({
|
|
293
|
+
store,
|
|
294
|
+
executionId: record.uuid,
|
|
295
|
+
exitCode: 0,
|
|
296
|
+
oomKilled: false,
|
|
297
|
+
startedAt: 'unknown',
|
|
298
|
+
finishedAt: '0001-01-01T00:00:00Z',
|
|
299
|
+
containerError: '',
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const stored = store.get(record.uuid);
|
|
303
|
+
expect(stored.status).toBe(ExecutionStatus.EXECUTED);
|
|
304
|
+
expect(stored.exitCode).toBe(0);
|
|
305
|
+
expect(stored.endTimeSource).toBe('observed-at');
|
|
306
|
+
expect(stored.observedAt).toBe(stored.endTime);
|
|
307
|
+
expect(new Date(stored.endTime).getUTCFullYear()).toBeGreaterThan(2000);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('never fails when the record is gone', () => {
|
|
311
|
+
const appFolder = makeTempDir('finalize-170-missing-');
|
|
312
|
+
const store = new ExecutionStore({ appFolder, useLinks: false });
|
|
313
|
+
const outcome = finalizeDetachedExecution({
|
|
314
|
+
store,
|
|
315
|
+
executionId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
|
316
|
+
exitCode: 1,
|
|
317
|
+
});
|
|
318
|
+
expect(outcome.updated).toBe(false);
|
|
319
|
+
expect(outcome.reason).toBe('record-not-found');
|
|
320
|
+
});
|
|
321
|
+
});
|