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.
Files changed (45) hide show
  1. package/CHANGELOG.md +6 -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 +16 -0
  13. package/src/lib/docker-cleanup.js +23 -2
  14. package/src/lib/docker-resource-limits.js +28 -0
  15. package/src/lib/docker-resource-options.js +180 -0
  16. package/src/lib/docker-runtime-args.js +15 -0
  17. package/src/lib/execution-recovery.js +145 -29
  18. package/src/lib/execution-resume-all.js +11 -0
  19. package/src/lib/execution-resume.js +142 -29
  20. package/src/lib/execution-store.js +25 -121
  21. package/src/lib/exit-evidence.js +123 -0
  22. package/src/lib/exit-reason.js +24 -6
  23. package/src/lib/isolation.js +28 -3
  24. package/src/lib/launch-owner.js +25 -0
  25. package/src/lib/launch-persistence.js +54 -0
  26. package/src/lib/query-commands.js +14 -3
  27. package/src/lib/resume-resources.js +88 -0
  28. package/src/lib/status-formatter.js +28 -0
  29. package/src/lib/store-lock.js +133 -0
  30. package/src/lib/usage.js +11 -0
  31. package/test/docker-runtime-options.js +6 -0
  32. package/test/execution-resume.js +9 -3
  33. package/test/exit-reason.js +6 -1
  34. package/test/isolation.js +5 -4
  35. package/test/regression-174.js +3 -1
  36. package/test/regression-180.js +17 -10
  37. package/test/regression-182.js +7 -3
  38. package/test/regression-185.js +4 -9
  39. package/test/regression-187.js +2 -1
  40. package/test/regression-189.js +224 -0
  41. package/test/regression-190.js +111 -0
  42. package/test/regression-191.js +152 -0
  43. package/test/regression-192.js +66 -0
  44. package/test/regression-193.js +241 -0
  45. 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 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
@@ -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
- fs.writeFileSync(this.linoDbPath, content, 'utf8');
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
+ }
@@ -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 (marker) {
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 observed =
234
- input.oomKilled === true || cgroupOomKills(input.cgroupMemory) > 0;
235
- if (!observed) {
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 (fromLog) {
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 ? `signal (${signalName})` : null;
347
+ return signalName
348
+ ? `signal (${signalName}${signalName === 'SIGKILL' ? '; cause unknown' : ''})`
349
+ : null;
332
350
  }
333
351
 
334
352
  module.exports = {
@@ -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(store, identifier, command, outputFormat) {
58
+ async function handleResumeQuery(
59
+ store,
60
+ identifier,
61
+ command,
62
+ outputFormat,
63
+ resourceOptions = {}
64
+ ) {
59
65
  return reportResult(
60
- await resumeExecution(store, identifier, { command, outputFormat })
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 };