start-command 0.35.4 → 0.37.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 +14 -0
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/bin/cli.js +48 -7
- package/src/lib/args-parser-queries.js +14 -0
- package/src/lib/args-parser.js +32 -1
- package/src/lib/atomic-write.js +34 -0
- package/src/lib/attached-diagnostics.js +90 -0
- package/src/lib/cgroup-memory.js +43 -11
- package/src/lib/command-builder.js +43 -0
- package/src/lib/cpu-penalty-monitor.js +226 -0
- package/src/lib/cpu-penalty.js +175 -0
- package/src/lib/detached-finalize.js +16 -0
- package/src/lib/docker-cleanup.js +35 -7
- package/src/lib/docker-command-handoff.js +75 -0
- package/src/lib/docker-labels.js +33 -0
- package/src/lib/docker-resource-limits.js +28 -0
- package/src/lib/docker-resource-options.js +180 -0
- package/src/lib/docker-runtime-args.js +23 -0
- package/src/lib/docker-snapshot-safety.js +164 -0
- package/src/lib/execution-recovery.js +153 -30
- package/src/lib/execution-resume-all.js +11 -0
- package/src/lib/execution-resume.js +314 -54
- package/src/lib/execution-store.js +25 -121
- package/src/lib/exit-evidence.js +123 -0
- package/src/lib/exit-reason.js +24 -6
- package/src/lib/failure-handler.js +15 -5
- package/src/lib/isolation.js +48 -7
- package/src/lib/launch-owner.js +25 -0
- package/src/lib/launch-persistence.js +54 -0
- package/src/lib/log-sanitizer.js +267 -0
- package/src/lib/log-uploader.js +38 -19
- package/src/lib/query-commands.js +18 -6
- package/src/lib/resume-resources.js +88 -0
- package/src/lib/shell-utils.js +67 -17
- package/src/lib/status-formatter.js +28 -0
- package/src/lib/store-lock.js +133 -0
- package/src/lib/usage.js +16 -1
- package/test/ci-workflow-invariants.js +15 -14
- package/test/docker-command-handoff.js +107 -0
- package/test/docker-runtime-options.js +10 -0
- package/test/docker-snapshot-safety.js +106 -0
- package/test/execution-resume.js +19 -11
- package/test/exit-reason.js +6 -1
- package/test/failure-handler.js +11 -0
- package/test/isolation.js +5 -4
- package/test/javascript-first-ci.js +216 -0
- package/test/log-sanitizer.js +306 -0
- package/test/regression-174.js +3 -1
- package/test/regression-176.js +7 -0
- package/test/regression-180.js +17 -10
- package/test/regression-182.js +7 -3
- package/test/regression-185.js +4 -9
- package/test/regression-187.js +11 -2
- package/test/regression-189.js +224 -0
- package/test/regression-190.js +111 -0
- package/test/regression-191.js +160 -0
- package/test/regression-192.js +66 -0
- package/test/regression-193.js +251 -0
- package/test/regression-194.js +182 -0
- package/test/regression-198.js +281 -0
- package/test/regression-199.js +79 -0
- package/test/regression-201.js +25 -0
- package/test/regression-202.js +92 -0
- package/test/regression-91.js +23 -20
- package/test/shared-golden.js +54 -0
- package/test/status-query.js +97 -15
|
@@ -24,6 +24,7 @@ const path = require('path');
|
|
|
24
24
|
const os = require('os');
|
|
25
25
|
const { execFileSync, spawnSync } = require('child_process');
|
|
26
26
|
const crypto = require('crypto');
|
|
27
|
+
const { atomicWrite } = require('./atomic-write');
|
|
27
28
|
|
|
28
29
|
// Synchronous wrapper using Bun's native ESM support
|
|
29
30
|
// This works because Bun handles ESM/CJS interop
|
|
@@ -51,8 +52,7 @@ const DEFAULT_APP_FOLDER = path.join(os.homedir(), '.start-command');
|
|
|
51
52
|
const LINO_DB_FILE = 'executions.lino';
|
|
52
53
|
const LINKS_DB_FILE = 'executions.links';
|
|
53
54
|
const LOCK_FILE = 'executions.lock';
|
|
54
|
-
const
|
|
55
|
-
const LOCK_STALE_MS = 60000; // Consider lock stale after 60 seconds
|
|
55
|
+
const { LockManager } = require('./store-lock');
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* Execution status enumeration
|
|
@@ -128,6 +128,7 @@ class ExecutionRecord {
|
|
|
128
128
|
this.shell = options.shell || process.env.SHELL || '/bin/sh';
|
|
129
129
|
this.platform = options.platform || process.platform;
|
|
130
130
|
this.options = options.options || {};
|
|
131
|
+
this.cpuPenalty = options.cpuPenalty;
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
/**
|
|
@@ -154,6 +155,9 @@ class ExecutionRecord {
|
|
|
154
155
|
startTime: this.startTime,
|
|
155
156
|
endTime: this.endTime,
|
|
156
157
|
};
|
|
158
|
+
if (this.cpuPenalty) {
|
|
159
|
+
obj.cpuPenalty = this.cpuPenalty;
|
|
160
|
+
}
|
|
157
161
|
if (this.attempt) {
|
|
158
162
|
obj.attempt = this.attempt;
|
|
159
163
|
}
|
|
@@ -210,123 +214,6 @@ class ExecutionRecord {
|
|
|
210
214
|
}
|
|
211
215
|
}
|
|
212
216
|
|
|
213
|
-
/**
|
|
214
|
-
* File-based lock manager
|
|
215
|
-
*/
|
|
216
|
-
class LockManager {
|
|
217
|
-
constructor(lockFilePath) {
|
|
218
|
-
this.lockFilePath = lockFilePath;
|
|
219
|
-
this.lockAcquired = false;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Acquire an exclusive lock
|
|
224
|
-
* @param {number} timeout - Maximum time to wait for lock in ms
|
|
225
|
-
* @returns {boolean} True if lock acquired
|
|
226
|
-
*/
|
|
227
|
-
acquire(timeout = LOCK_TIMEOUT_MS) {
|
|
228
|
-
const startTime = Date.now();
|
|
229
|
-
|
|
230
|
-
while (Date.now() - startTime < timeout) {
|
|
231
|
-
try {
|
|
232
|
-
// Check if existing lock is stale
|
|
233
|
-
if (fs.existsSync(this.lockFilePath)) {
|
|
234
|
-
const lockData = this.readLockFile();
|
|
235
|
-
if (lockData && this.isLockStale(lockData)) {
|
|
236
|
-
// Remove stale lock
|
|
237
|
-
fs.unlinkSync(this.lockFilePath);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Try to create lock file exclusively
|
|
242
|
-
const lockData = {
|
|
243
|
-
pid: process.pid,
|
|
244
|
-
timestamp: Date.now(),
|
|
245
|
-
hostname: os.hostname(),
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
fs.writeFileSync(this.lockFilePath, JSON.stringify(lockData), {
|
|
249
|
-
flag: 'wx', // Fail if file exists
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
this.lockAcquired = true;
|
|
253
|
-
return true;
|
|
254
|
-
} catch (err) {
|
|
255
|
-
if (err.code === 'EEXIST') {
|
|
256
|
-
// Lock file exists, wait and retry
|
|
257
|
-
this.sleep(100);
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
throw err;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Release the lock
|
|
269
|
-
*/
|
|
270
|
-
release() {
|
|
271
|
-
if (this.lockAcquired) {
|
|
272
|
-
try {
|
|
273
|
-
fs.unlinkSync(this.lockFilePath);
|
|
274
|
-
} catch {
|
|
275
|
-
// Ignore errors during release
|
|
276
|
-
}
|
|
277
|
-
this.lockAcquired = false;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Read lock file data
|
|
283
|
-
*/
|
|
284
|
-
readLockFile() {
|
|
285
|
-
try {
|
|
286
|
-
const content = fs.readFileSync(this.lockFilePath, 'utf8');
|
|
287
|
-
return JSON.parse(content);
|
|
288
|
-
} catch {
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Check if lock is stale
|
|
295
|
-
*/
|
|
296
|
-
isLockStale(lockData) {
|
|
297
|
-
if (!lockData || !lockData.timestamp) {
|
|
298
|
-
return true;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Check if lock is too old
|
|
302
|
-
if (Date.now() - lockData.timestamp > LOCK_STALE_MS) {
|
|
303
|
-
return true;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Check if the process that holds the lock is still running
|
|
307
|
-
if (lockData.pid && lockData.hostname === os.hostname()) {
|
|
308
|
-
try {
|
|
309
|
-
process.kill(lockData.pid, 0); // Signal 0 just checks if process exists
|
|
310
|
-
return false; // Process exists, lock is valid
|
|
311
|
-
} catch {
|
|
312
|
-
return true; // Process doesn't exist, lock is stale
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
return false;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Simple sleep function
|
|
321
|
-
*/
|
|
322
|
-
sleep(ms) {
|
|
323
|
-
const end = Date.now() + ms;
|
|
324
|
-
while (Date.now() < end) {
|
|
325
|
-
// Busy wait (not ideal but works for short durations)
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
217
|
/**
|
|
331
218
|
* Check if clink is installed
|
|
332
219
|
* @returns {boolean}
|
|
@@ -412,7 +299,7 @@ class ExecutionStore {
|
|
|
412
299
|
writeLinoRecords(records) {
|
|
413
300
|
const data = records.map((r) => r.toObject());
|
|
414
301
|
const content = encodeSync(data);
|
|
415
|
-
|
|
302
|
+
atomicWrite(this.linoDbPath, content);
|
|
416
303
|
this.log(`Wrote ${records.length} records to lino file`);
|
|
417
304
|
}
|
|
418
305
|
|
|
@@ -529,7 +416,7 @@ class ExecutionStore {
|
|
|
529
416
|
* @param {ExecutionRecord} record
|
|
530
417
|
* @returns {boolean}
|
|
531
418
|
*/
|
|
532
|
-
save(record) {
|
|
419
|
+
save(record, previous = null) {
|
|
533
420
|
const lock = new LockManager(this.lockFilePath);
|
|
534
421
|
|
|
535
422
|
if (!lock.acquire()) {
|
|
@@ -542,6 +429,16 @@ class ExecutionStore {
|
|
|
542
429
|
|
|
543
430
|
// Find existing record index
|
|
544
431
|
const existingIndex = records.findIndex((r) => r.uuid === record.uuid);
|
|
432
|
+
if (previous) {
|
|
433
|
+
const current = records[existingIndex];
|
|
434
|
+
if (
|
|
435
|
+
!current ||
|
|
436
|
+
require('./launch-owner').hasActiveLaunch(current) ||
|
|
437
|
+
(current.attempt?.number || 0) !== (previous.attempt?.number || 0)
|
|
438
|
+
) {
|
|
439
|
+
throw new Error('A different resume already reserved this execution');
|
|
440
|
+
}
|
|
441
|
+
}
|
|
545
442
|
|
|
546
443
|
if (existingIndex >= 0) {
|
|
547
444
|
// Update existing record
|
|
@@ -565,6 +462,10 @@ class ExecutionStore {
|
|
|
565
462
|
}
|
|
566
463
|
}
|
|
567
464
|
|
|
465
|
+
reserveLaunch(record, previous) {
|
|
466
|
+
return this.save(record, previous);
|
|
467
|
+
}
|
|
468
|
+
|
|
568
469
|
// Merge watcher metadata under the database lock without replacing terminal state.
|
|
569
470
|
patchAttempt(uuid, number, fields) {
|
|
570
471
|
const lock = new LockManager(this.lockFilePath);
|
|
@@ -689,6 +590,9 @@ class ExecutionStore {
|
|
|
689
590
|
const staleRecords = [];
|
|
690
591
|
|
|
691
592
|
for (const record of executingRecords) {
|
|
593
|
+
if (require('./launch-owner').hasActiveLaunch(record)) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
692
596
|
let isStale = false;
|
|
693
597
|
let reason = '';
|
|
694
598
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** Attempt-scoped evidence, independent of Docker's sticky OOMKilled flag. */
|
|
2
|
+
const { spawnSync } = require('child_process');
|
|
3
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
4
|
+
const MAIN_OOM = 'Exit evidence: main-oom (recent cgroup oom_kill delta)';
|
|
5
|
+
const DAEMON_RESTART =
|
|
6
|
+
'Exit evidence: docker-daemon-restart (Docker service journal)';
|
|
7
|
+
|
|
8
|
+
function recentOomDelta(sample, finishedAt) {
|
|
9
|
+
const fields = String(sample || '')
|
|
10
|
+
.trim()
|
|
11
|
+
.split(/\s+/);
|
|
12
|
+
const changed = Number(fields[4]),
|
|
13
|
+
observed = Number(fields[5]);
|
|
14
|
+
const finished = Date.parse(finishedAt) / 1000;
|
|
15
|
+
return (
|
|
16
|
+
changed > 0 &&
|
|
17
|
+
Number.isFinite(finished) &&
|
|
18
|
+
changed <= observed &&
|
|
19
|
+
Math.abs(finished - changed) <= 3 &&
|
|
20
|
+
Math.abs(finished - observed) <= 3
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function daemonRestartEvidence(journal, containerId) {
|
|
25
|
+
const lines = String(journal || '').split('\n');
|
|
26
|
+
const exited = lines.some((line) =>
|
|
27
|
+
/Main process exited|Stopping Docker|Starting Docker|Daemon shutdown complete/.test(
|
|
28
|
+
line
|
|
29
|
+
)
|
|
30
|
+
);
|
|
31
|
+
const forced =
|
|
32
|
+
containerId &&
|
|
33
|
+
lines.some(
|
|
34
|
+
(line) =>
|
|
35
|
+
line.includes(containerId) &&
|
|
36
|
+
/failed to exit|using the force|force.kill/i.test(line)
|
|
37
|
+
);
|
|
38
|
+
return Boolean(exited && forced);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collectExitEvidence(
|
|
42
|
+
{ containerName, sample, finishedAt, exitCode, oomKilled },
|
|
43
|
+
runner
|
|
44
|
+
) {
|
|
45
|
+
const run =
|
|
46
|
+
runner ||
|
|
47
|
+
((cmd, args) => {
|
|
48
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8' });
|
|
49
|
+
return { success: r.status === 0, stdout: r.stdout || '' };
|
|
50
|
+
});
|
|
51
|
+
// A local journal cannot diagnose a remote daemon. Require a local socket.
|
|
52
|
+
const local =
|
|
53
|
+
!process.env.DOCKER_HOST || process.env.DOCKER_HOST.startsWith('unix://');
|
|
54
|
+
if (
|
|
55
|
+
local &&
|
|
56
|
+
Number(exitCode) === 137 &&
|
|
57
|
+
Number.isFinite(Date.parse(finishedAt))
|
|
58
|
+
) {
|
|
59
|
+
const docker = require('./docker-cleanup').getDockerCommand();
|
|
60
|
+
const id = run(docker, ['inspect', '-f', '{{.Id}}', containerName]);
|
|
61
|
+
if (id.success && /^[a-f0-9]{64}$/.test(id.stdout.trim())) {
|
|
62
|
+
const end = Date.parse(finishedAt);
|
|
63
|
+
const journal = run('journalctl', [
|
|
64
|
+
'-u',
|
|
65
|
+
'docker.service',
|
|
66
|
+
'--since',
|
|
67
|
+
new Date(end - 60000).toISOString(),
|
|
68
|
+
'--until',
|
|
69
|
+
new Date(end + 1000).toISOString(),
|
|
70
|
+
'--no-pager',
|
|
71
|
+
'-o',
|
|
72
|
+
'cat',
|
|
73
|
+
]);
|
|
74
|
+
if (
|
|
75
|
+
journal.success &&
|
|
76
|
+
daemonRestartEvidence(journal.stdout, id.stdout.trim())
|
|
77
|
+
) {
|
|
78
|
+
return DAEMON_RESTART;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
(oomKilled === true || oomKilled === 'true') &&
|
|
84
|
+
(Number(exitCode) === 137 || Number(exitCode) < 0) &&
|
|
85
|
+
recentOomDelta(sample, finishedAt)
|
|
86
|
+
) {
|
|
87
|
+
return MAIN_OOM;
|
|
88
|
+
}
|
|
89
|
+
return 'Exit evidence: unavailable (no attributed exit-time OOM or daemon restart evidence)';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function fromLog(log) {
|
|
93
|
+
const lines = String(log || '').split('\n');
|
|
94
|
+
const last = lines.filter((line) => line.startsWith('Exit evidence:')).at(-1);
|
|
95
|
+
return { mainOom: last === MAIN_OOM, daemonRestart: last === DAEMON_RESTART };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildExitEvidenceSnippet(containerName, quotedLogPath) {
|
|
99
|
+
return `${shellQuote(process.execPath)} ${shellQuote(__filename)} ${shellQuote(containerName)} "$__start_command_cgroup" "$__start_command_finished" "$__start_command_exit" "$__start_command_oom" >> ${quotedLogPath} 2>/dev/null`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
MAIN_OOM,
|
|
104
|
+
DAEMON_RESTART,
|
|
105
|
+
recentOomDelta,
|
|
106
|
+
daemonRestartEvidence,
|
|
107
|
+
collectExitEvidence,
|
|
108
|
+
fromLog,
|
|
109
|
+
buildExitEvidenceSnippet,
|
|
110
|
+
};
|
|
111
|
+
if (require.main === module) {
|
|
112
|
+
const [containerName, sample, finishedAt, exitCode, oomKilled] =
|
|
113
|
+
process.argv.slice(2);
|
|
114
|
+
console.log(
|
|
115
|
+
collectExitEvidence({
|
|
116
|
+
containerName,
|
|
117
|
+
sample,
|
|
118
|
+
finishedAt,
|
|
119
|
+
exitCode,
|
|
120
|
+
oomKilled,
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
}
|
package/src/lib/exit-reason.js
CHANGED
|
@@ -181,8 +181,16 @@ function resolveMemoryExhaustion(input) {
|
|
|
181
181
|
return null;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
const evidence =
|
|
185
|
+
input.exitEvidence || require('./exit-evidence').fromLog(input.logTail);
|
|
186
|
+
if (evidence.daemonRestart) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
184
189
|
const marker = detectMemoryMarker(input.logTail);
|
|
185
|
-
if (
|
|
190
|
+
if (
|
|
191
|
+
marker &&
|
|
192
|
+
!(marker.reason.includes('kernel-oom-killer') && !evidence.mainOom)
|
|
193
|
+
) {
|
|
186
194
|
return { memoryExhausted: true, memoryExhaustedReason: marker.line };
|
|
187
195
|
}
|
|
188
196
|
if (input.oomKilled === true && isOomKillOfCommand(input)) {
|
|
@@ -230,9 +238,9 @@ function isOomKillOfCommand(input) {
|
|
|
230
238
|
if (!input) {
|
|
231
239
|
return false;
|
|
232
240
|
}
|
|
233
|
-
const
|
|
234
|
-
input.
|
|
235
|
-
if (!
|
|
241
|
+
const evidence =
|
|
242
|
+
input.exitEvidence || require('./exit-evidence').fromLog(input.logTail);
|
|
243
|
+
if (!evidence.mainOom || evidence.daemonRestart) {
|
|
236
244
|
return false;
|
|
237
245
|
}
|
|
238
246
|
const { exitCode } = input;
|
|
@@ -318,8 +326,16 @@ function resolveExitReason(input) {
|
|
|
318
326
|
return null;
|
|
319
327
|
}
|
|
320
328
|
|
|
329
|
+
const evidence =
|
|
330
|
+
input.exitEvidence || require('./exit-evidence').fromLog(input.logTail);
|
|
331
|
+
if (evidence.daemonRestart) {
|
|
332
|
+
return 'killed (docker daemon restart)';
|
|
333
|
+
}
|
|
321
334
|
const fromLog = detectExitReason(input.logTail);
|
|
322
|
-
if (
|
|
335
|
+
if (
|
|
336
|
+
fromLog &&
|
|
337
|
+
!(fromLog.includes('kernel-oom-killer') && !evidence.mainOom)
|
|
338
|
+
) {
|
|
323
339
|
return fromLog;
|
|
324
340
|
}
|
|
325
341
|
|
|
@@ -328,7 +344,9 @@ function resolveExitReason(input) {
|
|
|
328
344
|
}
|
|
329
345
|
|
|
330
346
|
const signalName = signalNameForExitCode(input.exitCode);
|
|
331
|
-
return signalName
|
|
347
|
+
return signalName
|
|
348
|
+
? `signal (${signalName}${signalName === 'SIGKILL' ? '; cause unknown' : ''})`
|
|
349
|
+
: null;
|
|
332
350
|
}
|
|
333
351
|
|
|
334
352
|
module.exports = {
|
|
@@ -9,6 +9,7 @@ const os = require('os');
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const { getTimestamp } = require('./isolation');
|
|
12
|
+
const { sanitizeLogToTemp, StreamingSanitizer } = require('./log-sanitizer');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Handle command failure - detect repository, upload log, create issue
|
|
@@ -55,7 +56,7 @@ function handleFailure(config, cmdName, fullCommand, exitCode, logPath) {
|
|
|
55
56
|
console.log('Log upload disabled via START_DISABLE_LOG_UPLOAD');
|
|
56
57
|
}
|
|
57
58
|
} else if (isGhUploadLogAvailable()) {
|
|
58
|
-
logUrl = uploadLog(logPath);
|
|
59
|
+
logUrl = uploadLog(logPath, { verbose: config.verbose });
|
|
59
60
|
if (logUrl) {
|
|
60
61
|
console.log(`Log uploaded: ${logUrl}`);
|
|
61
62
|
}
|
|
@@ -273,9 +274,11 @@ function isGhUploadLogAvailable() {
|
|
|
273
274
|
* @param {string} logPath - Path to the log file
|
|
274
275
|
* @returns {string|null} URL of the uploaded log or null
|
|
275
276
|
*/
|
|
276
|
-
function uploadLog(logPath) {
|
|
277
|
+
function uploadLog(logPath, options = {}) {
|
|
278
|
+
let prepared;
|
|
277
279
|
try {
|
|
278
|
-
|
|
280
|
+
prepared = sanitizeLogToTemp(logPath, { verbose: options.verbose });
|
|
281
|
+
const result = execFileSync('gh-upload-log', [prepared.path, '--private'], {
|
|
279
282
|
encoding: 'utf8',
|
|
280
283
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
281
284
|
});
|
|
@@ -293,9 +296,13 @@ function uploadLog(logPath) {
|
|
|
293
296
|
}
|
|
294
297
|
|
|
295
298
|
return null;
|
|
296
|
-
} catch
|
|
297
|
-
console.log(
|
|
299
|
+
} catch {
|
|
300
|
+
console.log('Warning: Log preparation or upload failed; upload blocked.');
|
|
298
301
|
return null;
|
|
302
|
+
} finally {
|
|
303
|
+
if (prepared) {
|
|
304
|
+
prepared.cleanup();
|
|
305
|
+
}
|
|
299
306
|
}
|
|
300
307
|
}
|
|
301
308
|
|
|
@@ -327,6 +334,9 @@ function canCreateIssue(owner, repo) {
|
|
|
327
334
|
*/
|
|
328
335
|
function createIssue(repoInfo, fullCommand, exitCode, logUrl) {
|
|
329
336
|
try {
|
|
337
|
+
fullCommand = new StreamingSanitizer()
|
|
338
|
+
.push(Buffer.from(fullCommand), true)
|
|
339
|
+
.toString();
|
|
330
340
|
const title = `Command failed with exit code ${exitCode}: ${fullCommand.substring(0, 50)}${fullCommand.length > 50 ? '...' : ''}`;
|
|
331
341
|
|
|
332
342
|
// Get runtime information
|
package/src/lib/isolation.js
CHANGED
|
@@ -547,7 +547,32 @@ function runInDocker(command, options = {}) {
|
|
|
547
547
|
});
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
+
if (!options.resolvedLimits) {
|
|
551
|
+
try {
|
|
552
|
+
Object.assign(
|
|
553
|
+
options,
|
|
554
|
+
require('./docker-resource-options').resolveResourceOptions(
|
|
555
|
+
options,
|
|
556
|
+
require('./execution-control').runCommand
|
|
557
|
+
)
|
|
558
|
+
);
|
|
559
|
+
} catch (error) {
|
|
560
|
+
return Promise.resolve({
|
|
561
|
+
success: false,
|
|
562
|
+
containerName: null,
|
|
563
|
+
message: error.message,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
550
567
|
const containerName = options.session || generateSessionName('docker');
|
|
568
|
+
const attribution = {
|
|
569
|
+
...options,
|
|
570
|
+
containerName,
|
|
571
|
+
sessionId:
|
|
572
|
+
options.sessionId ||
|
|
573
|
+
options.executionId ||
|
|
574
|
+
require('crypto').randomUUID(),
|
|
575
|
+
};
|
|
551
576
|
const containerExistedBeforeLaunch =
|
|
552
577
|
readDockerContainerStatus(containerName) !== null;
|
|
553
578
|
const cleanupPolicy = getDockerContainerCleanupPolicy(options);
|
|
@@ -587,7 +612,7 @@ function runInDocker(command, options = {}) {
|
|
|
587
612
|
dockerArgs.push('--user', options.user);
|
|
588
613
|
}
|
|
589
614
|
|
|
590
|
-
dockerArgs.push(...buildDockerRuntimeArgs(
|
|
615
|
+
dockerArgs.push(...buildDockerRuntimeArgs(attribution));
|
|
591
616
|
|
|
592
617
|
const effectiveCommand = options.keepAlive
|
|
593
618
|
? `${command}; exec ${shellToUse}`
|
|
@@ -597,12 +622,12 @@ function runInDocker(command, options = {}) {
|
|
|
597
622
|
: [shellToUse];
|
|
598
623
|
const mainCmdArgs = isBareShell
|
|
599
624
|
? toShellWords(command)
|
|
600
|
-
: isShellInvocationWithArgs(
|
|
625
|
+
: isShellInvocationWithArgs(effectiveCommand)
|
|
601
626
|
? buildShellWithArgsCmdArgs(effectiveCommand)
|
|
602
627
|
: [...shellArgs, '-c', effectiveCommand];
|
|
603
628
|
// A recovery command is selected by a marker that `docker cp` drops
|
|
604
629
|
// into the container before it is started again (issue #176).
|
|
605
|
-
const
|
|
630
|
+
const recoveryArgs = options.recoveryCommand
|
|
606
631
|
? buildRecoverySelectorArgs(mainCmdArgs, {
|
|
607
632
|
shell: shellToUse,
|
|
608
633
|
shellFlag: shellInteractiveFlag,
|
|
@@ -611,6 +636,14 @@ function runInDocker(command, options = {}) {
|
|
|
611
636
|
: options.recoveryCommand,
|
|
612
637
|
})
|
|
613
638
|
: mainCmdArgs;
|
|
639
|
+
const {
|
|
640
|
+
buildCommandHandoffArgs,
|
|
641
|
+
commandHandoffPath,
|
|
642
|
+
} = require('./docker-command-handoff');
|
|
643
|
+
const cmdArgs = buildCommandHandoffArgs(
|
|
644
|
+
recoveryArgs,
|
|
645
|
+
commandHandoffPath(containerName)
|
|
646
|
+
);
|
|
614
647
|
dockerArgs.push(options.image, ...cmdArgs);
|
|
615
648
|
|
|
616
649
|
if (DEBUG) {
|
|
@@ -665,7 +698,7 @@ function runInDocker(command, options = {}) {
|
|
|
665
698
|
cleanupPolicy,
|
|
666
699
|
options.logPath,
|
|
667
700
|
options.executionId || null,
|
|
668
|
-
{ recoverOnKill: Boolean(options.onKillResume) }
|
|
701
|
+
{ recoverOnKill: Boolean(options.onKillResume), cpuOptions: options }
|
|
669
702
|
);
|
|
670
703
|
}
|
|
671
704
|
|
|
@@ -700,7 +733,7 @@ function runInDocker(command, options = {}) {
|
|
|
700
733
|
if (options.user) {
|
|
701
734
|
dockerArgs.push('--user', options.user);
|
|
702
735
|
}
|
|
703
|
-
dockerArgs.push(...buildDockerRuntimeArgs(
|
|
736
|
+
dockerArgs.push(...buildDockerRuntimeArgs(attribution));
|
|
704
737
|
if (DEBUG) {
|
|
705
738
|
console.log(`[DEBUG] shell: ${shellToUse}`);
|
|
706
739
|
}
|
|
@@ -745,9 +778,14 @@ function runInDocker(command, options = {}) {
|
|
|
745
778
|
const launchArgs = needsNetworkSetup
|
|
746
779
|
? ['start', '-a', '-i', containerName]
|
|
747
780
|
: dockerArgs;
|
|
781
|
+
const diagnostics =
|
|
782
|
+
require('./attached-diagnostics').startAttachedDiagnostics(
|
|
783
|
+
containerName,
|
|
784
|
+
options
|
|
785
|
+
);
|
|
748
786
|
const child = spawnAttachedDocker(launchArgs, options.logPath);
|
|
749
787
|
|
|
750
|
-
child.on('exit', (code) => {
|
|
788
|
+
child.on('exit', async (code) => {
|
|
751
789
|
const durationMs = Date.now() - startTime;
|
|
752
790
|
const exitCode = code ?? 1;
|
|
753
791
|
let message = `Docker container "${containerName}" exited with code ${exitCode}`;
|
|
@@ -762,6 +800,7 @@ function runInDocker(command, options = {}) {
|
|
|
762
800
|
}
|
|
763
801
|
|
|
764
802
|
const dockerState = readDockerContainerState(containerName);
|
|
803
|
+
const diagnosticFacts = await diagnostics.finish(dockerState);
|
|
765
804
|
const oomKilled = dockerState ? dockerState.oomKilled : null;
|
|
766
805
|
const postMortem = (removed) =>
|
|
767
806
|
recordAttachedDockerPostMortem({
|
|
@@ -806,12 +845,14 @@ function runInDocker(command, options = {}) {
|
|
|
806
845
|
resolve({
|
|
807
846
|
success: exitCode === 0,
|
|
808
847
|
containerName,
|
|
848
|
+
diagnostics: diagnosticFacts,
|
|
809
849
|
message,
|
|
810
850
|
exitCode,
|
|
811
851
|
});
|
|
812
852
|
});
|
|
813
853
|
|
|
814
|
-
child.on('error', (err) => {
|
|
854
|
+
child.on('error', async (err) => {
|
|
855
|
+
await diagnostics.finish(null);
|
|
815
856
|
resolve({
|
|
816
857
|
success: false,
|
|
817
858
|
containerName,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A dead launcher leaves a usable reservation, rather than a permanent gate. */
|
|
2
|
+
const os = require('os');
|
|
3
|
+
|
|
4
|
+
function markLaunch(record) {
|
|
5
|
+
record.options.launchPending = true;
|
|
6
|
+
record.options.launchOwner = { pid: process.pid, hostname: os.hostname() };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function hasActiveLaunch(record) {
|
|
10
|
+
if (!record?.options?.launchPending) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
const owner = record.options.launchOwner;
|
|
14
|
+
if (!owner || owner.hostname !== os.hostname()) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
process.kill(owner.pid, 0);
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return error.code !== 'ESRCH';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { markLaunch, hasActiveLaunch };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Reserve before launch, and identify/stop a launch whose final save failed. */
|
|
2
|
+
const { getDockerCommand } = require('./docker-cleanup');
|
|
3
|
+
|
|
4
|
+
function reserveLaunch(store, record, previous) {
|
|
5
|
+
require('./launch-owner').markLaunch(record);
|
|
6
|
+
if (typeof store.reserveLaunch === 'function') {
|
|
7
|
+
store.reserveLaunch(record, previous);
|
|
8
|
+
} else {
|
|
9
|
+
store.save(record);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function rollbackLaunch(store, previous) {
|
|
14
|
+
try {
|
|
15
|
+
store.save(previous);
|
|
16
|
+
} catch {
|
|
17
|
+
/* The durable reservation still names the session. */
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function failLaunchedPersistence(store, record, runner, error) {
|
|
22
|
+
const name = record.options.sessionName;
|
|
23
|
+
let stopped;
|
|
24
|
+
try {
|
|
25
|
+
stopped =
|
|
26
|
+
record.options.isolated === 'docker'
|
|
27
|
+
? runner(getDockerCommand(), ['stop', name])
|
|
28
|
+
: null;
|
|
29
|
+
} catch {
|
|
30
|
+
stopped = null;
|
|
31
|
+
}
|
|
32
|
+
const warning = {
|
|
33
|
+
code: 'LAUNCH_PERSISTENCE_FAILED',
|
|
34
|
+
uuid: record.uuid,
|
|
35
|
+
containerName: name,
|
|
36
|
+
running: !stopped?.success,
|
|
37
|
+
error: error.message,
|
|
38
|
+
};
|
|
39
|
+
if (stopped?.success) {
|
|
40
|
+
record.status = 'executed';
|
|
41
|
+
record.exitCode = -1;
|
|
42
|
+
record.endTime = new Date().toISOString();
|
|
43
|
+
record.endTimeSource = 'observed-at';
|
|
44
|
+
record.options.launchPending = false;
|
|
45
|
+
try {
|
|
46
|
+
store.save(record);
|
|
47
|
+
} catch {
|
|
48
|
+
/* Completion watcher remains attached. */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return JSON.stringify(warning);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { reserveLaunch, rollbackLaunch, failLaunchedPersistence };
|