start-command 0.32.0 → 0.33.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 +23 -136
- package/src/lib/args-parser-queries.js +228 -0
- package/src/lib/args-parser.js +38 -179
- package/src/lib/docker-cleanup.js +80 -5
- package/src/lib/execution-attach.js +245 -0
- package/src/lib/execution-resume-all.js +210 -0
- package/src/lib/execution-resume.js +454 -0
- package/src/lib/execution-store.js +47 -4
- package/src/lib/exit-reason.js +234 -0
- package/src/lib/failure-handler.js +43 -41
- package/src/lib/isolation-log-utils.js +58 -0
- package/src/lib/isolation.js +13 -10
- 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 +130 -57
- 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/docker-network-integration.js +22 -14
- 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 +52 -0
- 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-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
|
@@ -15,13 +15,14 @@
|
|
|
15
15
|
* - startTime: Timestamp when execution started
|
|
16
16
|
* - endTime: Timestamp when execution completed (null while executing)
|
|
17
17
|
* - oomKilled: Docker resource-exhaustion signal when available
|
|
18
|
+
* - memoryExhausted: memory-exhaustion observation derived from the log tail
|
|
18
19
|
* - options: Execution options (isolation mode, etc.)
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
const fs = require('fs');
|
|
22
23
|
const path = require('path');
|
|
23
24
|
const os = require('os');
|
|
24
|
-
const {
|
|
25
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
25
26
|
const crypto = require('crypto');
|
|
26
27
|
|
|
27
28
|
// Synchronous wrapper using Bun's native ESM support
|
|
@@ -76,6 +77,21 @@ class ExecutionRecord {
|
|
|
76
77
|
this.endTime = options.endTime || null;
|
|
77
78
|
this.oomKilled =
|
|
78
79
|
options.oomKilled !== undefined ? options.oomKilled : undefined;
|
|
80
|
+
// Query-time hint explaining an opaque exit code (issue #162). Derived from
|
|
81
|
+
// the log tail by the status formatter; never persisted as a verdict.
|
|
82
|
+
this.exitReason =
|
|
83
|
+
options.exitReason !== undefined ? options.exitReason : undefined;
|
|
84
|
+
// Query-time memory-exhaustion observation (issue #165). A runtime that
|
|
85
|
+
// aborts on its own heap limit is invisible to `oomKilled`, so the evidence
|
|
86
|
+
// the runtime printed into the log is surfaced separately.
|
|
87
|
+
this.memoryExhausted =
|
|
88
|
+
options.memoryExhausted !== undefined
|
|
89
|
+
? options.memoryExhausted
|
|
90
|
+
: undefined;
|
|
91
|
+
this.memoryExhaustedReason =
|
|
92
|
+
options.memoryExhaustedReason !== undefined
|
|
93
|
+
? options.memoryExhaustedReason
|
|
94
|
+
: undefined;
|
|
79
95
|
this.workingDirectory = options.workingDirectory || process.cwd();
|
|
80
96
|
this.shell = options.shell || process.env.SHELL || '/bin/sh';
|
|
81
97
|
this.platform = options.platform || process.platform;
|
|
@@ -109,6 +125,18 @@ class ExecutionRecord {
|
|
|
109
125
|
if (this.oomKilled !== undefined && this.oomKilled !== null) {
|
|
110
126
|
obj.oomKilled = this.oomKilled;
|
|
111
127
|
}
|
|
128
|
+
if (this.exitReason !== undefined && this.exitReason !== null) {
|
|
129
|
+
obj.exitReason = this.exitReason;
|
|
130
|
+
}
|
|
131
|
+
if (this.memoryExhausted !== undefined && this.memoryExhausted !== null) {
|
|
132
|
+
obj.memoryExhausted = this.memoryExhausted;
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
this.memoryExhaustedReason !== undefined &&
|
|
136
|
+
this.memoryExhaustedReason !== null
|
|
137
|
+
) {
|
|
138
|
+
obj.memoryExhaustedReason = this.memoryExhaustedReason;
|
|
139
|
+
}
|
|
112
140
|
Object.assign(obj, {
|
|
113
141
|
workingDirectory: this.workingDirectory,
|
|
114
142
|
shell: this.shell,
|
|
@@ -389,7 +417,10 @@ class ExecutionStore {
|
|
|
389
417
|
*/
|
|
390
418
|
execClink(query) {
|
|
391
419
|
try {
|
|
392
|
-
|
|
420
|
+
// The query embeds record values verbatim; a shell string would let a
|
|
421
|
+
// single quote in any of them break out of the argument
|
|
422
|
+
// (CodeQL js/shell-command-constructed-from-input, issue #168).
|
|
423
|
+
const result = execFileSync('clink', [query, '--db', this.linksDbPath], {
|
|
393
424
|
encoding: 'utf8',
|
|
394
425
|
timeout: 10000,
|
|
395
426
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -480,7 +511,8 @@ class ExecutionStore {
|
|
|
480
511
|
|
|
481
512
|
/**
|
|
482
513
|
* Get an execution record by UUID or session name
|
|
483
|
-
* First tries exact UUID match, then
|
|
514
|
+
* First tries exact UUID match, then the current session name, then any
|
|
515
|
+
* session name the execution used before it was resumed
|
|
484
516
|
* @param {string} identifier - UUID or session name
|
|
485
517
|
* @returns {ExecutionRecord|null}
|
|
486
518
|
*/
|
|
@@ -495,7 +527,18 @@ class ExecutionStore {
|
|
|
495
527
|
const bySessionName = records.find(
|
|
496
528
|
(r) => r.options && r.options.sessionName === identifier
|
|
497
529
|
);
|
|
498
|
-
|
|
530
|
+
if (bySessionName) {
|
|
531
|
+
return bySessionName;
|
|
532
|
+
}
|
|
533
|
+
// Finally, honor session names this record used before it was resumed, so
|
|
534
|
+
// one logical session stays addressable across restarts (issue #162).
|
|
535
|
+
const byPreviousSessionName = records.find(
|
|
536
|
+
(r) =>
|
|
537
|
+
r.options &&
|
|
538
|
+
Array.isArray(r.options.sessionNameHistory) &&
|
|
539
|
+
r.options.sessionNameHistory.includes(identifier)
|
|
540
|
+
);
|
|
541
|
+
return byPreviousSessionName || null;
|
|
499
542
|
}
|
|
500
543
|
|
|
501
544
|
/**
|
|
@@ -0,0 +1,234 @@
|
|
|
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
|
+
const SIGNAL_NAMES = {
|
|
62
|
+
1: 'SIGHUP',
|
|
63
|
+
2: 'SIGINT',
|
|
64
|
+
3: 'SIGQUIT',
|
|
65
|
+
4: 'SIGILL',
|
|
66
|
+
6: 'SIGABRT',
|
|
67
|
+
8: 'SIGFPE',
|
|
68
|
+
9: 'SIGKILL',
|
|
69
|
+
11: 'SIGSEGV',
|
|
70
|
+
13: 'SIGPIPE',
|
|
71
|
+
15: 'SIGTERM',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Longest `memoryExhaustedReason` reported. A marker line is a single line of
|
|
76
|
+
* runtime output, but a log can contain arbitrarily long lines, and the reason
|
|
77
|
+
* travels inside status records that stay readable only when bounded.
|
|
78
|
+
*/
|
|
79
|
+
const MAX_MARKER_LINE_LENGTH = 300;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract the whole log line containing `index`, trimmed and length-bounded.
|
|
83
|
+
* @param {string} text - Log text
|
|
84
|
+
* @param {number} index - Offset of the match inside `text`
|
|
85
|
+
* @returns {string} The line the match sits on
|
|
86
|
+
*/
|
|
87
|
+
function extractLineAt(text, index) {
|
|
88
|
+
const start = text.lastIndexOf('\n', index) + 1;
|
|
89
|
+
const end = text.indexOf('\n', index);
|
|
90
|
+
const line = text.slice(start, end === -1 ? undefined : end).trim();
|
|
91
|
+
return line.length > MAX_MARKER_LINE_LENGTH
|
|
92
|
+
? `${line.slice(0, MAX_MARKER_LINE_LENGTH)}...`
|
|
93
|
+
: line;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Find the first known fatal marker in log text.
|
|
98
|
+
* @param {string|null|undefined} text - Log text (usually the log tail)
|
|
99
|
+
* @param {(marker: {reason: string, pattern: RegExp}) => boolean} [accept] - Marker filter
|
|
100
|
+
* @returns {{reason: string, line: string}|null} Marker and the line carrying it
|
|
101
|
+
*/
|
|
102
|
+
function findExitReasonMarker(text, accept = () => true) {
|
|
103
|
+
if (!text) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
for (const marker of EXIT_REASON_MARKERS) {
|
|
107
|
+
if (!accept(marker)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const match = marker.pattern.exec(text);
|
|
111
|
+
if (match) {
|
|
112
|
+
return { reason: marker.reason, line: extractLineAt(text, match.index) };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Scan log text for a known fatal marker.
|
|
120
|
+
* @param {string|null|undefined} text - Log text (usually the log tail)
|
|
121
|
+
* @returns {string|null} Detected reason, or null when nothing matched
|
|
122
|
+
*/
|
|
123
|
+
function detectExitReason(text) {
|
|
124
|
+
const marker = findExitReasonMarker(text);
|
|
125
|
+
return marker ? marker.reason : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Scan log text for a marker that specifically reports memory exhaustion.
|
|
130
|
+
* @param {string|null|undefined} text - Log text (usually the log tail)
|
|
131
|
+
* @returns {{reason: string, line: string}|null} Marker and the line carrying it
|
|
132
|
+
*/
|
|
133
|
+
function detectMemoryMarker(text) {
|
|
134
|
+
return findExitReasonMarker(text, (marker) =>
|
|
135
|
+
marker.reason.startsWith(MEMORY_EXHAUSTION_PREFIX)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve the memory-exhaustion observation for a finished execution (#165).
|
|
141
|
+
*
|
|
142
|
+
* A runtime that aborts on its own heap limit is invisible to every container
|
|
143
|
+
* signal: it dies below the container limit, so `State.OOMKilled` stays `false`
|
|
144
|
+
* and the cgroup `oom_kill` counter stays `0`. The only evidence is what the
|
|
145
|
+
* runtime printed on its way out, which sits in the very log tail the footer
|
|
146
|
+
* scan already reads.
|
|
147
|
+
*
|
|
148
|
+
* Like `oomKilled` (#151) this is an *observation*, never a verdict: it is only
|
|
149
|
+
* derived for a run that already ended abnormally, so a log that merely quotes
|
|
150
|
+
* a fatal marker (a test fixture, an `rg` dump) cannot turn a clean run into a
|
|
151
|
+
* reported memory failure.
|
|
152
|
+
*
|
|
153
|
+
* @param {{exitCode?: number|null, logTail?: string|null, oomKilled?: boolean}|null} input
|
|
154
|
+
* @returns {{memoryExhausted: true, memoryExhaustedReason: string}|null}
|
|
155
|
+
*/
|
|
156
|
+
function resolveMemoryExhaustion(input) {
|
|
157
|
+
if (!input) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const { exitCode } = input;
|
|
161
|
+
if (
|
|
162
|
+
typeof exitCode !== 'number' ||
|
|
163
|
+
!Number.isFinite(exitCode) ||
|
|
164
|
+
exitCode === 0
|
|
165
|
+
) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const marker = detectMemoryMarker(input.logTail);
|
|
170
|
+
if (marker) {
|
|
171
|
+
return { memoryExhausted: true, memoryExhaustedReason: marker.line };
|
|
172
|
+
}
|
|
173
|
+
if (input.oomKilled === true) {
|
|
174
|
+
return {
|
|
175
|
+
memoryExhausted: true,
|
|
176
|
+
memoryExhaustedReason: 'Docker reported State.OOMKilled=true',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Map a shell exit code to the signal name it encodes.
|
|
184
|
+
* @param {number|null|undefined} exitCode - Terminal exit code
|
|
185
|
+
* @returns {string|null} Signal name, or null when the code is not a signal
|
|
186
|
+
*/
|
|
187
|
+
function signalNameForExitCode(exitCode) {
|
|
188
|
+
if (typeof exitCode !== 'number' || !Number.isFinite(exitCode)) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
if (exitCode <= 128 || exitCode > 128 + 64) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return SIGNAL_NAMES[exitCode - 128] || null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resolve the best available hint for why an execution ended.
|
|
199
|
+
*
|
|
200
|
+
* Precedence: the log marker (evidence written by the command itself), then the
|
|
201
|
+
* cgroup OOM observation, then the signal encoded in the exit code.
|
|
202
|
+
*
|
|
203
|
+
* @param {{exitCode?: number|null, logTail?: string|null, oomKilled?: boolean}|null} input
|
|
204
|
+
* @returns {string|null} Exit reason hint, or null when nothing is known
|
|
205
|
+
*/
|
|
206
|
+
function resolveExitReason(input) {
|
|
207
|
+
if (!input) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const fromLog = detectExitReason(input.logTail);
|
|
212
|
+
if (fromLog) {
|
|
213
|
+
return fromLog;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (input.oomKilled === true) {
|
|
217
|
+
return 'memory-exhaustion (cgroup-oom-killer)';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const signalName = signalNameForExitCode(input.exitCode);
|
|
221
|
+
return signalName ? `signal (${signalName})` : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = {
|
|
225
|
+
EXIT_REASON_MARKERS,
|
|
226
|
+
MEMORY_EXHAUSTION_PREFIX,
|
|
227
|
+
SIGNAL_NAMES,
|
|
228
|
+
detectExitReason,
|
|
229
|
+
detectMemoryMarker,
|
|
230
|
+
findExitReasonMarker,
|
|
231
|
+
resolveMemoryExhaustion,
|
|
232
|
+
resolveExitReason,
|
|
233
|
+
signalNameForExitCode,
|
|
234
|
+
};
|
|
@@ -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'],
|
|
@@ -204,7 +204,65 @@ function runAsIsolatedUser(cmd, username) {
|
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Number of trailing bytes scanned for the terminal log footer. The footer is
|
|
209
|
+
* always appended at the very end of the log, so there is no reason to read
|
|
210
|
+
* (potentially megabytes of) command output on every `--status` call.
|
|
211
|
+
*/
|
|
212
|
+
const LOG_TAIL_BYTES = 16 * 1024;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Number of trailing bytes scanned for a fatal marker (issue #165).
|
|
216
|
+
*
|
|
217
|
+
* Wider than the footer window because a dying runtime keeps printing after the
|
|
218
|
+
* marker: V8 emits a full native stack trace between `FATAL ERROR: Reached heap
|
|
219
|
+
* limit ...` and the end of the log, which can push the marker several tens of
|
|
220
|
+
* kilobytes away from EOF.
|
|
221
|
+
*/
|
|
222
|
+
const FATAL_MARKER_TAIL_BYTES = 64 * 1024;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Read the last `bytes` bytes of a file as UTF-8 text.
|
|
226
|
+
*
|
|
227
|
+
* A partial first line is dropped: the slice can start in the middle of a line,
|
|
228
|
+
* and that fragment must not be treated as the beginning of a line by the
|
|
229
|
+
* anchored footer pattern.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} logPath - Path to the log file
|
|
232
|
+
* @param {number} [bytes] - Maximum number of trailing bytes to read
|
|
233
|
+
* @returns {string|null} Tail content, or null when the file cannot be read
|
|
234
|
+
*/
|
|
235
|
+
function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
|
|
236
|
+
let fd;
|
|
237
|
+
try {
|
|
238
|
+
fd = fs.openSync(logPath, 'r');
|
|
239
|
+
const size = fs.fstatSync(fd).size;
|
|
240
|
+
const length = Math.min(size, bytes);
|
|
241
|
+
const buffer = Buffer.alloc(length);
|
|
242
|
+
fs.readSync(fd, buffer, 0, length, size - length);
|
|
243
|
+
const tail = buffer.toString('utf8');
|
|
244
|
+
if (size <= bytes) {
|
|
245
|
+
return tail;
|
|
246
|
+
}
|
|
247
|
+
const firstNewline = tail.indexOf('\n');
|
|
248
|
+
return firstNewline === -1 ? '' : tail.slice(firstNewline + 1);
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
} finally {
|
|
252
|
+
if (fd !== undefined) {
|
|
253
|
+
try {
|
|
254
|
+
fs.closeSync(fd);
|
|
255
|
+
} catch {
|
|
256
|
+
// ignore
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
207
262
|
module.exports = {
|
|
263
|
+
LOG_TAIL_BYTES,
|
|
264
|
+
FATAL_MARKER_TAIL_BYTES,
|
|
265
|
+
readLogTail,
|
|
208
266
|
getTempRoot,
|
|
209
267
|
ensureDirectory,
|
|
210
268
|
ensureParentDirectory,
|
package/src/lib/isolation.js
CHANGED
|
@@ -27,6 +27,7 @@ const {
|
|
|
27
27
|
wrapCommandWithLogFooter,
|
|
28
28
|
} = require('./isolation-log-utils');
|
|
29
29
|
const {
|
|
30
|
+
buildAttachedDockerKeptMessage,
|
|
30
31
|
DOCKER_CONTAINER_CLEANUP_POLICY,
|
|
31
32
|
getDockerContainerCleanupPolicy,
|
|
32
33
|
shouldCleanupDockerContainer,
|
|
@@ -168,6 +169,7 @@ const {
|
|
|
168
169
|
isShellInvocationWithArgs,
|
|
169
170
|
buildShellWithArgsCmdArgs,
|
|
170
171
|
buildDisplayCommand,
|
|
172
|
+
toShellWords,
|
|
171
173
|
} = require('./shell-utils');
|
|
172
174
|
|
|
173
175
|
/** Returns true if the current process has a TTY attached. */
|
|
@@ -452,7 +454,7 @@ function runInSsh(command, options = {}) {
|
|
|
452
454
|
} else {
|
|
453
455
|
const extraFlags = shellInteractiveFlag ? [shellInteractiveFlag] : [];
|
|
454
456
|
const sshArgs = isInteractiveShellCommand(command)
|
|
455
|
-
? [sshTarget, ...command
|
|
457
|
+
? [sshTarget, ...toShellWords(command)]
|
|
456
458
|
: useExplicitShell
|
|
457
459
|
? [sshTarget, useExplicitShell, ...extraFlags, '-c', command]
|
|
458
460
|
: [sshTarget, command];
|
|
@@ -677,7 +679,7 @@ function runInDocker(command, options = {}) {
|
|
|
677
679
|
? [shellToUse, shellInteractiveFlag]
|
|
678
680
|
: [shellToUse];
|
|
679
681
|
const cmdArgs = isBareShell
|
|
680
|
-
? command
|
|
682
|
+
? toShellWords(command)
|
|
681
683
|
: isShellInvocationWithArgs(command)
|
|
682
684
|
? buildShellWithArgsCmdArgs(effectiveCommand)
|
|
683
685
|
: [...shellArgs, '-c', effectiveCommand];
|
|
@@ -747,7 +749,7 @@ function runInDocker(command, options = {}) {
|
|
|
747
749
|
containerName,
|
|
748
750
|
cleanupPolicy
|
|
749
751
|
);
|
|
750
|
-
message += `\nAttach with: docker attach ${containerName}`;
|
|
752
|
+
message += `\nAttach with: $ --attach ${containerName} (or docker attach ${containerName})`;
|
|
751
753
|
message += `\nView logs: docker logs ${containerName}`;
|
|
752
754
|
if (options.logPath) {
|
|
753
755
|
message += `\nLive log: ${options.logPath}`;
|
|
@@ -777,7 +779,7 @@ function runInDocker(command, options = {}) {
|
|
|
777
779
|
// Shell with -c: pass directly as argv (avoids double-wrapping and quote-stripping, issue #91).
|
|
778
780
|
let attachedCmdArgs;
|
|
779
781
|
if (isBareShell) {
|
|
780
|
-
const parts = command
|
|
782
|
+
const parts = toShellWords(command);
|
|
781
783
|
const bareFlag = getShellInteractiveFlag(parts[0]);
|
|
782
784
|
attachedCmdArgs =
|
|
783
785
|
bareFlag && !parts.includes(bareFlag)
|
|
@@ -819,7 +821,7 @@ function runInDocker(command, options = {}) {
|
|
|
819
821
|
let message = `Docker container "${containerName}" exited with code ${exitCode}`;
|
|
820
822
|
// Bare shell exited non-zero quickly → startup file error; suggest --norc (issue #84).
|
|
821
823
|
if (isBareShell && exitCode !== 0 && durationMs < 3000) {
|
|
822
|
-
const shell0 = command
|
|
824
|
+
const shell0 = toShellWords(command)[0];
|
|
823
825
|
// prettier-ignore
|
|
824
826
|
const norc = path.basename(shell0) === 'zsh' ? '--no-rcs' : '--norc';
|
|
825
827
|
const hint = `Hint: The shell exited immediately — its startup file (.bashrc/.zshrc) may have errors.\nTry skipping startup files: ${shell0} ${norc}`;
|
|
@@ -853,11 +855,12 @@ function runInDocker(command, options = {}) {
|
|
|
853
855
|
cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL ||
|
|
854
856
|
cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT
|
|
855
857
|
) {
|
|
856
|
-
message +=
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
858
|
+
message += buildAttachedDockerKeptMessage({
|
|
859
|
+
containerName,
|
|
860
|
+
exitCode,
|
|
861
|
+
oomKilled,
|
|
862
|
+
logPath: options.logPath,
|
|
863
|
+
});
|
|
861
864
|
}
|
|
862
865
|
|
|
863
866
|
resolve({
|