start-command 0.35.3 → 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 +12 -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 +47 -7
- package/src/lib/detached-output.js +61 -0
- package/src/lib/docker-cleanup.js +43 -4
- 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-attempt.js +153 -0
- package/src/lib/execution-recovery.js +191 -15
- package/src/lib/execution-resume-all.js +23 -1
- package/src/lib/execution-resume.js +203 -40
- package/src/lib/execution-store.js +56 -121
- package/src/lib/exit-evidence.js +123 -0
- package/src/lib/exit-reason.js +24 -6
- package/src/lib/isolation-log-utils.js +3 -3
- package/src/lib/isolation.js +36 -9
- 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 +66 -6
- 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 +10 -3
- package/test/exit-reason.js +6 -1
- package/test/isolation.js +5 -4
- package/test/regression-174.js +3 -1
- package/test/regression-176.js +5 -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 +547 -0
- 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
|
@@ -28,10 +28,20 @@ const { escapeForLinksNotation } = require('./output-blocks');
|
|
|
28
28
|
const {
|
|
29
29
|
buildResourceLimitsStatusLine,
|
|
30
30
|
normalizeResourceLimits,
|
|
31
|
-
readDockerResourceLimits,
|
|
32
31
|
} = require('./docker-resource-limits');
|
|
33
32
|
const { appendLogFile } = require('./isolation-log-utils');
|
|
33
|
+
const {
|
|
34
|
+
appendLifecycle,
|
|
35
|
+
archiveAttempt,
|
|
36
|
+
createAttempt,
|
|
37
|
+
patchAttempt,
|
|
38
|
+
} = require('./execution-attempt');
|
|
34
39
|
const { runCommand } = require('./execution-control');
|
|
40
|
+
const {
|
|
41
|
+
reserveLaunch,
|
|
42
|
+
rollbackLaunch,
|
|
43
|
+
failLaunchedPersistence,
|
|
44
|
+
} = require('./launch-persistence');
|
|
35
45
|
const { SessionState, probeSession } = require('./session-probe');
|
|
36
46
|
const { getDockerNetworks } = require('./docker-network-lifecycle');
|
|
37
47
|
const {
|
|
@@ -83,6 +93,7 @@ function buildResumedSessionName(sessionName, attempt) {
|
|
|
83
93
|
function buildLaunchOptions(record) {
|
|
84
94
|
const opts = record.options || {};
|
|
85
95
|
return {
|
|
96
|
+
...opts,
|
|
86
97
|
image: opts.image || null,
|
|
87
98
|
session: opts.sessionName,
|
|
88
99
|
detached: true,
|
|
@@ -105,6 +116,7 @@ function buildLaunchOptions(record) {
|
|
|
105
116
|
resourceLimits: normalizeResourceLimits(opts.resourceLimits),
|
|
106
117
|
// Append to the same log so one logical session keeps one gap-free record.
|
|
107
118
|
logPath: record.logPath || null,
|
|
119
|
+
deferCompletionWatcher: true,
|
|
108
120
|
};
|
|
109
121
|
}
|
|
110
122
|
|
|
@@ -339,12 +351,20 @@ function formatResumeResult(result, outputFormat) {
|
|
|
339
351
|
* @param {?string} containerId - New container id, when one was created
|
|
340
352
|
* @returns {object} The updated record
|
|
341
353
|
*/
|
|
342
|
-
function applyResumeToRecord(
|
|
354
|
+
function applyResumeToRecord(
|
|
355
|
+
record,
|
|
356
|
+
plan,
|
|
357
|
+
containerId,
|
|
358
|
+
attempt = createAttempt(record, plan)
|
|
359
|
+
) {
|
|
360
|
+
archiveAttempt(record);
|
|
361
|
+
record.attempt = attempt;
|
|
343
362
|
const options = { ...(record.options || {}) };
|
|
344
363
|
options.resumeCount = plan.attempt;
|
|
345
|
-
options.resumedAt =
|
|
364
|
+
options.resumedAt = attempt.startedAt;
|
|
346
365
|
// A resume is a new deliberate start: launch-time recovery applies again.
|
|
347
366
|
delete options.stopRequestedAt;
|
|
367
|
+
delete options.exitEvidence;
|
|
348
368
|
if (plan.resourceLimits && plan.resourceLimits.length > 0) {
|
|
349
369
|
options.resourceLimits = plan.resourceLimits;
|
|
350
370
|
}
|
|
@@ -397,41 +417,120 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
397
417
|
};
|
|
398
418
|
}
|
|
399
419
|
|
|
420
|
+
if (require('./launch-owner').hasActiveLaunch(record)) {
|
|
421
|
+
return {
|
|
422
|
+
success: false,
|
|
423
|
+
error: 'An execution launch is already reserved.',
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const previousData = globalThis.structuredClone(
|
|
427
|
+
record.toObject ? record.toObject() : record
|
|
428
|
+
);
|
|
429
|
+
const previous = record.constructor.fromObject
|
|
430
|
+
? record.constructor.fromObject(previousData)
|
|
431
|
+
: previousData;
|
|
400
432
|
const runner = deps.runner || runCommand;
|
|
401
433
|
const probeFn = deps.probe || ((r) => probeSession(r, runner));
|
|
402
434
|
const probe = probeFn(record);
|
|
403
435
|
const opts = record.options || {};
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
436
|
+
let resources;
|
|
437
|
+
try {
|
|
438
|
+
resources = require('./resume-resources').prepareResumeResources(
|
|
439
|
+
record,
|
|
440
|
+
probe,
|
|
441
|
+
deps.resourceOptions || {},
|
|
442
|
+
runner,
|
|
443
|
+
deps.random
|
|
444
|
+
);
|
|
445
|
+
} catch (error) {
|
|
446
|
+
return { success: false, error: error.message };
|
|
447
|
+
}
|
|
448
|
+
const planningRecord = resources
|
|
449
|
+
? {
|
|
450
|
+
...record,
|
|
451
|
+
options: { ...opts, resourceLimits: resources.resourceLimits },
|
|
452
|
+
}
|
|
453
|
+
: record;
|
|
415
454
|
const plan = buildResumePlan(
|
|
416
|
-
|
|
455
|
+
planningRecord,
|
|
417
456
|
deps.command || null,
|
|
418
457
|
probe,
|
|
419
|
-
|
|
458
|
+
resources?.resourceLimits
|
|
420
459
|
);
|
|
460
|
+
if (resources && !plan.error) {
|
|
461
|
+
plan.resourceLimits = resources.resourceLimits;
|
|
462
|
+
if (plan.mode === ResumeMode.DOCKER_START && resources.update) {
|
|
463
|
+
plan.steps.unshift({
|
|
464
|
+
command: getDockerCommand(),
|
|
465
|
+
args: [
|
|
466
|
+
'update',
|
|
467
|
+
...resources.resourceLimits.filter((s) =>
|
|
468
|
+
/^--(?:memory|memory-swap|cpus|cpu-quota|cpu-period)=/.test(s)
|
|
469
|
+
),
|
|
470
|
+
plan.sessionName,
|
|
471
|
+
],
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
421
475
|
if (plan.error) {
|
|
422
476
|
return { success: false, error: plan.error };
|
|
423
477
|
}
|
|
424
478
|
|
|
425
479
|
let containerId = null;
|
|
480
|
+
const attempt = createAttempt(record, plan);
|
|
481
|
+
const lifecycleRecord = {
|
|
482
|
+
uuid: record.uuid,
|
|
483
|
+
logPath: record.logPath,
|
|
484
|
+
attempt,
|
|
485
|
+
};
|
|
486
|
+
appendLifecycle(lifecycleRecord, 'resume-started');
|
|
487
|
+
const updated = applyResumeToRecord(record, plan, null, attempt);
|
|
488
|
+
if (resources) {
|
|
489
|
+
updated.options.resolvedLimits = resources.resolvedLimits;
|
|
490
|
+
updated.options.resourceLimitSpecs = resources.resourceLimitSpecs;
|
|
491
|
+
if (
|
|
492
|
+
opts.cpuPenaltyConfig &&
|
|
493
|
+
deps.resourceOptions?.cpus !== null &&
|
|
494
|
+
deps.resourceOptions?.cpus !== undefined
|
|
495
|
+
) {
|
|
496
|
+
updated.options.baseResourceLimits = resources.resourceLimits;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
reserveLaunch(store, updated, previous);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
appendLifecycle(lifecycleRecord, 'launch-failed', { error: error.message });
|
|
503
|
+
return {
|
|
504
|
+
success: false,
|
|
505
|
+
error: `Launch reservation failed: ${error.message}`,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (resources && deps.resourceOptions?.memory && record.logPath) {
|
|
510
|
+
const oldMemory = resources.oldMemory;
|
|
511
|
+
appendLogFile(
|
|
512
|
+
record.logPath,
|
|
513
|
+
`Resume memory limit: ${oldMemory} -> ${resources.resolvedLimits.memory} (${deps.resourceOptions.memory})\n`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
426
516
|
|
|
427
517
|
if (plan.mode === ResumeMode.RELAUNCH) {
|
|
428
518
|
const runIsolated = deps.runIsolated || require('./isolation').runIsolated;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
519
|
+
let launchResult;
|
|
520
|
+
try {
|
|
521
|
+
launchResult = await runIsolated(
|
|
522
|
+
plan.backend,
|
|
523
|
+
plan.command,
|
|
524
|
+
plan.launchOptions
|
|
525
|
+
);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
launchResult = { success: false, message: error.message };
|
|
528
|
+
}
|
|
434
529
|
if (!launchResult || !launchResult.success) {
|
|
530
|
+
rollbackLaunch(store, previous);
|
|
531
|
+
appendLifecycle(lifecycleRecord, 'launch-failed', {
|
|
532
|
+
error: launchResult?.message || 'unknown error',
|
|
533
|
+
});
|
|
435
534
|
return {
|
|
436
535
|
success: false,
|
|
437
536
|
error: `Failed to relaunch ${plan.backend} session "${plan.sessionName}": ${
|
|
@@ -442,12 +541,19 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
442
541
|
containerId = launchResult.containerId || null;
|
|
443
542
|
} else {
|
|
444
543
|
for (const step of plan.steps) {
|
|
445
|
-
|
|
544
|
+
let result;
|
|
545
|
+
try {
|
|
546
|
+
result = runner(step.command, step.args);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
result = { success: false, error: error.message };
|
|
549
|
+
}
|
|
446
550
|
if (!result.success) {
|
|
551
|
+
rollbackLaunch(store, previous);
|
|
447
552
|
const detail =
|
|
448
553
|
(result.stderr || '').trim() ||
|
|
449
554
|
result.error ||
|
|
450
555
|
`exit code ${result.status}`;
|
|
556
|
+
appendLifecycle(lifecycleRecord, 'launch-failed', { error: detail });
|
|
451
557
|
return {
|
|
452
558
|
success: false,
|
|
453
559
|
error: `Failed to resume ${plan.backend} session "${plan.sessionName}": ${detail}`,
|
|
@@ -460,28 +566,85 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
460
566
|
if (limitsLine && record.logPath) {
|
|
461
567
|
appendLogFile(record.logPath, `${limitsLine}\n`);
|
|
462
568
|
}
|
|
569
|
+
}
|
|
463
570
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
571
|
+
const previousSessionName = plan.newSessionName ? plan.sessionName : null;
|
|
572
|
+
attempt.launchAcceptedAt = new Date().toISOString();
|
|
573
|
+
updated.options.launchPending = false;
|
|
574
|
+
if (containerId) {
|
|
575
|
+
updated.options.containerId = containerId;
|
|
576
|
+
}
|
|
577
|
+
let persistenceError = null;
|
|
578
|
+
try {
|
|
579
|
+
store.save(updated);
|
|
580
|
+
} catch (error) {
|
|
581
|
+
persistenceError = error;
|
|
582
|
+
}
|
|
583
|
+
appendLifecycle(updated, 'launch-accepted');
|
|
584
|
+
|
|
585
|
+
if (plan.backend === 'docker') {
|
|
586
|
+
try {
|
|
587
|
+
const startWatcher =
|
|
588
|
+
deps.startWatcher || startDetachedDockerCompletionWatcher;
|
|
589
|
+
const watcher = startWatcher(
|
|
590
|
+
activeSessionName(plan),
|
|
591
|
+
getDockerContainerCleanupPolicy(updated.options),
|
|
592
|
+
updated.logPath || null,
|
|
593
|
+
updated.uuid || null,
|
|
594
|
+
{
|
|
595
|
+
since: attempt.startedAt,
|
|
596
|
+
attemptNumber: attempt.number,
|
|
597
|
+
recoverOnKill:
|
|
598
|
+
plan.mode !== ResumeMode.DOCKER_SNAPSHOT &&
|
|
599
|
+
Boolean(opts.onKillResume),
|
|
600
|
+
}
|
|
601
|
+
);
|
|
602
|
+
if (watcher === false) {
|
|
603
|
+
throw new Error('Watcher process could not be started');
|
|
478
604
|
}
|
|
479
|
-
|
|
605
|
+
const current = persistenceError
|
|
606
|
+
? null
|
|
607
|
+
: patchAttempt(store, updated, {
|
|
608
|
+
watcherAttachedAt: new Date().toISOString(),
|
|
609
|
+
});
|
|
610
|
+
if (current) {
|
|
611
|
+
appendLifecycle(current, 'watcher-attached');
|
|
612
|
+
}
|
|
613
|
+
} catch (error) {
|
|
614
|
+
if (persistenceError) {
|
|
615
|
+
return {
|
|
616
|
+
success: false,
|
|
617
|
+
error: failLaunchedPersistence(
|
|
618
|
+
store,
|
|
619
|
+
updated,
|
|
620
|
+
runner,
|
|
621
|
+
persistenceError
|
|
622
|
+
),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
const current = persistenceError
|
|
626
|
+
? null
|
|
627
|
+
: patchAttempt(store, updated, {
|
|
628
|
+
watcherError: error.message,
|
|
629
|
+
});
|
|
630
|
+
appendLifecycle(current || updated, 'watcher-attachment-failed', {
|
|
631
|
+
error: error.message,
|
|
632
|
+
});
|
|
633
|
+
return {
|
|
634
|
+
success: false,
|
|
635
|
+
error: `Launch accepted for container ${activeSessionName(plan)}, but completion watcher attachment failed: ${error.message}`,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
480
638
|
}
|
|
481
639
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
640
|
+
if (persistenceError) {
|
|
641
|
+
// The reservation remains durable and the watcher is attached. Stop the
|
|
642
|
+
// new container rather than allow an untracked command to keep running.
|
|
643
|
+
return {
|
|
644
|
+
success: false,
|
|
645
|
+
error: failLaunchedPersistence(store, updated, runner, persistenceError),
|
|
646
|
+
};
|
|
647
|
+
}
|
|
485
648
|
|
|
486
649
|
return {
|
|
487
650
|
success: true,
|
|
@@ -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
|
|
@@ -75,6 +75,8 @@ class ExecutionRecord {
|
|
|
75
75
|
this.logPath = options.logPath || '';
|
|
76
76
|
this.startTime = options.startTime || new Date().toISOString();
|
|
77
77
|
this.endTime = options.endTime || null;
|
|
78
|
+
this.attempt = options.attempt;
|
|
79
|
+
this.attemptHistory = options.attemptHistory;
|
|
78
80
|
// Provenance of `endTime` (issue #170.2). `--status` used to fabricate a
|
|
79
81
|
// finish time with `new Date()` whenever the record had none, which made an
|
|
80
82
|
// observation indistinguishable from a real finish time. One of
|
|
@@ -126,6 +128,7 @@ class ExecutionRecord {
|
|
|
126
128
|
this.shell = options.shell || process.env.SHELL || '/bin/sh';
|
|
127
129
|
this.platform = options.platform || process.platform;
|
|
128
130
|
this.options = options.options || {};
|
|
131
|
+
this.cpuPenalty = options.cpuPenalty;
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
/**
|
|
@@ -152,6 +155,15 @@ class ExecutionRecord {
|
|
|
152
155
|
startTime: this.startTime,
|
|
153
156
|
endTime: this.endTime,
|
|
154
157
|
};
|
|
158
|
+
if (this.cpuPenalty) {
|
|
159
|
+
obj.cpuPenalty = this.cpuPenalty;
|
|
160
|
+
}
|
|
161
|
+
if (this.attempt) {
|
|
162
|
+
obj.attempt = this.attempt;
|
|
163
|
+
}
|
|
164
|
+
if (this.attemptHistory) {
|
|
165
|
+
obj.attemptHistory = this.attemptHistory;
|
|
166
|
+
}
|
|
155
167
|
if (this.endTimeSource !== undefined && this.endTimeSource !== null) {
|
|
156
168
|
obj.endTimeSource = this.endTimeSource;
|
|
157
169
|
}
|
|
@@ -202,123 +214,6 @@ class ExecutionRecord {
|
|
|
202
214
|
}
|
|
203
215
|
}
|
|
204
216
|
|
|
205
|
-
/**
|
|
206
|
-
* File-based lock manager
|
|
207
|
-
*/
|
|
208
|
-
class LockManager {
|
|
209
|
-
constructor(lockFilePath) {
|
|
210
|
-
this.lockFilePath = lockFilePath;
|
|
211
|
-
this.lockAcquired = false;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Acquire an exclusive lock
|
|
216
|
-
* @param {number} timeout - Maximum time to wait for lock in ms
|
|
217
|
-
* @returns {boolean} True if lock acquired
|
|
218
|
-
*/
|
|
219
|
-
acquire(timeout = LOCK_TIMEOUT_MS) {
|
|
220
|
-
const startTime = Date.now();
|
|
221
|
-
|
|
222
|
-
while (Date.now() - startTime < timeout) {
|
|
223
|
-
try {
|
|
224
|
-
// Check if existing lock is stale
|
|
225
|
-
if (fs.existsSync(this.lockFilePath)) {
|
|
226
|
-
const lockData = this.readLockFile();
|
|
227
|
-
if (lockData && this.isLockStale(lockData)) {
|
|
228
|
-
// Remove stale lock
|
|
229
|
-
fs.unlinkSync(this.lockFilePath);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Try to create lock file exclusively
|
|
234
|
-
const lockData = {
|
|
235
|
-
pid: process.pid,
|
|
236
|
-
timestamp: Date.now(),
|
|
237
|
-
hostname: os.hostname(),
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
fs.writeFileSync(this.lockFilePath, JSON.stringify(lockData), {
|
|
241
|
-
flag: 'wx', // Fail if file exists
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
this.lockAcquired = true;
|
|
245
|
-
return true;
|
|
246
|
-
} catch (err) {
|
|
247
|
-
if (err.code === 'EEXIST') {
|
|
248
|
-
// Lock file exists, wait and retry
|
|
249
|
-
this.sleep(100);
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
throw err;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return false;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Release the lock
|
|
261
|
-
*/
|
|
262
|
-
release() {
|
|
263
|
-
if (this.lockAcquired) {
|
|
264
|
-
try {
|
|
265
|
-
fs.unlinkSync(this.lockFilePath);
|
|
266
|
-
} catch {
|
|
267
|
-
// Ignore errors during release
|
|
268
|
-
}
|
|
269
|
-
this.lockAcquired = false;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Read lock file data
|
|
275
|
-
*/
|
|
276
|
-
readLockFile() {
|
|
277
|
-
try {
|
|
278
|
-
const content = fs.readFileSync(this.lockFilePath, 'utf8');
|
|
279
|
-
return JSON.parse(content);
|
|
280
|
-
} catch {
|
|
281
|
-
return null;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Check if lock is stale
|
|
287
|
-
*/
|
|
288
|
-
isLockStale(lockData) {
|
|
289
|
-
if (!lockData || !lockData.timestamp) {
|
|
290
|
-
return true;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Check if lock is too old
|
|
294
|
-
if (Date.now() - lockData.timestamp > LOCK_STALE_MS) {
|
|
295
|
-
return true;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Check if the process that holds the lock is still running
|
|
299
|
-
if (lockData.pid && lockData.hostname === os.hostname()) {
|
|
300
|
-
try {
|
|
301
|
-
process.kill(lockData.pid, 0); // Signal 0 just checks if process exists
|
|
302
|
-
return false; // Process exists, lock is valid
|
|
303
|
-
} catch {
|
|
304
|
-
return true; // Process doesn't exist, lock is stale
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
return false;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Simple sleep function
|
|
313
|
-
*/
|
|
314
|
-
sleep(ms) {
|
|
315
|
-
const end = Date.now() + ms;
|
|
316
|
-
while (Date.now() < end) {
|
|
317
|
-
// Busy wait (not ideal but works for short durations)
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
217
|
/**
|
|
323
218
|
* Check if clink is installed
|
|
324
219
|
* @returns {boolean}
|
|
@@ -404,7 +299,7 @@ class ExecutionStore {
|
|
|
404
299
|
writeLinoRecords(records) {
|
|
405
300
|
const data = records.map((r) => r.toObject());
|
|
406
301
|
const content = encodeSync(data);
|
|
407
|
-
|
|
302
|
+
atomicWrite(this.linoDbPath, content);
|
|
408
303
|
this.log(`Wrote ${records.length} records to lino file`);
|
|
409
304
|
}
|
|
410
305
|
|
|
@@ -521,7 +416,7 @@ class ExecutionStore {
|
|
|
521
416
|
* @param {ExecutionRecord} record
|
|
522
417
|
* @returns {boolean}
|
|
523
418
|
*/
|
|
524
|
-
save(record) {
|
|
419
|
+
save(record, previous = null) {
|
|
525
420
|
const lock = new LockManager(this.lockFilePath);
|
|
526
421
|
|
|
527
422
|
if (!lock.acquire()) {
|
|
@@ -534,6 +429,16 @@ class ExecutionStore {
|
|
|
534
429
|
|
|
535
430
|
// Find existing record index
|
|
536
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
|
+
}
|
|
537
442
|
|
|
538
443
|
if (existingIndex >= 0) {
|
|
539
444
|
// Update existing record
|
|
@@ -557,6 +462,33 @@ class ExecutionStore {
|
|
|
557
462
|
}
|
|
558
463
|
}
|
|
559
464
|
|
|
465
|
+
reserveLaunch(record, previous) {
|
|
466
|
+
return this.save(record, previous);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Merge watcher metadata under the database lock without replacing terminal state.
|
|
470
|
+
patchAttempt(uuid, number, fields) {
|
|
471
|
+
const lock = new LockManager(this.lockFilePath);
|
|
472
|
+
if (!lock.acquire()) {
|
|
473
|
+
throw new Error('Failed to acquire lock for attempt metadata');
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
const records = this.readLinoRecords();
|
|
477
|
+
const current = records.find((record) => record.uuid === uuid);
|
|
478
|
+
if (current?.attempt?.number !== number) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
current.attempt = { ...current.attempt, ...fields };
|
|
482
|
+
this.writeLinoRecords(records);
|
|
483
|
+
if (this.useLinks) {
|
|
484
|
+
this.writeLinksRecord(current);
|
|
485
|
+
}
|
|
486
|
+
return current;
|
|
487
|
+
} finally {
|
|
488
|
+
lock.release();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
560
492
|
/**
|
|
561
493
|
* Get an execution record by UUID or session name
|
|
562
494
|
* First tries exact UUID match, then the current session name, then any
|
|
@@ -658,6 +590,9 @@ class ExecutionStore {
|
|
|
658
590
|
const staleRecords = [];
|
|
659
591
|
|
|
660
592
|
for (const record of executingRecords) {
|
|
593
|
+
if (require('./launch-owner').hasActiveLaunch(record)) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
661
596
|
let isStale = false;
|
|
662
597
|
let reason = '';
|
|
663
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
|
+
}
|