start-command 0.30.4 → 0.30.5
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 +6 -0
- package/package.json +1 -1
- package/src/lib/status-formatter.js +85 -13
- package/test/regression-150.js +178 -0
- package/test/session-name-status.js +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.30.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d0656c2: Stop `--status` from fabricating a detached session exit code out of the command's own output: the terminal exit code is now read from the anchored three-line footer `start` writes (separator / `Finished:` / `Exit Code:`) in the tail of the log only, and Docker's own `.State.ExitCode` takes precedence over the log text.
|
|
8
|
+
|
|
3
9
|
## 0.30.4
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -175,20 +175,88 @@ function isDetachedSessionAlive(record) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Number of trailing bytes scanned for the terminal log footer. The footer is
|
|
180
|
+
* always appended at the very end of the log, so there is no reason to read
|
|
181
|
+
* (potentially megabytes of) command output on every `--status` call.
|
|
182
|
+
*/
|
|
183
|
+
const LOG_TAIL_BYTES = 16 * 1024;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The three-line terminal footer that `start` itself writes (see
|
|
187
|
+
* `createLogFooter()` and `createShellLogFooterSnippet()`):
|
|
188
|
+
*
|
|
189
|
+
* ==================================================
|
|
190
|
+
* Finished: 2026-07-30 23:36:20.295
|
|
191
|
+
* Exit Code: 0
|
|
192
|
+
*
|
|
193
|
+
* The whole block is anchored at line starts so that a bare `Exit Code: N`
|
|
194
|
+
* substring emitted by the wrapped command (inside a JSON payload, a quoted
|
|
195
|
+
* log excerpt, an `rg` dump, ...) can no longer be mistaken for the footer
|
|
196
|
+
* `start` appends itself (issue #150).
|
|
197
|
+
*/
|
|
198
|
+
const LOG_FOOTER_PATTERN =
|
|
199
|
+
/^={10,}[ \t]*\r?\n^Finished:[^\r\n]*\r?\n^Exit Code:[ \t]*(-?\d+)[ \t]*(?![^\r\n])/gm;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Read the last `bytes` bytes of a file as UTF-8 text.
|
|
203
|
+
*
|
|
204
|
+
* A partial first line is dropped: the slice can start in the middle of a line,
|
|
205
|
+
* and that fragment must not be treated as the beginning of a line by the
|
|
206
|
+
* anchored footer pattern.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} logPath - Path to the log file
|
|
209
|
+
* @param {number} [bytes] - Maximum number of trailing bytes to read
|
|
210
|
+
* @returns {string|null} Tail content, or null when the file cannot be read
|
|
211
|
+
*/
|
|
212
|
+
function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
|
|
213
|
+
let fd;
|
|
214
|
+
try {
|
|
215
|
+
fd = fs.openSync(logPath, 'r');
|
|
216
|
+
const size = fs.fstatSync(fd).size;
|
|
217
|
+
const length = Math.min(size, bytes);
|
|
218
|
+
const buffer = Buffer.alloc(length);
|
|
219
|
+
fs.readSync(fd, buffer, 0, length, size - length);
|
|
220
|
+
const tail = buffer.toString('utf8');
|
|
221
|
+
if (size <= bytes) {
|
|
222
|
+
return tail;
|
|
223
|
+
}
|
|
224
|
+
const firstNewline = tail.indexOf('\n');
|
|
225
|
+
return firstNewline === -1 ? '' : tail.slice(firstNewline + 1);
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
} finally {
|
|
229
|
+
if (fd !== undefined) {
|
|
230
|
+
try {
|
|
231
|
+
fs.closeSync(fd);
|
|
232
|
+
} catch {
|
|
233
|
+
// ignore
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Read the terminal exit code from the anchored footer at the end of a log.
|
|
241
|
+
* Returns null when the log has no footer yet (command still running, or the
|
|
242
|
+
* host-side watcher has not appended it yet) — never a number parsed out of the
|
|
243
|
+
* command's own output.
|
|
244
|
+
* @param {string} logPath - Path to the log file
|
|
245
|
+
* @returns {number|null}
|
|
246
|
+
*/
|
|
178
247
|
function readExitCodeFromLog(logPath) {
|
|
179
248
|
if (!logPath) {
|
|
180
249
|
return null;
|
|
181
250
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const matches = [...content.matchAll(/Exit Code:\s*(-?\d+)/g)];
|
|
185
|
-
if (matches.length === 0) {
|
|
186
|
-
return null;
|
|
187
|
-
}
|
|
188
|
-
return parseInt(matches[matches.length - 1][1], 10);
|
|
189
|
-
} catch {
|
|
251
|
+
const tail = readLogTail(logPath);
|
|
252
|
+
if (tail === null) {
|
|
190
253
|
return null;
|
|
191
254
|
}
|
|
255
|
+
const matches = [...tail.matchAll(LOG_FOOTER_PATTERN)];
|
|
256
|
+
if (matches.length === 0) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
return parseInt(matches[matches.length - 1][1], 10);
|
|
192
260
|
}
|
|
193
261
|
|
|
194
262
|
/**
|
|
@@ -280,15 +348,17 @@ function enrichDetachedStatus(record) {
|
|
|
280
348
|
// Otherwise keep the recorded/footer exit code - the command has finished.
|
|
281
349
|
} else if (!alive && enriched.status === 'executing') {
|
|
282
350
|
// Session ended but record says executing - correct it. Resolve a real exit
|
|
283
|
-
// code: prefer the
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
351
|
+
// code: prefer the backend's own record (e.g. `docker inspect
|
|
352
|
+
// .State.ExitCode`, which is authoritative and cannot be spoofed by command
|
|
353
|
+
// output — issue #150), then the anchored log footer, then `137` when the
|
|
354
|
+
// only evidence left is the OOM observation, and only fall back to the `-1`
|
|
355
|
+
// sentinel as a last resort when no real code can be obtained (issues #136,
|
|
356
|
+
// #151).
|
|
287
357
|
enriched.status = 'executed';
|
|
288
358
|
if (enriched.exitCode === null || enriched.exitCode === undefined) {
|
|
289
359
|
enriched.exitCode =
|
|
290
|
-
footerExit ??
|
|
291
360
|
backendExitCode(dockerState) ??
|
|
361
|
+
footerExit ??
|
|
292
362
|
(oomKilled === true ? 137 : -1);
|
|
293
363
|
}
|
|
294
364
|
if (!enriched.endTime) {
|
|
@@ -638,6 +708,8 @@ module.exports = {
|
|
|
638
708
|
listExecutions,
|
|
639
709
|
isDetachedSessionAlive,
|
|
640
710
|
enrichDetachedStatus,
|
|
711
|
+
readExitCodeFromLog,
|
|
712
|
+
readLogTail,
|
|
641
713
|
attachCurrentTime,
|
|
642
714
|
attachProcessIds,
|
|
643
715
|
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Regression tests for issue #150:
|
|
4
|
+
* "`$ --status` fabricates a detached session exit code from the command's own
|
|
5
|
+
* output (unanchored `Exit Code:` scan over the whole log)"
|
|
6
|
+
*
|
|
7
|
+
* The exit code of a detached session used to be derived from an *unanchored*
|
|
8
|
+
* `Exit Code: N` scan over the *whole* session log. Any text the wrapped
|
|
9
|
+
* command printed that merely contained the substring `Exit Code: N` was
|
|
10
|
+
* indistinguishable from the terminal footer `start` appends itself, so
|
|
11
|
+
* `$ --status` could report an exit code the command never produced.
|
|
12
|
+
*
|
|
13
|
+
* The fix anchors the scan on the three-line footer block `start` writes
|
|
14
|
+
* (separator / `Finished:` / `Exit Code:`) and only reads the tail of the log,
|
|
15
|
+
* where the footer always is.
|
|
16
|
+
*
|
|
17
|
+
* Reference: https://github.com/link-foundation/start/issues/150
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { describe, it, expect, beforeEach, afterEach } = require('bun:test');
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
|
|
25
|
+
const { ExecutionRecord } = require('../src/lib/execution-store');
|
|
26
|
+
const {
|
|
27
|
+
readExitCodeFromLog,
|
|
28
|
+
readLogTail,
|
|
29
|
+
enrichDetachedStatus,
|
|
30
|
+
} = require('../src/lib/status-formatter');
|
|
31
|
+
|
|
32
|
+
const SEPARATOR = '='.repeat(50);
|
|
33
|
+
|
|
34
|
+
function realFooter(exitCode) {
|
|
35
|
+
return `\n${SEPARATOR}\nFinished: 2026-07-30 23:36:20.295\nExit Code: ${exitCode}\n`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('anchored log footer parsing (issue #150)', () => {
|
|
39
|
+
let tempDir;
|
|
40
|
+
let logPath;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-150-'));
|
|
44
|
+
logPath = path.join(tempDir, 'session.log');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('ignores an `Exit Code:` substring inside the command output', () => {
|
|
52
|
+
// Exactly the payload from the incident: an older session log dumped by
|
|
53
|
+
// `rg -n` into the JSON output of the running command.
|
|
54
|
+
fs.writeFileSync(
|
|
55
|
+
logPath,
|
|
56
|
+
'{"type":"item.completed","item":{"aggregated_output":' +
|
|
57
|
+
'"40-==================================================\\n41-Finished: 2026-07-28 20:04:52.316\\n42-Exit Code: 1\\n",' +
|
|
58
|
+
'"exit_code":0,"status":"completed"}}\n'
|
|
59
|
+
);
|
|
60
|
+
expect(readExitCodeFromLog(logPath)).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('ignores a bare `Exit Code:` line without the footer block around it', () => {
|
|
64
|
+
fs.writeFileSync(logPath, 'Exit Code: 1\nstill running\n');
|
|
65
|
+
expect(readExitCodeFromLog(logPath)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('ignores an `Exit Code:` substring in the middle of a line', () => {
|
|
69
|
+
fs.writeFileSync(
|
|
70
|
+
logPath,
|
|
71
|
+
`${SEPARATOR}\nFinished: now\nlog: Exit Code: 3\n`
|
|
72
|
+
);
|
|
73
|
+
expect(readExitCodeFromLog(logPath)).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('reads the real footer that `start` appends', () => {
|
|
77
|
+
fs.writeFileSync(logPath, `hello\n${realFooter(0)}`);
|
|
78
|
+
expect(readExitCodeFromLog(logPath)).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('reads the real footer even when the output forged one earlier', () => {
|
|
82
|
+
fs.writeFileSync(
|
|
83
|
+
logPath,
|
|
84
|
+
`${SEPARATOR}\nFinished: fake\nExit Code: 1\n${realFooter(0)}`
|
|
85
|
+
);
|
|
86
|
+
// The last anchored footer wins - here the genuine one, appended last.
|
|
87
|
+
expect(readExitCodeFromLog(logPath)).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('reads negative and multi-digit codes, and tolerates CRLF logs', () => {
|
|
91
|
+
fs.writeFileSync(
|
|
92
|
+
logPath,
|
|
93
|
+
`${SEPARATOR}\r\nFinished: t\r\nExit Code: 137\r\n`
|
|
94
|
+
);
|
|
95
|
+
expect(readExitCodeFromLog(logPath)).toBe(137);
|
|
96
|
+
fs.writeFileSync(logPath, realFooter(-1));
|
|
97
|
+
expect(readExitCodeFromLog(logPath)).toBe(-1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns null for a log without a footer and for a missing file', () => {
|
|
101
|
+
fs.writeFileSync(logPath, 'still running, no footer yet\n');
|
|
102
|
+
expect(readExitCodeFromLog(logPath)).toBeNull();
|
|
103
|
+
expect(readExitCodeFromLog(path.join(tempDir, 'missing.log'))).toBeNull();
|
|
104
|
+
expect(readExitCodeFromLog(null)).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('finds the footer at the end of a large log without reading it all', () => {
|
|
108
|
+
fs.writeFileSync(
|
|
109
|
+
logPath,
|
|
110
|
+
`${'x'.repeat(2 * 1024 * 1024)}\n${realFooter(42)}`
|
|
111
|
+
);
|
|
112
|
+
expect(readExitCodeFromLog(logPath)).toBe(42);
|
|
113
|
+
// Only the tail is read, so an `Exit Code:` far from the end is invisible.
|
|
114
|
+
const tail = readLogTail(logPath);
|
|
115
|
+
expect(tail.length).toBeLessThan(32 * 1024);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('drops the partial first line of the tail so it cannot be a line start', () => {
|
|
119
|
+
const filler = 'y'.repeat(32 * 1024);
|
|
120
|
+
fs.writeFileSync(
|
|
121
|
+
logPath,
|
|
122
|
+
`${filler}${SEPARATOR}\nFinished: t\nExit Code: 7\n`
|
|
123
|
+
);
|
|
124
|
+
// The separator is a line *continuation* (the line starts with the filler),
|
|
125
|
+
// so it is not a footer even though the tail slice may begin mid-line.
|
|
126
|
+
expect(readExitCodeFromLog(logPath)).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('detached status is not derailed by forged output (issue #150)', () => {
|
|
131
|
+
let tempDir;
|
|
132
|
+
let logPath;
|
|
133
|
+
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-150-enrich-'));
|
|
136
|
+
logPath = path.join(tempDir, 'session.log');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
function makeRecord() {
|
|
144
|
+
return new ExecutionRecord({
|
|
145
|
+
command: 'solve.mjs',
|
|
146
|
+
logPath,
|
|
147
|
+
options: {
|
|
148
|
+
// A container name that cannot be inspected: liveness is unknown, the
|
|
149
|
+
// exact window in which the incident happened.
|
|
150
|
+
sessionName: `issue150-absent-${process.pid}-${Date.now()}`,
|
|
151
|
+
isolated: 'docker',
|
|
152
|
+
isolationMode: 'detached',
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
it('keeps an executing record executing when only the output claims an exit code', () => {
|
|
158
|
+
fs.writeFileSync(
|
|
159
|
+
logPath,
|
|
160
|
+
'{"aggregated_output":"41-Finished: x\\n42-Exit Code: 1\\n","exit_code":0}\n'
|
|
161
|
+
);
|
|
162
|
+
const enriched = enrichDetachedStatus(makeRecord());
|
|
163
|
+
expect(enriched.status).toBe('executing');
|
|
164
|
+
expect(enriched.exitCode).toBeNull();
|
|
165
|
+
expect(enriched.endTime).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('marks the record executed once the genuine footer is appended', () => {
|
|
169
|
+
fs.writeFileSync(
|
|
170
|
+
logPath,
|
|
171
|
+
`{"aggregated_output":"42-Exit Code: 1\\n"}\n${realFooter(0)}`
|
|
172
|
+
);
|
|
173
|
+
const enriched = enrichDetachedStatus(makeRecord());
|
|
174
|
+
expect(enriched.status).toBe('executed');
|
|
175
|
+
expect(enriched.exitCode).toBe(0);
|
|
176
|
+
expect(enriched.endTime).not.toBeNull();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -846,7 +846,12 @@ describe('Issue #151: OOMKilled is an observation, not a verdict', () => {
|
|
|
846
846
|
});
|
|
847
847
|
|
|
848
848
|
it('prefers the log footer exit code over the OOM fallback when the container is gone', () => {
|
|
849
|
-
|
|
849
|
+
// The anchored footer block `start` itself writes (issue #150).
|
|
850
|
+
fs.writeFileSync(
|
|
851
|
+
logPath,
|
|
852
|
+
`${'='.repeat(50)}\nFinished: now\nExit Code: 0\n`,
|
|
853
|
+
'utf8'
|
|
854
|
+
);
|
|
850
855
|
const record = saveDockerRecord({ oomKilled: true });
|
|
851
856
|
|
|
852
857
|
withFakeDockerMissingContainer(() => {
|