start-command 0.33.0 → 0.34.1
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 +1 -1
- package/src/bin/cli.js +11 -2
- package/src/lib/command-stream.js +4 -2
- package/src/lib/detached-finalize.js +305 -0
- package/src/lib/docker-cleanup.js +185 -38
- package/src/lib/docker-post-mortem.js +382 -0
- package/src/lib/execution-resume-all.js +2 -1
- package/src/lib/execution-resume.js +2 -1
- package/src/lib/execution-store.js +46 -2
- package/src/lib/exit-reason.js +54 -0
- package/src/lib/isolation-log-utils.js +16 -5
- package/src/lib/isolation.js +17 -7
- package/src/lib/spawn-helpers.js +4 -2
- package/src/lib/status-formatter.js +115 -24
- package/test/execution-store.js +5 -2
- package/test/regression-170.js +324 -0
- package/test/regression-171.js +562 -0
- package/test/regression-174.js +397 -0
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
|
|
|
@@ -14,11 +14,17 @@ const {
|
|
|
14
14
|
} = require('./output-blocks');
|
|
15
15
|
const { collectProcessIds } = require('./execution-control');
|
|
16
16
|
const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
|
|
17
|
-
const {
|
|
17
|
+
const {
|
|
18
|
+
describeExitCode,
|
|
19
|
+
resolveExitReason,
|
|
20
|
+
resolveMemoryExhaustion,
|
|
21
|
+
} = require('./exit-reason');
|
|
18
22
|
const {
|
|
19
23
|
FATAL_MARKER_TAIL_BYTES,
|
|
20
24
|
readLogTail,
|
|
21
25
|
} = require('./isolation-log-utils');
|
|
26
|
+
const { normalizeDockerTimestamp } = require('./docker-post-mortem');
|
|
27
|
+
const { END_TIME_SOURCE } = require('./detached-finalize');
|
|
22
28
|
|
|
23
29
|
/**
|
|
24
30
|
* Inspect the live state of a detached docker container by name.
|
|
@@ -44,7 +50,8 @@ function inspectDockerState(sessionName) {
|
|
|
44
50
|
[
|
|
45
51
|
'inspect',
|
|
46
52
|
'-f',
|
|
47
|
-
'{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}}'
|
|
53
|
+
'{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}} ' +
|
|
54
|
+
'{{.State.StartedAt}} {{.State.FinishedAt}}',
|
|
48
55
|
sessionName,
|
|
49
56
|
],
|
|
50
57
|
getDockerSpawnOptions({
|
|
@@ -56,13 +63,18 @@ function inspectDockerState(sessionName) {
|
|
|
56
63
|
if (result.error || result.status !== 0 || !result.stdout) {
|
|
57
64
|
return null;
|
|
58
65
|
}
|
|
59
|
-
|
|
66
|
+
// Parsed positionally and defensively: an older docker (or a stubbed one)
|
|
67
|
+
// may answer with fewer fields than the template asks for.
|
|
68
|
+
const [runningRaw, exitRaw, oomKilledRaw, startedRaw, finishedRaw] =
|
|
69
|
+
result.stdout.trim().split(/\s+/);
|
|
60
70
|
const exitCode = Number.parseInt(exitRaw, 10);
|
|
61
71
|
return {
|
|
62
72
|
running: runningRaw === 'true',
|
|
63
73
|
exitCode: Number.isFinite(exitCode) ? exitCode : null,
|
|
64
74
|
oomKilled:
|
|
65
75
|
oomKilledRaw === 'true' ? true : oomKilledRaw === 'false' ? false : null,
|
|
76
|
+
startedAt: normalizeDockerTimestamp(startedRaw),
|
|
77
|
+
finishedAt: normalizeDockerTimestamp(finishedRaw),
|
|
66
78
|
};
|
|
67
79
|
}
|
|
68
80
|
|
|
@@ -193,7 +205,50 @@ function isDetachedSessionAlive(record) {
|
|
|
193
205
|
* `start` appends itself (issue #150).
|
|
194
206
|
*/
|
|
195
207
|
const LOG_FOOTER_PATTERN =
|
|
196
|
-
/^={10,}[ \t]*\r?\n^Finished:[^\r\n]
|
|
208
|
+
/^={10,}[ \t]*\r?\n^Finished:[ \t]*([^\r\n]*)\r?\n^Exit Code:[ \t]*(-?\d+)[ \t]*(?![^\r\n])/gm;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parse the `Finished:` line of the footer into an ISO timestamp.
|
|
212
|
+
*
|
|
213
|
+
* `start` writes it as `YYYY-MM-DD HH:MM:SS[.mmm]` in UTC (both from JS and
|
|
214
|
+
* from the shell snippet). Anything else — a truncated log, a test fixture, a
|
|
215
|
+
* future format — yields null so the caller falls back instead of inventing a
|
|
216
|
+
* timestamp out of an unparseable string.
|
|
217
|
+
*
|
|
218
|
+
* @param {string|null|undefined} value - Raw text after `Finished:`
|
|
219
|
+
* @returns {string|null} ISO timestamp, or null
|
|
220
|
+
*/
|
|
221
|
+
function parseFooterTimestamp(value) {
|
|
222
|
+
if (typeof value !== 'string') {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
const trimmed = value.trim();
|
|
226
|
+
if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?Z?$/.test(trimmed)) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const parsed = Date.parse(`${trimmed.replace(' ', 'T').replace(/Z$/, '')}Z`);
|
|
230
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Read the terminal footer written at the very end of a log.
|
|
235
|
+
* @param {string|null} tail - Tail of the execution log
|
|
236
|
+
* @returns {{exitCode: number|null, finishedAt: string|null}} Footer facts
|
|
237
|
+
*/
|
|
238
|
+
function parseFooterFromTail(tail) {
|
|
239
|
+
if (tail === null || tail === undefined) {
|
|
240
|
+
return { exitCode: null, finishedAt: null };
|
|
241
|
+
}
|
|
242
|
+
const matches = [...tail.matchAll(LOG_FOOTER_PATTERN)];
|
|
243
|
+
if (matches.length === 0) {
|
|
244
|
+
return { exitCode: null, finishedAt: null };
|
|
245
|
+
}
|
|
246
|
+
const last = matches[matches.length - 1];
|
|
247
|
+
return {
|
|
248
|
+
exitCode: parseInt(last[2], 10),
|
|
249
|
+
finishedAt: parseFooterTimestamp(last[1]),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
197
252
|
|
|
198
253
|
/**
|
|
199
254
|
* Read the terminal exit code from the anchored footer at the end of a log.
|
|
@@ -207,11 +262,7 @@ function parseExitCodeFromTail(tail) {
|
|
|
207
262
|
if (tail === null || tail === undefined) {
|
|
208
263
|
return null;
|
|
209
264
|
}
|
|
210
|
-
|
|
211
|
-
if (matches.length === 0) {
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
return parseInt(matches[matches.length - 1][1], 10);
|
|
265
|
+
return parseFooterFromTail(tail).exitCode;
|
|
215
266
|
}
|
|
216
267
|
|
|
217
268
|
function readExitCodeFromLog(logPath) {
|
|
@@ -230,8 +281,43 @@ function readExitCodeFromLog(logPath) {
|
|
|
230
281
|
* @param {string|null} logTail - Tail of the execution log (already read once)
|
|
231
282
|
* @returns {Object} Possibly updated execution record
|
|
232
283
|
*/
|
|
284
|
+
/**
|
|
285
|
+
* Decide the moment a finished execution ended, and say where that moment came
|
|
286
|
+
* from (issue #170.2).
|
|
287
|
+
*
|
|
288
|
+
* Precedence: the container's own `State.FinishedAt`, then the `Finished:` line
|
|
289
|
+
* of the footer `start` wrote itself, and only then the time the end was
|
|
290
|
+
* observed — which is explicitly marked as such, so a consumer can tell a real
|
|
291
|
+
* finish time from a query-time observation.
|
|
292
|
+
*
|
|
293
|
+
* @param {Object} enriched - Record copy being enriched (mutated)
|
|
294
|
+
* @param {Object|null} dockerState - Inspected docker state, when available
|
|
295
|
+
* @param {string|null} footerFinishedAt - Footer timestamp, when parseable
|
|
296
|
+
* @returns {void}
|
|
297
|
+
*/
|
|
298
|
+
function applyEndTime(enriched, dockerState, footerFinishedAt) {
|
|
299
|
+
if (enriched.endTime) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const dockerFinishedAt = dockerState ? dockerState.finishedAt : null;
|
|
303
|
+
if (dockerFinishedAt) {
|
|
304
|
+
enriched.endTime = new Date(dockerFinishedAt).toISOString();
|
|
305
|
+
enriched.endTimeSource = END_TIME_SOURCE.DOCKER_FINISHED_AT;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (footerFinishedAt) {
|
|
309
|
+
enriched.endTime = footerFinishedAt;
|
|
310
|
+
enriched.endTimeSource = END_TIME_SOURCE.LOG_FOOTER;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
enriched.endTime = new Date().toISOString();
|
|
314
|
+
enriched.endTimeSource = END_TIME_SOURCE.OBSERVED_AT;
|
|
315
|
+
enriched.observedAt = enriched.endTime;
|
|
316
|
+
}
|
|
317
|
+
|
|
233
318
|
function resolveDetachedStatus(record, logTail) {
|
|
234
|
-
const
|
|
319
|
+
const footer = parseFooterFromTail(logTail);
|
|
320
|
+
const footerExit = footer.exitCode;
|
|
235
321
|
const dockerState = isDetachedDockerRecord(record)
|
|
236
322
|
? readDockerState(record)
|
|
237
323
|
: null;
|
|
@@ -268,9 +354,7 @@ function resolveDetachedStatus(record, logTail) {
|
|
|
268
354
|
const enriched = cloneRecord();
|
|
269
355
|
enriched.status = 'executed';
|
|
270
356
|
enriched.exitCode = footerExit;
|
|
271
|
-
|
|
272
|
-
enriched.endTime = new Date().toISOString();
|
|
273
|
-
}
|
|
357
|
+
applyEndTime(enriched, dockerState, footer.finishedAt);
|
|
274
358
|
return enriched;
|
|
275
359
|
}
|
|
276
360
|
if (oomKilled === true) {
|
|
@@ -282,9 +366,7 @@ function resolveDetachedStatus(record, logTail) {
|
|
|
282
366
|
const enriched = cloneRecord();
|
|
283
367
|
enriched.status = 'executed';
|
|
284
368
|
enriched.exitCode = 137;
|
|
285
|
-
|
|
286
|
-
enriched.endTime = new Date().toISOString();
|
|
287
|
-
}
|
|
369
|
+
applyEndTime(enriched, dockerState, footer.finishedAt);
|
|
288
370
|
return enriched;
|
|
289
371
|
}
|
|
290
372
|
}
|
|
@@ -324,9 +406,7 @@ function resolveDetachedStatus(record, logTail) {
|
|
|
324
406
|
footerExit ??
|
|
325
407
|
(oomKilled === true ? 137 : -1);
|
|
326
408
|
}
|
|
327
|
-
|
|
328
|
-
enriched.endTime = new Date().toISOString();
|
|
329
|
-
}
|
|
409
|
+
applyEndTime(enriched, dockerState, footer.finishedAt);
|
|
330
410
|
}
|
|
331
411
|
|
|
332
412
|
return enriched;
|
|
@@ -562,7 +642,7 @@ function formatRecordAsText(record) {
|
|
|
562
642
|
`UUID: ${obj.uuid}`,
|
|
563
643
|
`Status: ${obj.status}`,
|
|
564
644
|
`Command: ${obj.command}`,
|
|
565
|
-
`Exit Code: ${obj.exitCode !== null ? obj.exitCode : 'N/A'}`,
|
|
645
|
+
`Exit Code: ${obj.exitCode !== null ? describeExitCode(obj.exitCode).text : 'N/A'}`,
|
|
566
646
|
...(obj.oomKilled !== undefined
|
|
567
647
|
? [`OOM Killed: ${obj.oomKilled}`]
|
|
568
648
|
: []),
|
|
@@ -584,10 +664,19 @@ function formatRecordAsText(record) {
|
|
|
584
664
|
if (obj.currentTime) {
|
|
585
665
|
lines.push(`Current Time: ${obj.currentTime}`);
|
|
586
666
|
}
|
|
587
|
-
lines.push(
|
|
588
|
-
|
|
589
|
-
`
|
|
590
|
-
|
|
667
|
+
lines.push(`End Time: ${obj.endTime || 'N/A'}`);
|
|
668
|
+
if (obj.endTimeSource) {
|
|
669
|
+
// Where `End Time` came from: a real finish time, or merely when `start`
|
|
670
|
+
// noticed the execution was over (issue #170.2).
|
|
671
|
+
lines.push(`End Time Source: ${obj.endTimeSource}`);
|
|
672
|
+
}
|
|
673
|
+
if (obj.containerStartedAt) {
|
|
674
|
+
lines.push(`Container Started: ${obj.containerStartedAt}`);
|
|
675
|
+
}
|
|
676
|
+
if (obj.staleDetectedAt) {
|
|
677
|
+
lines.push(`Stale Detected At: ${obj.staleDetectedAt}`);
|
|
678
|
+
}
|
|
679
|
+
lines.push(`Log Path: ${obj.logPath}`);
|
|
591
680
|
|
|
592
681
|
// Format options as nested list instead of JSON
|
|
593
682
|
const optionEntries = Object.entries(obj.options || {}).filter(
|
|
@@ -778,6 +867,8 @@ module.exports = {
|
|
|
778
867
|
listExecutions,
|
|
779
868
|
isDetachedSessionAlive,
|
|
780
869
|
enrichDetachedStatus,
|
|
870
|
+
parseFooterFromTail,
|
|
871
|
+
parseFooterTimestamp,
|
|
781
872
|
readExitCodeFromLog,
|
|
782
873
|
readLogTail,
|
|
783
874
|
attachExitReason,
|
package/test/execution-store.js
CHANGED
|
@@ -573,11 +573,14 @@ describe('ExecutionStore cleanupStale', () => {
|
|
|
573
573
|
const result = store.cleanupStale({ dryRun: false });
|
|
574
574
|
expect(result.cleaned).toBe(1);
|
|
575
575
|
|
|
576
|
-
// Verify it's now "executed" with exit code -1
|
|
576
|
+
// Verify it's now "executed" with exit code -1. The finish time stays
|
|
577
|
+
// unknown — cleanup records *when the loss was detected* instead of
|
|
578
|
+
// fabricating a finish time it cannot know (issue #170.3).
|
|
577
579
|
retrieved = store.get(record.uuid);
|
|
578
580
|
expect(retrieved.status).toBe(ExecutionStatus.EXECUTED);
|
|
579
581
|
expect(retrieved.exitCode).toBe(-1);
|
|
580
|
-
expect(retrieved.endTime).
|
|
582
|
+
expect(retrieved.endTime).toBeNull();
|
|
583
|
+
expect(retrieved.staleDetectedAt).toBeTruthy();
|
|
581
584
|
});
|
|
582
585
|
|
|
583
586
|
it('should handle custom max age', () => {
|
|
@@ -0,0 +1,324 @@
|
|
|
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
|
+
// `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');
|
|
308
|
+
expect(stored.endTimeSource).toBe('observed-at');
|
|
309
|
+
expect(stored.observedAt).toBe(stored.endTime);
|
|
310
|
+
expect(new Date(stored.endTime).getUTCFullYear()).toBeGreaterThan(2000);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('never fails when the record is gone', () => {
|
|
314
|
+
const appFolder = makeTempDir('finalize-170-missing-');
|
|
315
|
+
const store = new ExecutionStore({ appFolder, useLinks: false });
|
|
316
|
+
const outcome = finalizeDetachedExecution({
|
|
317
|
+
store,
|
|
318
|
+
executionId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
|
319
|
+
exitCode: 1,
|
|
320
|
+
});
|
|
321
|
+
expect(outcome.updated).toBe(false);
|
|
322
|
+
expect(outcome.reason).toBe('record-not-found');
|
|
323
|
+
});
|
|
324
|
+
});
|