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,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit reason hints for finished executions.
|
|
3
|
+
*
|
|
4
|
+
* A numeric exit code alone is frequently misleading: a Bun/Node process that
|
|
5
|
+
* exhausts its heap aborts itself, so the container reports `exitCode 139` with
|
|
6
|
+
* `OOMKilled false` even though the run died of memory exhaustion (issue #162,
|
|
7
|
+
* related to #144, #148 and #151). `start` already reads the tail of the log to
|
|
8
|
+
* find the terminal footer, so the same tail is scanned for well-known fatal
|
|
9
|
+
* markers and the finding is surfaced as an extra `exitReason` field.
|
|
10
|
+
*
|
|
11
|
+
* The same scan answers a narrower question for consumers that key off
|
|
12
|
+
* `oomKilled`: was this run killed by memory exhaustion at all? That answer is
|
|
13
|
+
* surfaced as `memoryExhausted` plus `memoryExhaustedReason`, the log line that
|
|
14
|
+
* carried the evidence (issue #165).
|
|
15
|
+
*
|
|
16
|
+
* Both fields are hints, never verdicts: they never change `status`,
|
|
17
|
+
* `exitCode` or `oomKilled`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reasons carrying this prefix report memory exhaustion, whatever the mechanism
|
|
22
|
+
* (runtime self-abort, kernel OOM killer, failed allocation).
|
|
23
|
+
*/
|
|
24
|
+
const MEMORY_EXHAUSTION_PREFIX = 'memory-exhaustion';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fatal markers, most specific first. The first matching entry wins.
|
|
28
|
+
* @type {{reason: string, pattern: RegExp}[]}
|
|
29
|
+
*/
|
|
30
|
+
const EXIT_REASON_MARKERS = [
|
|
31
|
+
{
|
|
32
|
+
reason: 'memory-exhaustion (v8-heap-limit)',
|
|
33
|
+
pattern:
|
|
34
|
+
/FATAL ERROR:[^\r\n]*(?:Reached heap limit|JavaScript heap out of memory)/i,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
reason: 'memory-exhaustion (v8-heap-limit)',
|
|
38
|
+
pattern: /JavaScript heap out of memory/i,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
reason: 'memory-exhaustion (kernel-oom-killer)',
|
|
42
|
+
pattern:
|
|
43
|
+
/Out of memory: Kill(?:ed)? process|oom-kill(?:er)?[: ]|Killed process \d+/i,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
reason: 'memory-exhaustion (go-runtime)',
|
|
47
|
+
pattern: /fatal error: runtime: out of memory/i,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
reason: 'memory-exhaustion (allocation-failure)',
|
|
51
|
+
pattern:
|
|
52
|
+
/std::bad_alloc|Cannot allocate memory|memory allocation of \d+ bytes failed|Allocation failed - process out of memory|Array buffer allocation failed/i,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Exit codes above 128 encode the signal that killed the process.
|
|
58
|
+
* Only the signals that actually show up in command logs are named.
|
|
59
|
+
* @type {Object<number, string>}
|
|
60
|
+
*/
|
|
61
|
+
/** Rendered when an exit code is unavailable or not numeric. */
|
|
62
|
+
const UNKNOWN_EXIT_CODE = 'unknown';
|
|
63
|
+
|
|
64
|
+
const SIGNAL_NAMES = {
|
|
65
|
+
1: 'SIGHUP',
|
|
66
|
+
2: 'SIGINT',
|
|
67
|
+
3: 'SIGQUIT',
|
|
68
|
+
4: 'SIGILL',
|
|
69
|
+
6: 'SIGABRT',
|
|
70
|
+
8: 'SIGFPE',
|
|
71
|
+
9: 'SIGKILL',
|
|
72
|
+
11: 'SIGSEGV',
|
|
73
|
+
13: 'SIGPIPE',
|
|
74
|
+
15: 'SIGTERM',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Longest `memoryExhaustedReason` reported. A marker line is a single line of
|
|
79
|
+
* runtime output, but a log can contain arbitrarily long lines, and the reason
|
|
80
|
+
* travels inside status records that stay readable only when bounded.
|
|
81
|
+
*/
|
|
82
|
+
const MAX_MARKER_LINE_LENGTH = 300;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extract the whole log line containing `index`, trimmed and length-bounded.
|
|
86
|
+
* @param {string} text - Log text
|
|
87
|
+
* @param {number} index - Offset of the match inside `text`
|
|
88
|
+
* @returns {string} The line the match sits on
|
|
89
|
+
*/
|
|
90
|
+
function extractLineAt(text, index) {
|
|
91
|
+
const start = text.lastIndexOf('\n', index) + 1;
|
|
92
|
+
const end = text.indexOf('\n', index);
|
|
93
|
+
const line = text.slice(start, end === -1 ? undefined : end).trim();
|
|
94
|
+
return line.length > MAX_MARKER_LINE_LENGTH
|
|
95
|
+
? `${line.slice(0, MAX_MARKER_LINE_LENGTH)}...`
|
|
96
|
+
: line;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Find the first known fatal marker in log text.
|
|
101
|
+
* @param {string|null|undefined} text - Log text (usually the log tail)
|
|
102
|
+
* @param {(marker: {reason: string, pattern: RegExp}) => boolean} [accept] - Marker filter
|
|
103
|
+
* @returns {{reason: string, line: string}|null} Marker and the line carrying it
|
|
104
|
+
*/
|
|
105
|
+
function findExitReasonMarker(text, accept = () => true) {
|
|
106
|
+
if (!text) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
for (const marker of EXIT_REASON_MARKERS) {
|
|
110
|
+
if (!accept(marker)) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const match = marker.pattern.exec(text);
|
|
114
|
+
if (match) {
|
|
115
|
+
return { reason: marker.reason, line: extractLineAt(text, match.index) };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Scan log text for a known fatal marker.
|
|
123
|
+
* @param {string|null|undefined} text - Log text (usually the log tail)
|
|
124
|
+
* @returns {string|null} Detected reason, or null when nothing matched
|
|
125
|
+
*/
|
|
126
|
+
function detectExitReason(text) {
|
|
127
|
+
const marker = findExitReasonMarker(text);
|
|
128
|
+
return marker ? marker.reason : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Scan log text for a marker that specifically reports memory exhaustion.
|
|
133
|
+
* @param {string|null|undefined} text - Log text (usually the log tail)
|
|
134
|
+
* @returns {{reason: string, line: string}|null} Marker and the line carrying it
|
|
135
|
+
*/
|
|
136
|
+
function detectMemoryMarker(text) {
|
|
137
|
+
return findExitReasonMarker(text, (marker) =>
|
|
138
|
+
marker.reason.startsWith(MEMORY_EXHAUSTION_PREFIX)
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolve the memory-exhaustion observation for a finished execution (#165).
|
|
144
|
+
*
|
|
145
|
+
* A runtime that aborts on its own heap limit is invisible to every container
|
|
146
|
+
* signal: it dies below the container limit, so `State.OOMKilled` stays `false`
|
|
147
|
+
* and the cgroup `oom_kill` counter stays `0`. The only evidence is what the
|
|
148
|
+
* runtime printed on its way out, which sits in the very log tail the footer
|
|
149
|
+
* scan already reads.
|
|
150
|
+
*
|
|
151
|
+
* Like `oomKilled` (#151) this is an *observation*, never a verdict: it is only
|
|
152
|
+
* derived for a run that already ended abnormally, so a log that merely quotes
|
|
153
|
+
* a fatal marker (a test fixture, an `rg` dump) cannot turn a clean run into a
|
|
154
|
+
* reported memory failure.
|
|
155
|
+
*
|
|
156
|
+
* @param {{exitCode?: number|null, logTail?: string|null, oomKilled?: boolean}|null} input
|
|
157
|
+
* @returns {{memoryExhausted: true, memoryExhaustedReason: string}|null}
|
|
158
|
+
*/
|
|
159
|
+
function resolveMemoryExhaustion(input) {
|
|
160
|
+
if (!input) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const { exitCode } = input;
|
|
164
|
+
if (
|
|
165
|
+
typeof exitCode !== 'number' ||
|
|
166
|
+
!Number.isFinite(exitCode) ||
|
|
167
|
+
exitCode === 0
|
|
168
|
+
) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const marker = detectMemoryMarker(input.logTail);
|
|
173
|
+
if (marker) {
|
|
174
|
+
return { memoryExhausted: true, memoryExhaustedReason: marker.line };
|
|
175
|
+
}
|
|
176
|
+
if (input.oomKilled === true) {
|
|
177
|
+
return {
|
|
178
|
+
memoryExhausted: true,
|
|
179
|
+
memoryExhaustedReason: 'Docker reported State.OOMKilled=true',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Map a shell exit code to the signal name it encodes.
|
|
187
|
+
* @param {number|null|undefined} exitCode - Terminal exit code
|
|
188
|
+
* @returns {string|null} Signal name, or null when the code is not a signal
|
|
189
|
+
*/
|
|
190
|
+
function signalNameForExitCode(exitCode) {
|
|
191
|
+
if (typeof exitCode !== 'number' || !Number.isFinite(exitCode)) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (exitCode <= 128 || exitCode > 128 + 64) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return SIGNAL_NAMES[exitCode - 128] || null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Describe a terminal exit code, decoding the `128+n` signal convention.
|
|
202
|
+
*
|
|
203
|
+
* Single source of truth for issue #171.4: the detached Docker watcher writes
|
|
204
|
+
* this text into the log at completion time (through the shell it generates),
|
|
205
|
+
* and `--status` renders the same text at query time from the stored record.
|
|
206
|
+
* Both used to decode `128+n` on their own, so they could disagree.
|
|
207
|
+
*
|
|
208
|
+
* @param {number|string|null|undefined} exitCode - Terminal exit code
|
|
209
|
+
* @returns {{code: number|null, signal: string|null, text: string}} Description
|
|
210
|
+
*/
|
|
211
|
+
function describeExitCode(exitCode) {
|
|
212
|
+
const code =
|
|
213
|
+
typeof exitCode === 'number' ? exitCode : Number.parseInt(exitCode, 10);
|
|
214
|
+
if (!Number.isFinite(code)) {
|
|
215
|
+
return { code: null, signal: null, text: UNKNOWN_EXIT_CODE };
|
|
216
|
+
}
|
|
217
|
+
const signal = signalNameForExitCode(code);
|
|
218
|
+
return {
|
|
219
|
+
code,
|
|
220
|
+
signal,
|
|
221
|
+
text: signal ? `${code} (${signal} - 128+${code - 128})` : String(code),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Resolve the best available hint for why an execution ended.
|
|
227
|
+
*
|
|
228
|
+
* Precedence: the log marker (evidence written by the command itself), then the
|
|
229
|
+
* cgroup OOM observation, then the signal encoded in the exit code.
|
|
230
|
+
*
|
|
231
|
+
* @param {{exitCode?: number|null, logTail?: string|null, oomKilled?: boolean}|null} input
|
|
232
|
+
* @returns {string|null} Exit reason hint, or null when nothing is known
|
|
233
|
+
*/
|
|
234
|
+
function resolveExitReason(input) {
|
|
235
|
+
if (!input) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const fromLog = detectExitReason(input.logTail);
|
|
240
|
+
if (fromLog) {
|
|
241
|
+
return fromLog;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (input.oomKilled === true) {
|
|
245
|
+
return 'memory-exhaustion (cgroup-oom-killer)';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const signalName = signalNameForExitCode(input.exitCode);
|
|
249
|
+
return signalName ? `signal (${signalName})` : null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
EXIT_REASON_MARKERS,
|
|
254
|
+
UNKNOWN_EXIT_CODE,
|
|
255
|
+
MEMORY_EXHAUSTION_PREFIX,
|
|
256
|
+
SIGNAL_NAMES,
|
|
257
|
+
detectExitReason,
|
|
258
|
+
detectMemoryMarker,
|
|
259
|
+
findExitReasonMarker,
|
|
260
|
+
resolveMemoryExhaustion,
|
|
261
|
+
describeExitCode,
|
|
262
|
+
resolveExitReason,
|
|
263
|
+
signalNameForExitCode,
|
|
264
|
+
};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Handles command failures - detects repository, uploads logs, creates issues
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const {
|
|
7
|
+
const { execFileSync } = require('child_process');
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
@@ -97,7 +97,7 @@ function detectRepository(cmdName) {
|
|
|
97
97
|
let cmdPath;
|
|
98
98
|
|
|
99
99
|
try {
|
|
100
|
-
cmdPath =
|
|
100
|
+
cmdPath = execFileSync(whichCmd, [cmdName], {
|
|
101
101
|
encoding: 'utf8',
|
|
102
102
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
103
103
|
}).trim();
|
|
@@ -117,7 +117,7 @@ function detectRepository(cmdName) {
|
|
|
117
117
|
// Check if it's in npm global modules
|
|
118
118
|
let npmGlobalPath;
|
|
119
119
|
try {
|
|
120
|
-
npmGlobalPath =
|
|
120
|
+
npmGlobalPath = execFileSync('npm', ['root', '-g'], {
|
|
121
121
|
encoding: 'utf8',
|
|
122
122
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
123
123
|
}).trim();
|
|
@@ -183,42 +183,28 @@ function detectRepository(cmdName) {
|
|
|
183
183
|
packageName = cmdName;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
// Try
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
186
|
+
// Try the repository URL, then the bugs URL, from the npm registry.
|
|
187
|
+
// execFileSync, not execSync: packageName comes from a path or from the
|
|
188
|
+
// failing command's own name, and a shell would run anything it carries
|
|
189
|
+
// (CodeQL js/shell-command-injection-from-environment).
|
|
190
|
+
for (const field of ['repository.url', 'bugs.url']) {
|
|
191
|
+
let url;
|
|
192
|
+
try {
|
|
193
|
+
url = execFileSync('npm', ['view', packageName, field], {
|
|
191
194
|
encoding: 'utf8',
|
|
192
|
-
stdio: ['pipe', 'pipe', '
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
// Parse git URL to extract owner and repo
|
|
198
|
-
const parsed = parseGitUrl(npmInfo);
|
|
199
|
-
if (parsed) {
|
|
200
|
-
return parsed;
|
|
201
|
-
}
|
|
195
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
196
|
+
}).trim();
|
|
197
|
+
} catch {
|
|
198
|
+
// npm view failed: package might not exist or have no such field.
|
|
199
|
+
continue;
|
|
202
200
|
}
|
|
203
|
-
} catch {
|
|
204
|
-
// npm view failed, package might not exist or have no repository
|
|
205
|
-
}
|
|
206
201
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}).trim();
|
|
213
|
-
|
|
214
|
-
if (bugsUrl && bugsUrl.includes('github.com')) {
|
|
215
|
-
const parsed = parseGitUrl(bugsUrl);
|
|
216
|
-
if (parsed) {
|
|
217
|
-
return parsed;
|
|
218
|
-
}
|
|
202
|
+
// parseGitUrl anchors on the github.com host itself, so no separate
|
|
203
|
+
// (and substring-only) host check is needed here.
|
|
204
|
+
const parsed = parseGitUrl(url);
|
|
205
|
+
if (parsed) {
|
|
206
|
+
return parsed;
|
|
219
207
|
}
|
|
220
|
-
} catch {
|
|
221
|
-
// Fallback also failed
|
|
222
208
|
}
|
|
223
209
|
|
|
224
210
|
return null;
|
|
@@ -258,7 +244,7 @@ function parseGitUrl(url) {
|
|
|
258
244
|
*/
|
|
259
245
|
function isGhAuthenticated() {
|
|
260
246
|
try {
|
|
261
|
-
|
|
247
|
+
execFileSync('gh', ['auth', 'status'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
262
248
|
return true;
|
|
263
249
|
} catch {
|
|
264
250
|
return false;
|
|
@@ -273,7 +259,9 @@ function isGhUploadLogAvailable() {
|
|
|
273
259
|
const isWindows = process.platform === 'win32';
|
|
274
260
|
try {
|
|
275
261
|
const whichCmd = isWindows ? 'where' : 'which';
|
|
276
|
-
|
|
262
|
+
execFileSync(whichCmd, ['gh-upload-log'], {
|
|
263
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
264
|
+
});
|
|
277
265
|
return true;
|
|
278
266
|
} catch {
|
|
279
267
|
return false;
|
|
@@ -287,7 +275,7 @@ function isGhUploadLogAvailable() {
|
|
|
287
275
|
*/
|
|
288
276
|
function uploadLog(logPath) {
|
|
289
277
|
try {
|
|
290
|
-
const result =
|
|
278
|
+
const result = execFileSync('gh-upload-log', [logPath, '--public'], {
|
|
291
279
|
encoding: 'utf8',
|
|
292
280
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
293
281
|
});
|
|
@@ -320,7 +308,7 @@ function uploadLog(logPath) {
|
|
|
320
308
|
function canCreateIssue(owner, repo) {
|
|
321
309
|
try {
|
|
322
310
|
// Check if the repository exists and we have access
|
|
323
|
-
|
|
311
|
+
execFileSync('gh', ['repo', 'view', `${owner}/${repo}`, '--json', 'name'], {
|
|
324
312
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
325
313
|
});
|
|
326
314
|
return true;
|
|
@@ -364,8 +352,22 @@ function createIssue(repoInfo, fullCommand, exitCode, logUrl) {
|
|
|
364
352
|
body += `---\n`;
|
|
365
353
|
body += `*This issue was automatically created by [start-command](https://github.com/link-foundation/start)*\n`;
|
|
366
354
|
|
|
367
|
-
|
|
368
|
-
|
|
355
|
+
// execFileSync passes the title and body as argv entries, so no quoting is
|
|
356
|
+
// involved: the failing command they quote reached the shell verbatim
|
|
357
|
+
// before, and its newlines survived only as the two characters "\n"
|
|
358
|
+
// (CodeQL js/incomplete-sanitization, issue #168).
|
|
359
|
+
const result = execFileSync(
|
|
360
|
+
'gh',
|
|
361
|
+
[
|
|
362
|
+
'issue',
|
|
363
|
+
'create',
|
|
364
|
+
'--repo',
|
|
365
|
+
`${repoInfo.owner}/${repoInfo.repo}`,
|
|
366
|
+
'--title',
|
|
367
|
+
title,
|
|
368
|
+
'--body',
|
|
369
|
+
body,
|
|
370
|
+
],
|
|
369
371
|
{
|
|
370
372
|
encoding: 'utf8',
|
|
371
373
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -4,6 +4,7 @@ const { spawn } = require('child_process');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const { describeExitCode } = require('./exit-reason');
|
|
7
8
|
|
|
8
9
|
function getTempRoot() {
|
|
9
10
|
return process.env.START_TEMP_ROOT || path.join(os.tmpdir(), 'start-command');
|
|
@@ -158,8 +159,11 @@ function shellQuote(value) {
|
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
function createShellLogFooterSnippet() {
|
|
162
|
+
// UTC, to match `createLogFooter()` (which formats an ISO timestamp). Two
|
|
163
|
+
// writers of the same `Finished:` line disagreeing on the timezone makes the
|
|
164
|
+
// footer useless as a source for `endTime` (issue #170.2).
|
|
161
165
|
const dateCommand =
|
|
162
|
-
"date '+%Y-%m-%d %H:%M:%S.%3N' 2>/dev/null || date '+%Y-%m-%d %H:%M:%S'";
|
|
166
|
+
"date -u '+%Y-%m-%d %H:%M:%S.%3N' 2>/dev/null || date -u '+%Y-%m-%d %H:%M:%S'";
|
|
163
167
|
return `printf '\\n==================================================\\nFinished: %s\\nExit Code: %s\\n' "$(${dateCommand})" "$__start_command_exit"`;
|
|
164
168
|
}
|
|
165
169
|
|
|
@@ -204,7 +208,68 @@ function runAsIsolatedUser(cmd, username) {
|
|
|
204
208
|
});
|
|
205
209
|
}
|
|
206
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Number of trailing bytes scanned for the terminal log footer. The footer is
|
|
213
|
+
* always appended at the very end of the log, so there is no reason to read
|
|
214
|
+
* (potentially megabytes of) command output on every `--status` call.
|
|
215
|
+
*/
|
|
216
|
+
const LOG_TAIL_BYTES = 16 * 1024;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Number of trailing bytes scanned for a fatal marker (issue #165).
|
|
220
|
+
*
|
|
221
|
+
* Wider than the footer window because a dying runtime keeps printing after the
|
|
222
|
+
* marker: V8 emits a full native stack trace between `FATAL ERROR: Reached heap
|
|
223
|
+
* limit ...` and the end of the log, which can push the marker several tens of
|
|
224
|
+
* kilobytes away from EOF.
|
|
225
|
+
*/
|
|
226
|
+
const FATAL_MARKER_TAIL_BYTES = 64 * 1024;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Read the last `bytes` bytes of a file as UTF-8 text.
|
|
230
|
+
*
|
|
231
|
+
* A partial first line is dropped: the slice can start in the middle of a line,
|
|
232
|
+
* and that fragment must not be treated as the beginning of a line by the
|
|
233
|
+
* anchored footer pattern.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} logPath - Path to the log file
|
|
236
|
+
* @param {number} [bytes] - Maximum number of trailing bytes to read
|
|
237
|
+
* @returns {string|null} Tail content, or null when the file cannot be read
|
|
238
|
+
*/
|
|
239
|
+
function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
|
|
240
|
+
let fd;
|
|
241
|
+
try {
|
|
242
|
+
fd = fs.openSync(logPath, 'r');
|
|
243
|
+
const size = fs.fstatSync(fd).size;
|
|
244
|
+
const length = Math.min(size, bytes);
|
|
245
|
+
const buffer = Buffer.alloc(length);
|
|
246
|
+
fs.readSync(fd, buffer, 0, length, size - length);
|
|
247
|
+
const tail = buffer.toString('utf8');
|
|
248
|
+
if (size <= bytes) {
|
|
249
|
+
return tail;
|
|
250
|
+
}
|
|
251
|
+
const firstNewline = tail.indexOf('\n');
|
|
252
|
+
return firstNewline === -1 ? '' : tail.slice(firstNewline + 1);
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
} finally {
|
|
256
|
+
if (fd !== undefined) {
|
|
257
|
+
try {
|
|
258
|
+
fs.closeSync(fd);
|
|
259
|
+
} catch {
|
|
260
|
+
// ignore
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
207
266
|
module.exports = {
|
|
267
|
+
// Re-exported so the log writers and the status formatter share one decoder
|
|
268
|
+
// for the `128 + n` signal convention (issue #171.4).
|
|
269
|
+
describeExitCode,
|
|
270
|
+
LOG_TAIL_BYTES,
|
|
271
|
+
FATAL_MARKER_TAIL_BYTES,
|
|
272
|
+
readLogTail,
|
|
208
273
|
getTempRoot,
|
|
209
274
|
ensureDirectory,
|
|
210
275
|
ensureParentDirectory,
|
package/src/lib/isolation.js
CHANGED
|
@@ -27,12 +27,14 @@ const {
|
|
|
27
27
|
wrapCommandWithLogFooter,
|
|
28
28
|
} = require('./isolation-log-utils');
|
|
29
29
|
const {
|
|
30
|
+
buildAttachedDockerKeptMessage,
|
|
31
|
+
readDockerContainerState,
|
|
32
|
+
recordAttachedDockerPostMortem,
|
|
30
33
|
DOCKER_CONTAINER_CLEANUP_POLICY,
|
|
31
34
|
getDockerContainerCleanupPolicy,
|
|
32
35
|
shouldCleanupDockerContainer,
|
|
33
36
|
getDockerContainerCleanupInstructions,
|
|
34
37
|
appendDockerContainerCleanupPolicyMessage,
|
|
35
|
-
readDockerContainerOomKilled,
|
|
36
38
|
readDockerContainerStatus,
|
|
37
39
|
removeDockerContainer,
|
|
38
40
|
startDetachedDockerCompletionWatcher,
|
|
@@ -168,6 +170,7 @@ const {
|
|
|
168
170
|
isShellInvocationWithArgs,
|
|
169
171
|
buildShellWithArgsCmdArgs,
|
|
170
172
|
buildDisplayCommand,
|
|
173
|
+
toShellWords,
|
|
171
174
|
} = require('./shell-utils');
|
|
172
175
|
|
|
173
176
|
/** Returns true if the current process has a TTY attached. */
|
|
@@ -452,7 +455,7 @@ function runInSsh(command, options = {}) {
|
|
|
452
455
|
} else {
|
|
453
456
|
const extraFlags = shellInteractiveFlag ? [shellInteractiveFlag] : [];
|
|
454
457
|
const sshArgs = isInteractiveShellCommand(command)
|
|
455
|
-
? [sshTarget, ...command
|
|
458
|
+
? [sshTarget, ...toShellWords(command)]
|
|
456
459
|
: useExplicitShell
|
|
457
460
|
? [sshTarget, useExplicitShell, ...extraFlags, '-c', command]
|
|
458
461
|
: [sshTarget, command];
|
|
@@ -677,7 +680,7 @@ function runInDocker(command, options = {}) {
|
|
|
677
680
|
? [shellToUse, shellInteractiveFlag]
|
|
678
681
|
: [shellToUse];
|
|
679
682
|
const cmdArgs = isBareShell
|
|
680
|
-
? command
|
|
683
|
+
? toShellWords(command)
|
|
681
684
|
: isShellInvocationWithArgs(command)
|
|
682
685
|
? buildShellWithArgsCmdArgs(effectiveCommand)
|
|
683
686
|
: [...shellArgs, '-c', effectiveCommand];
|
|
@@ -732,7 +735,8 @@ function runInDocker(command, options = {}) {
|
|
|
732
735
|
startDetachedDockerCompletionWatcher(
|
|
733
736
|
containerName,
|
|
734
737
|
cleanupPolicy,
|
|
735
|
-
options.logPath
|
|
738
|
+
options.logPath,
|
|
739
|
+
options.executionId || null
|
|
736
740
|
);
|
|
737
741
|
|
|
738
742
|
let message = `Command started in detached docker container: ${containerName}`;
|
|
@@ -747,7 +751,7 @@ function runInDocker(command, options = {}) {
|
|
|
747
751
|
containerName,
|
|
748
752
|
cleanupPolicy
|
|
749
753
|
);
|
|
750
|
-
message += `\nAttach with: docker attach ${containerName}`;
|
|
754
|
+
message += `\nAttach with: $ --attach ${containerName} (or docker attach ${containerName})`;
|
|
751
755
|
message += `\nView logs: docker logs ${containerName}`;
|
|
752
756
|
if (options.logPath) {
|
|
753
757
|
message += `\nLive log: ${options.logPath}`;
|
|
@@ -777,7 +781,7 @@ function runInDocker(command, options = {}) {
|
|
|
777
781
|
// Shell with -c: pass directly as argv (avoids double-wrapping and quote-stripping, issue #91).
|
|
778
782
|
let attachedCmdArgs;
|
|
779
783
|
if (isBareShell) {
|
|
780
|
-
const parts = command
|
|
784
|
+
const parts = toShellWords(command);
|
|
781
785
|
const bareFlag = getShellInteractiveFlag(parts[0]);
|
|
782
786
|
attachedCmdArgs =
|
|
783
787
|
bareFlag && !parts.includes(bareFlag)
|
|
@@ -819,7 +823,7 @@ function runInDocker(command, options = {}) {
|
|
|
819
823
|
let message = `Docker container "${containerName}" exited with code ${exitCode}`;
|
|
820
824
|
// Bare shell exited non-zero quickly → startup file error; suggest --norc (issue #84).
|
|
821
825
|
if (isBareShell && exitCode !== 0 && durationMs < 3000) {
|
|
822
|
-
const shell0 = command
|
|
826
|
+
const shell0 = toShellWords(command)[0];
|
|
823
827
|
// prettier-ignore
|
|
824
828
|
const norc = path.basename(shell0) === 'zsh' ? '--no-rcs' : '--norc';
|
|
825
829
|
const hint = `Hint: The shell exited immediately — its startup file (.bashrc/.zshrc) may have errors.\nTry skipping startup files: ${shell0} ${norc}`;
|
|
@@ -827,7 +831,15 @@ function runInDocker(command, options = {}) {
|
|
|
827
831
|
message += `\n${hint}`;
|
|
828
832
|
}
|
|
829
833
|
|
|
830
|
-
const
|
|
834
|
+
const dockerState = readDockerContainerState(containerName);
|
|
835
|
+
const oomKilled = dockerState ? dockerState.oomKilled : null;
|
|
836
|
+
const postMortem = (removed) =>
|
|
837
|
+
recordAttachedDockerPostMortem({
|
|
838
|
+
containerName,
|
|
839
|
+
state: dockerState,
|
|
840
|
+
logPath: options.logPath,
|
|
841
|
+
removed,
|
|
842
|
+
});
|
|
831
843
|
const launchFailed =
|
|
832
844
|
!containerExistedBeforeLaunch &&
|
|
833
845
|
readDockerContainerStatus(containerName) === 'created';
|
|
@@ -842,22 +854,23 @@ function runInDocker(command, options = {}) {
|
|
|
842
854
|
shouldCleanupDockerContainer(cleanupPolicy, exitCode, oomKilled)
|
|
843
855
|
) {
|
|
844
856
|
if (removeDockerContainer(containerName, options.logPath)) {
|
|
845
|
-
message += `\nContainer removed after completion
|
|
857
|
+
message += `\nContainer removed after completion.${postMortem(true)}`;
|
|
846
858
|
} else {
|
|
847
859
|
message += `\nWarning: failed to remove container automatically.`;
|
|
848
860
|
message += `\nRemove when done: docker rm -f ${containerName}`;
|
|
849
861
|
}
|
|
850
862
|
} else if (cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP) {
|
|
851
|
-
message += `\n${getDockerContainerCleanupInstructions(containerName)}`;
|
|
863
|
+
message += `\n${getDockerContainerCleanupInstructions(containerName)}${postMortem(false)}`;
|
|
852
864
|
} else if (
|
|
853
865
|
cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL ||
|
|
854
866
|
cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT
|
|
855
867
|
) {
|
|
856
|
-
message +=
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
868
|
+
message += `${buildAttachedDockerKeptMessage({
|
|
869
|
+
containerName,
|
|
870
|
+
exitCode,
|
|
871
|
+
oomKilled,
|
|
872
|
+
logPath: options.logPath,
|
|
873
|
+
})}${postMortem(false)}`;
|
|
861
874
|
}
|
|
862
875
|
|
|
863
876
|
resolve({
|