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.
Files changed (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +10 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +43 -7
  5. package/src/lib/args-parser.js +12 -0
  6. package/src/lib/atomic-write.js +34 -0
  7. package/src/lib/attached-diagnostics.js +90 -0
  8. package/src/lib/cgroup-memory.js +43 -11
  9. package/src/lib/command-builder.js +40 -0
  10. package/src/lib/cpu-penalty-monitor.js +226 -0
  11. package/src/lib/cpu-penalty.js +175 -0
  12. package/src/lib/detached-finalize.js +47 -7
  13. package/src/lib/detached-output.js +61 -0
  14. package/src/lib/docker-cleanup.js +43 -4
  15. package/src/lib/docker-resource-limits.js +28 -0
  16. package/src/lib/docker-resource-options.js +180 -0
  17. package/src/lib/docker-runtime-args.js +15 -0
  18. package/src/lib/execution-attempt.js +153 -0
  19. package/src/lib/execution-recovery.js +191 -15
  20. package/src/lib/execution-resume-all.js +23 -1
  21. package/src/lib/execution-resume.js +203 -40
  22. package/src/lib/execution-store.js +56 -121
  23. package/src/lib/exit-evidence.js +123 -0
  24. package/src/lib/exit-reason.js +24 -6
  25. package/src/lib/isolation-log-utils.js +3 -3
  26. package/src/lib/isolation.js +36 -9
  27. package/src/lib/launch-owner.js +25 -0
  28. package/src/lib/launch-persistence.js +54 -0
  29. package/src/lib/query-commands.js +14 -3
  30. package/src/lib/resume-resources.js +88 -0
  31. package/src/lib/status-formatter.js +66 -6
  32. package/src/lib/store-lock.js +133 -0
  33. package/src/lib/usage.js +11 -0
  34. package/test/docker-runtime-options.js +6 -0
  35. package/test/execution-resume.js +10 -3
  36. package/test/exit-reason.js +6 -1
  37. package/test/isolation.js +5 -4
  38. package/test/regression-174.js +3 -1
  39. package/test/regression-176.js +5 -1
  40. package/test/regression-180.js +17 -10
  41. package/test/regression-182.js +7 -3
  42. package/test/regression-185.js +4 -9
  43. package/test/regression-187.js +547 -0
  44. package/test/regression-189.js +224 -0
  45. package/test/regression-190.js +111 -0
  46. package/test/regression-191.js +152 -0
  47. package/test/regression-192.js +66 -0
  48. package/test/regression-193.js +241 -0
  49. 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(record, plan, containerId) {
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 = new Date().toISOString();
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
- // Only a snapshot resume creates a new container, so only it needs the old
405
- // container's limits (issue #176).
406
- const liveResourceLimits =
407
- deps.command &&
408
- opts.isolated === 'docker' &&
409
- opts.sessionName &&
410
- probe &&
411
- !probe.alive &&
412
- probe.state === SessionState.STOPPED
413
- ? readDockerResourceLimits(opts.sessionName, runner)
414
- : null;
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
- record,
455
+ planningRecord,
417
456
  deps.command || null,
418
457
  probe,
419
- liveResourceLimits
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
- const launchResult = await runIsolated(
430
- plan.backend,
431
- plan.command,
432
- plan.launchOptions
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
- const result = runner(step.command, step.args);
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
- // The completion watcher died with the previous run (or with the
465
- // supervisor), so a new one must follow the resumed container.
466
- const startWatcher =
467
- deps.startWatcher || startDetachedDockerCompletionWatcher;
468
- startWatcher(
469
- activeSessionName(plan),
470
- getDockerContainerCleanupPolicy(record.options || {}),
471
- record.logPath || null,
472
- record.uuid || null,
473
- // `docker start` re-runs the launch-time selector, so the session keeps
474
- // its kill recovery (issue #176); a snapshot container has no selector.
475
- {
476
- recoverOnKill:
477
- plan.mode === ResumeMode.DOCKER_START && Boolean(opts.onKillResume),
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
- const previousSessionName = plan.newSessionName ? plan.sessionName : null;
483
- const updated = applyResumeToRecord(record, plan, containerId);
484
- store.save(updated);
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 LOCK_TIMEOUT_MS = 30000; // 30 second timeout for lock acquisition
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
- fs.writeFileSync(this.linoDbPath, content, 'utf8');
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
+ }