start-command 0.35.4 → 0.36.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 +6 -0
- package/README.md +10 -0
- package/package.json +1 -1
- package/src/bin/cli.js +43 -7
- package/src/lib/args-parser.js +12 -0
- 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 +40 -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 +23 -2
- 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 +15 -0
- package/src/lib/execution-recovery.js +145 -29
- package/src/lib/execution-resume-all.js +11 -0
- package/src/lib/execution-resume.js +142 -29
- 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/isolation.js +28 -3
- package/src/lib/launch-owner.js +25 -0
- package/src/lib/launch-persistence.js +54 -0
- package/src/lib/query-commands.js +14 -3
- package/src/lib/resume-resources.js +88 -0
- package/src/lib/status-formatter.js +28 -0
- package/src/lib/store-lock.js +133 -0
- package/src/lib/usage.js +11 -0
- package/test/docker-runtime-options.js +6 -0
- package/test/execution-resume.js +9 -3
- package/test/exit-reason.js +6 -1
- package/test/isolation.js +5 -4
- package/test/regression-174.js +3 -1
- 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 +2 -1
- package/test/regression-189.js +224 -0
- package/test/regression-190.js +111 -0
- package/test/regression-191.js +152 -0
- package/test/regression-192.js +66 -0
- package/test/regression-193.js +241 -0
- package/test/regression-194.js +182 -0
|
@@ -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 = {
|
package/src/lib/isolation.js
CHANGED
|
@@ -547,6 +547,23 @@ 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');
|
|
551
568
|
const containerExistedBeforeLaunch =
|
|
552
569
|
readDockerContainerStatus(containerName) !== null;
|
|
@@ -665,7 +682,7 @@ function runInDocker(command, options = {}) {
|
|
|
665
682
|
cleanupPolicy,
|
|
666
683
|
options.logPath,
|
|
667
684
|
options.executionId || null,
|
|
668
|
-
{ recoverOnKill: Boolean(options.onKillResume) }
|
|
685
|
+
{ recoverOnKill: Boolean(options.onKillResume), cpuOptions: options }
|
|
669
686
|
);
|
|
670
687
|
}
|
|
671
688
|
|
|
@@ -745,9 +762,14 @@ function runInDocker(command, options = {}) {
|
|
|
745
762
|
const launchArgs = needsNetworkSetup
|
|
746
763
|
? ['start', '-a', '-i', containerName]
|
|
747
764
|
: dockerArgs;
|
|
765
|
+
const diagnostics =
|
|
766
|
+
require('./attached-diagnostics').startAttachedDiagnostics(
|
|
767
|
+
containerName,
|
|
768
|
+
options
|
|
769
|
+
);
|
|
748
770
|
const child = spawnAttachedDocker(launchArgs, options.logPath);
|
|
749
771
|
|
|
750
|
-
child.on('exit', (code) => {
|
|
772
|
+
child.on('exit', async (code) => {
|
|
751
773
|
const durationMs = Date.now() - startTime;
|
|
752
774
|
const exitCode = code ?? 1;
|
|
753
775
|
let message = `Docker container "${containerName}" exited with code ${exitCode}`;
|
|
@@ -762,6 +784,7 @@ function runInDocker(command, options = {}) {
|
|
|
762
784
|
}
|
|
763
785
|
|
|
764
786
|
const dockerState = readDockerContainerState(containerName);
|
|
787
|
+
const diagnosticFacts = await diagnostics.finish(dockerState);
|
|
765
788
|
const oomKilled = dockerState ? dockerState.oomKilled : null;
|
|
766
789
|
const postMortem = (removed) =>
|
|
767
790
|
recordAttachedDockerPostMortem({
|
|
@@ -806,12 +829,14 @@ function runInDocker(command, options = {}) {
|
|
|
806
829
|
resolve({
|
|
807
830
|
success: exitCode === 0,
|
|
808
831
|
containerName,
|
|
832
|
+
diagnostics: diagnosticFacts,
|
|
809
833
|
message,
|
|
810
834
|
exitCode,
|
|
811
835
|
});
|
|
812
836
|
});
|
|
813
837
|
|
|
814
|
-
child.on('error', (err) => {
|
|
838
|
+
child.on('error', async (err) => {
|
|
839
|
+
await diagnostics.finish(null);
|
|
815
840
|
resolve({
|
|
816
841
|
success: false,
|
|
817
842
|
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 };
|
|
@@ -55,9 +55,19 @@ function handleAttachQuery(store, identifier, readOnly) {
|
|
|
55
55
|
return reportResult(attachExecution(store, identifier, { readOnly }));
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
async function handleResumeQuery(
|
|
58
|
+
async function handleResumeQuery(
|
|
59
|
+
store,
|
|
60
|
+
identifier,
|
|
61
|
+
command,
|
|
62
|
+
outputFormat,
|
|
63
|
+
resourceOptions = {}
|
|
64
|
+
) {
|
|
59
65
|
return reportResult(
|
|
60
|
-
await resumeExecution(store, identifier, {
|
|
66
|
+
await resumeExecution(store, identifier, {
|
|
67
|
+
command,
|
|
68
|
+
outputFormat,
|
|
69
|
+
resourceOptions,
|
|
70
|
+
})
|
|
61
71
|
);
|
|
62
72
|
}
|
|
63
73
|
|
|
@@ -174,7 +184,8 @@ async function dispatchQueryCommand(options, context = {}) {
|
|
|
174
184
|
store,
|
|
175
185
|
options.resume,
|
|
176
186
|
context.command || null,
|
|
177
|
-
outputFormat
|
|
187
|
+
outputFormat,
|
|
188
|
+
options
|
|
178
189
|
);
|
|
179
190
|
}
|
|
180
191
|
if (options.resumeAll) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const {
|
|
2
|
+
normalizeResourceLimits,
|
|
3
|
+
cpuCount,
|
|
4
|
+
withCpuLimit,
|
|
5
|
+
readDockerResourceLimits,
|
|
6
|
+
} = require('./docker-resource-limits');
|
|
7
|
+
const { resolveResourceOptions } = require('./docker-resource-options');
|
|
8
|
+
|
|
9
|
+
// Temporary penalty quotas must never become a resumed execution's base quota.
|
|
10
|
+
function restoreBaseCpu(limits, options) {
|
|
11
|
+
if (!options.cpuPenaltyConfig) {
|
|
12
|
+
return limits;
|
|
13
|
+
}
|
|
14
|
+
const base = normalizeResourceLimits(
|
|
15
|
+
options.baseResourceLimits || options.resourceLimits
|
|
16
|
+
);
|
|
17
|
+
return [
|
|
18
|
+
...limits.filter((s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
|
|
19
|
+
...base.filter((s) => /^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function prepareResumeResources(record, probe, overrides, runner, random) {
|
|
24
|
+
const options = record.options || {};
|
|
25
|
+
if (options.isolated !== 'docker') {
|
|
26
|
+
if (
|
|
27
|
+
['memory', 'memorySwap', 'cpus'].some(
|
|
28
|
+
(k) => overrides[k] !== null && overrides[k] !== undefined
|
|
29
|
+
)
|
|
30
|
+
) {
|
|
31
|
+
throw new Error('Resource overrides require a Docker execution');
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const live =
|
|
36
|
+
probe.state === 'stopped'
|
|
37
|
+
? readDockerResourceLimits(options.sessionName, runner)
|
|
38
|
+
: null;
|
|
39
|
+
const base = restoreBaseCpu(
|
|
40
|
+
normalizeResourceLimits(live || options.resourceLimits),
|
|
41
|
+
options
|
|
42
|
+
);
|
|
43
|
+
const oldMemory = base.find((s) => s.startsWith('--memory='))?.slice(9);
|
|
44
|
+
const result = resolveResourceOptions(
|
|
45
|
+
{ ...overrides, resourceLimits: base },
|
|
46
|
+
runner,
|
|
47
|
+
random
|
|
48
|
+
);
|
|
49
|
+
if (overrides.cpus && probe.state === 'stopped') {
|
|
50
|
+
// Docker cannot switch an existing legacy quota to NanoCPUs in place.
|
|
51
|
+
const cpu = withCpuLimit(base, result.resolvedLimits.cpus);
|
|
52
|
+
result.resourceLimits = [
|
|
53
|
+
...result.resourceLimits.filter(
|
|
54
|
+
(s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)
|
|
55
|
+
),
|
|
56
|
+
...cpu.filter((s) => /^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
result.oldMemory = oldMemory
|
|
60
|
+
? require('./docker-resource-options').parseResourceSpec(oldMemory).amount
|
|
61
|
+
: 'unlimited';
|
|
62
|
+
result.resolvedLimits = {
|
|
63
|
+
...options.resolvedLimits,
|
|
64
|
+
...result.resolvedLimits,
|
|
65
|
+
};
|
|
66
|
+
result.resourceLimitSpecs = {
|
|
67
|
+
...options.resourceLimitSpecs,
|
|
68
|
+
...result.resourceLimitSpecs,
|
|
69
|
+
};
|
|
70
|
+
result.update =
|
|
71
|
+
probe.state === 'stopped' &&
|
|
72
|
+
(['memory', 'memorySwap', 'cpus'].some(
|
|
73
|
+
(k) => overrides[k] !== null && overrides[k] !== undefined
|
|
74
|
+
) ||
|
|
75
|
+
Boolean(options.cpuPenaltyConfig));
|
|
76
|
+
if (options.cpuPenaltyConfig && !overrides.cpus) {
|
|
77
|
+
result.resourceLimits = clampCpuToDaemon(result.resourceLimits, runner);
|
|
78
|
+
result.resolvedLimits.cpus = cpuCount(result.resourceLimits);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clampCpuToDaemon(limits, runner) {
|
|
84
|
+
const { readDaemonCapacity } = require('./docker-resource-options');
|
|
85
|
+
const capacity = readDaemonCapacity(runner).cpus;
|
|
86
|
+
return withCpuLimit(limits, Math.min(cpuCount(limits) || capacity, capacity));
|
|
87
|
+
}
|
|
88
|
+
module.exports = { prepareResumeResources, restoreBaseCpu, clampCpuToDaemon };
|