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
@@ -0,0 +1,175 @@
1
+ /** Docker CPU hysteresis with timestamp-weighted, fully covered windows. */
2
+ const DEFAULTS = {
3
+ cpus: 2,
4
+ trigger: 95,
5
+ triggerWindowMs: 900000,
6
+ release: 65,
7
+ releaseWindowMs: 900000,
8
+ };
9
+ const FIELDS = {
10
+ '--cpu-penalty-cpus': 'cpus',
11
+ '--cpu-penalty-trigger': 'trigger',
12
+ '--cpu-penalty-trigger-window': 'triggerWindowMs',
13
+ '--cpu-penalty-release': 'release',
14
+ '--cpu-penalty-release-window': 'releaseWindowMs',
15
+ };
16
+ function duration(value) {
17
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value);
18
+ const n = m
19
+ ? Number(m[1]) * { ms: 1, s: 1000, m: 60000, h: 3600000 }[m[2]]
20
+ : NaN;
21
+ if (!Number.isSafeInteger(n) || n <= 0) {
22
+ throw Error(`Invalid CPU penalty duration: ${value}`);
23
+ }
24
+ return n;
25
+ }
26
+ function parse(args, index, options) {
27
+ const [flag, inline] = args[index].split('=', 2);
28
+ if (flag === '--cpu-penalty') {
29
+ if (inline !== undefined) {
30
+ throw Error('--cpu-penalty does not take a value');
31
+ }
32
+ options.cpuPenalty = true;
33
+ return 1;
34
+ }
35
+ const field = FIELDS[flag];
36
+ if (!field) {
37
+ return 0;
38
+ }
39
+ const value = inline ?? args[index + 1];
40
+ if (!value) {
41
+ throw Error(`${flag} requires a value`);
42
+ }
43
+ const number = field.endsWith('Ms')
44
+ ? duration(value)
45
+ : Number(field === 'cpus' ? value : value.replace(/%$/, ''));
46
+ if (
47
+ !(number > 0) ||
48
+ !Number.isFinite(number) ||
49
+ (field !== 'cpus' && !field.endsWith('Ms') && number > 100)
50
+ ) {
51
+ throw Error(`Invalid ${flag}: ${value}`);
52
+ }
53
+ options.cpuPenaltyConfig = {
54
+ ...DEFAULTS,
55
+ ...options.cpuPenaltyConfig,
56
+ [field]: number,
57
+ };
58
+ return inline === undefined ? 2 : 1;
59
+ }
60
+ function validate(options) {
61
+ if (!options.cpuPenalty && !options.cpuPenaltyConfig) {
62
+ return;
63
+ }
64
+ if (!options.cpuPenalty) {
65
+ throw Error('CPU penalty settings require --cpu-penalty');
66
+ }
67
+ if (!(options.isolatedStack || [options.isolated]).includes('docker')) {
68
+ throw Error('--cpu-penalty requires Docker isolation');
69
+ }
70
+ options.cpuPenaltyConfig = { ...DEFAULTS, ...options.cpuPenaltyConfig };
71
+ }
72
+ function initialState(baseCpus = null, now = Date.now(), saved = null) {
73
+ return {
74
+ phase: saved?.phase === 'penalized' ? 'penalized' : 'observing',
75
+ since: saved?.phase === 'penalized' ? saved.since : now,
76
+ limitCpus: saved?.phase === 'penalized' ? saved.limitCpus : baseCpus,
77
+ baseCpus,
78
+ penaltyCount: saved?.penaltyCount || 0,
79
+ penalizedMs: saved?.penalizedMs || 0,
80
+ samples: [],
81
+ lastAt: now,
82
+ capacity: null,
83
+ };
84
+ }
85
+ function average(samples, now, window) {
86
+ const start = now - window;
87
+ if (
88
+ samples.length < 2 ||
89
+ samples[0].at > start ||
90
+ samples.at(-1).at !== now
91
+ ) {
92
+ return null;
93
+ }
94
+ let weighted = 0;
95
+ for (let i = 1; i < samples.length; i++) {
96
+ const left = Math.max(start, samples[i - 1].at),
97
+ right = samples[i].at;
98
+ if (right > left) {
99
+ weighted += (right - left) * samples[i - 1].cores;
100
+ }
101
+ }
102
+ return weighted / window;
103
+ }
104
+ function evaluate(
105
+ state,
106
+ { now, cores, daemonCpus, maxGapMs },
107
+ config = DEFAULTS
108
+ ) {
109
+ const s = { ...state, samples: [...state.samples] };
110
+ if (s.phase === 'penalized') {
111
+ s.penalizedMs += Math.max(0, now - s.lastAt);
112
+ }
113
+ s.lastAt = now;
114
+ const capacity = Number.isFinite(daemonCpus)
115
+ ? Math.min(s.baseCpus || Infinity, daemonCpus)
116
+ : NaN;
117
+ if (
118
+ !(cores >= 0) ||
119
+ !Number.isFinite(cores) ||
120
+ !(capacity > 0) ||
121
+ now <= (s.samples.at(-1)?.at ?? -Infinity) ||
122
+ (s.samples.length && now - s.samples.at(-1).at > maxGapMs) ||
123
+ s.capacity !== capacity
124
+ ) {
125
+ s.samples = [];
126
+ }
127
+ s.capacity = capacity;
128
+ if (!(cores >= 0) || !Number.isFinite(cores) || !(capacity > 0)) {
129
+ return { state: s, action: null };
130
+ }
131
+ s.samples.push({ at: now, cores });
132
+ const window =
133
+ s.phase === 'penalized' ? config.releaseWindowMs : config.triggerWindowMs;
134
+ while (s.samples.length > 2 && s.samples[1].at <= now - window) {
135
+ s.samples.shift();
136
+ }
137
+ const avg = average(s.samples, now, window);
138
+ let action = null;
139
+ if (
140
+ s.phase === 'observing' &&
141
+ config.cpus < capacity &&
142
+ avg !== null &&
143
+ avg >= (config.trigger / 100) * capacity
144
+ ) {
145
+ action = { kind: 'apply', cpus: config.cpus, average: avg, capacity };
146
+ s.phase = 'penalized';
147
+ s.since = now;
148
+ s.limitCpus = config.cpus;
149
+ s.penaltyCount++;
150
+ } else if (
151
+ s.phase === 'penalized' &&
152
+ (config.cpus >= capacity ||
153
+ (now - s.since >= config.releaseWindowMs &&
154
+ avg !== null &&
155
+ avg < (config.release / 100) * config.cpus))
156
+ ) {
157
+ action = { kind: 'lift', cpus: capacity, average: avg, capacity };
158
+ s.phase = 'observing';
159
+ s.since = now;
160
+ s.limitCpus = capacity;
161
+ }
162
+ if (action) {
163
+ s.samples = [];
164
+ }
165
+ return { state: s, action };
166
+ }
167
+ module.exports = {
168
+ DEFAULTS,
169
+ parse,
170
+ validate,
171
+ duration,
172
+ initialState,
173
+ average,
174
+ evaluate,
175
+ };
@@ -85,6 +85,17 @@ function finalizeDetachedExecution(options = {}) {
85
85
  if (!record) {
86
86
  return { updated: false, reason: 'record-not-found' };
87
87
  }
88
+ const started = normalizeDockerTimestamp(options.startedAt);
89
+ const attemptNumber = options.attemptNumber ?? null;
90
+ if (
91
+ record.attempt &&
92
+ ((attemptNumber !== null &&
93
+ Number(attemptNumber) !== record.attempt.number) ||
94
+ (started && Date.parse(started) < Date.parse(record.attempt.startedAt)) ||
95
+ (attemptNumber === null && !started))
96
+ ) {
97
+ return { updated: false, reason: 'stale-attempt' };
98
+ }
88
99
  if (record.status === 'executed' && record.endTime) {
89
100
  // Already finalized (e.g. a `--status` query got there first, or the
90
101
  // watcher ran twice after a resume). Nothing to correct.
@@ -135,6 +146,21 @@ function finalizeDetachedExecution(options = {}) {
135
146
  record.cgroupMemory = cgroupMemory;
136
147
  }
137
148
 
149
+ const evidence = require('./exit-evidence');
150
+ const logged = evidence.fromLog(
151
+ record.logPath
152
+ ? require('./execution-attempt').readAttemptLogTail(record, 65536)
153
+ : null
154
+ );
155
+ record.options.exitEvidence = {
156
+ daemonRestart: logged.daemonRestart,
157
+ mainOom:
158
+ !logged.daemonRestart &&
159
+ (logged.mainOom ||
160
+ (evidence.recentOomDelta(options.cgroupMemory, options.finishedAt) &&
161
+ record.oomKilled === true &&
162
+ (record.exitCode === 137 || record.exitCode < 0))),
163
+ };
138
164
  const exitReason =
139
165
  resolveReason(record) || (finishedAt ? null : WATCHER_LOST_CONTAINER);
140
166
  if (exitReason) {
@@ -146,11 +172,24 @@ function finalizeDetachedExecution(options = {}) {
146
172
  record.options = { ...(record.options || {}), containerError };
147
173
  }
148
174
 
175
+ const {
176
+ appendLifecycle,
177
+ readAttemptActivity,
178
+ } = require('./execution-attempt');
179
+ readAttemptActivity(record);
149
180
  try {
150
181
  store.save(record);
151
182
  } catch (err) {
152
183
  return { updated: false, reason: `save-failed: ${err.message}`, record };
153
184
  }
185
+ appendLifecycle(record, 'terminal', {
186
+ status: record.status,
187
+ exitCode: record.exitCode,
188
+ endTime: record.endTime,
189
+ exitReason: record.exitReason || null,
190
+ oomKilled: record.oomKilled ?? null,
191
+ cgroupMemory: record.cgroupMemory || null,
192
+ });
154
193
  return { updated: true, record };
155
194
  }
156
195
 
@@ -163,18 +202,17 @@ function finalizeDetachedExecution(options = {}) {
163
202
  function resolveReason(record) {
164
203
  try {
165
204
  const { resolveExitReason } = require('./exit-reason');
166
- const {
167
- FATAL_MARKER_TAIL_BYTES,
168
- readLogTail,
169
- } = require('./isolation-log-utils');
205
+ const { FATAL_MARKER_TAIL_BYTES } = require('./isolation-log-utils');
206
+ const { readAttemptLogTail } = require('./execution-attempt');
170
207
  const logTail = record.logPath
171
- ? readLogTail(record.logPath, FATAL_MARKER_TAIL_BYTES)
208
+ ? readAttemptLogTail(record, FATAL_MARKER_TAIL_BYTES)
172
209
  : null;
173
210
  return resolveExitReason({
174
211
  exitCode: record.exitCode,
175
212
  logTail,
176
213
  oomKilled: record.oomKilled,
177
214
  cgroupMemory: record.cgroupMemory,
215
+ exitEvidence: record.options?.exitEvidence,
178
216
  });
179
217
  } catch {
180
218
  return null;
@@ -251,13 +289,13 @@ function reconcileFinalizedRecord(store, record) {
251
289
  * @param {string} executionId - UUID of the record to finalize
252
290
  * @returns {string} POSIX shell fragment
253
291
  */
254
- function buildDetachedFinalizeSnippet(executionId) {
292
+ function buildDetachedFinalizeSnippet(executionId, attemptNumber = null) {
255
293
  const v = SHELL_VARS;
256
294
  return (
257
295
  `${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
258
296
  `${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
259
297
  `"$${v.started}" "$${v.finished}" "$${v.error}" "$${v.running}" ` +
260
- `"$${CGROUP_SHELL_VARS.sample}" >/dev/null 2>&1 || true`
298
+ `"$${CGROUP_SHELL_VARS.sample}"${attemptNumber === null ? '' : ` ${shellQuote(attemptNumber)}`} >/dev/null 2>&1 || true`
261
299
  );
262
300
  }
263
301
 
@@ -279,6 +317,7 @@ function main(argv) {
279
317
  containerError,
280
318
  running,
281
319
  cgroupMemory,
320
+ attemptNumber,
282
321
  ] = argv;
283
322
  if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
284
323
  return;
@@ -300,6 +339,7 @@ function main(argv) {
300
339
  containerError,
301
340
  running,
302
341
  cgroupMemory,
342
+ attemptNumber: attemptNumber || null,
303
343
  });
304
344
  } catch {
305
345
  // Deliberately silent: see the exit-0 contract above.
@@ -0,0 +1,61 @@
1
+ /** Stream Docker output without buffering lines; observe only timestamped output. */
2
+ const fs = require('fs');
3
+ const { StringDecoder } = require('string_decoder');
4
+ const { activityPath } = require('./execution-attempt');
5
+
6
+ class OutputObserver {
7
+ constructor(logPath, number, since) {
8
+ this.logPath = logPath;
9
+ this.activity = activityPath({ logPath, attempt: { number } });
10
+ this.since = Date.parse(since);
11
+ this.prefix = '';
12
+ this.atLineStart = true;
13
+ this.latest = null;
14
+ this.decoder = new StringDecoder('utf8');
15
+ }
16
+
17
+ write(chunk) {
18
+ fs.appendFileSync(this.logPath, chunk);
19
+ let latest = null;
20
+ for (const character of this.decoder.write(chunk)) {
21
+ if (character === '\n') {
22
+ this.atLineStart = true;
23
+ this.prefix = '';
24
+ } else if (this.atLineStart) {
25
+ this.prefix += character;
26
+ if (character === ' ') {
27
+ const text = this.prefix.trim();
28
+ const time = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(
29
+ text
30
+ )
31
+ ? Date.parse(text)
32
+ : NaN;
33
+ if (
34
+ Number.isFinite(time) &&
35
+ time >= this.since &&
36
+ (this.latest === null || time > this.latest)
37
+ ) {
38
+ this.latest = time;
39
+ latest = new Date(time).toISOString();
40
+ }
41
+ this.atLineStart = false;
42
+ } else if (this.prefix.length > 80) {
43
+ this.atLineStart = false;
44
+ }
45
+ }
46
+ }
47
+ if (latest) {
48
+ const temporary = `${this.activity}.${process.pid}.tmp`;
49
+ fs.writeFileSync(temporary, latest);
50
+ fs.renameSync(temporary, this.activity);
51
+ }
52
+ }
53
+ }
54
+
55
+ if (require.main === module) {
56
+ const [logPath, number, since] = process.argv.slice(2);
57
+ const observer = new OutputObserver(logPath, Number(number), since);
58
+ process.stdin.on('data', (chunk) => observer.write(chunk));
59
+ }
60
+
61
+ module.exports = { OutputObserver };
@@ -1,4 +1,5 @@
1
1
  const { spawn, spawnSync } = require('child_process');
2
+ const path = require('path');
2
3
  const {
3
4
  appendLogFile,
4
5
  createShellLogFooterSnippet,
@@ -274,10 +275,13 @@ function buildAttachedDockerKeptMessage({
274
275
  oomKilled,
275
276
  logPath,
276
277
  }) {
278
+ const logTail = logPath
279
+ ? readLogTail(logPath, FATAL_MARKER_TAIL_BYTES)
280
+ : null;
277
281
  let message;
278
282
  if (oomKilled !== true) {
279
283
  message = `\nContainer kept because the command failed.`;
280
- } else if (isOomKillOfCommand({ exitCode, oomKilled })) {
284
+ } else if (isOomKillOfCommand({ exitCode, oomKilled, logTail })) {
281
285
  message = `\nContainer kept because Docker reports it was OOM-killed.`;
282
286
  } else {
283
287
  // The flag is container-wide (#180): a child was OOM-killed, the command
@@ -286,7 +290,7 @@ function buildAttachedDockerKeptMessage({
286
290
  }
287
291
  const memory = resolveMemoryExhaustion({
288
292
  exitCode,
289
- logTail: logPath ? readLogTail(logPath, FATAL_MARKER_TAIL_BYTES) : null,
293
+ logTail,
290
294
  oomKilled,
291
295
  });
292
296
  if (memory) {
@@ -359,7 +363,17 @@ function buildDetachedDockerCompletionScript(
359
363
  const quotedName = shellQuote(containerName);
360
364
  // The container's cgroup disappears with it, so its memory counters are
361
365
  // sampled while it runs (issue #182).
366
+ const cpuMonitor = require('./cpu-penalty-monitor');
362
367
  const parts = [buildCgroupSamplerStartSnippet(containerName)];
368
+ const cpuStart = cpuMonitor.startSnippet(
369
+ executionId,
370
+ containerName,
371
+ watcherOptions.attemptNumber || 1,
372
+ watcherOptions.cpuOptions
373
+ );
374
+ if (cpuStart) {
375
+ parts.push(cpuStart);
376
+ }
363
377
  // Everything that assumes the container has exited: cleanup, footer and
364
378
  // finalization. Guarded as a whole by `.State.Running` below.
365
379
  const exited = [];
@@ -381,10 +395,26 @@ function buildDetachedDockerCompletionScript(
381
395
  const since = watcherOptions.since
382
396
  ? ` --since ${shellQuote(watcherOptions.since)}`
383
397
  : '';
384
- parts.push(`docker logs -f${since} ${quotedName} >> ${quotedLogPath} 2>&1`);
398
+ if (watcherOptions.attemptNumber) {
399
+ const capture = shellQuote(path.join(__dirname, 'detached-output.js'));
400
+ parts.push(
401
+ `docker logs -f --timestamps${since} ${quotedName} 2>&1 | ${shellQuote(process.execPath)} ${capture} ${quotedLogPath} ${watcherOptions.attemptNumber} ${shellQuote(watcherOptions.since)}`
402
+ );
403
+ } else {
404
+ parts.push(
405
+ `docker logs -f${since} ${quotedName} >> ${quotedLogPath} 2>&1`
406
+ );
407
+ }
385
408
  parts.push(buildDockerWaitForExitSnippet(containerName, quotedLogPath));
409
+ parts.push(cpuMonitor.stopSnippet());
386
410
  parts.push(buildCgroupSamplerStopSnippet());
387
411
  parts.push(buildDockerStateSnippet(containerName));
412
+ parts.push(
413
+ require('./exit-evidence').buildExitEvidenceSnippet(
414
+ containerName,
415
+ quotedLogPath
416
+ )
417
+ );
388
418
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
389
419
  exited.push(remove);
390
420
  } else if (
@@ -404,6 +434,7 @@ function buildDetachedDockerCompletionScript(
404
434
  } else {
405
435
  parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
406
436
  parts.push(buildDockerWaitForExitSnippet(containerName));
437
+ parts.push(cpuMonitor.stopSnippet());
407
438
  parts.push(buildCgroupSamplerStopSnippet());
408
439
  parts.push(buildDockerStateSnippet(containerName));
409
440
  if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
@@ -420,7 +451,9 @@ function buildDetachedDockerCompletionScript(
420
451
 
421
452
  if (executionId) {
422
453
  // Last, so the record is only marked terminal once the log is complete.
423
- exited.push(buildDetachedFinalizeSnippet(executionId));
454
+ exited.push(
455
+ buildDetachedFinalizeSnippet(executionId, watcherOptions.attemptNumber)
456
+ );
424
457
  }
425
458
 
426
459
  const stillRunning = logPath
@@ -474,7 +507,13 @@ function startDetachedDockerCompletionWatcher(
474
507
  stdio: 'ignore',
475
508
  }
476
509
  );
510
+ watcher.on('error', (error) => {
511
+ if (process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true') {
512
+ console.error(`[DEBUG] completion watcher failed: ${error.message}`);
513
+ }
514
+ });
477
515
  watcher.unref();
516
+ return Boolean(watcher.pid);
478
517
  }
479
518
 
480
519
  function spawnAttachedDocker(dockerArgs, logPath) {
@@ -200,8 +200,36 @@ function buildResourceLimitsStatusLine(value) {
200
200
  : null;
201
201
  }
202
202
 
203
+ /** Preserve Docker's CPU representation: an existing quota cannot become NanoCPUs in place. */
204
+ function cpuCount(limits) {
205
+ const get = (prefix) =>
206
+ positive(limits.find((s) => s.startsWith(prefix))?.slice(prefix.length));
207
+ return (
208
+ get('--cpus=') ||
209
+ (get('--cpu-quota=')
210
+ ? get('--cpu-quota=') / (get('--cpu-period=') || 100000)
211
+ : null)
212
+ );
213
+ }
214
+ function cpuUpdateArgs(limits, cpus) {
215
+ return !limits.some((s) => s.startsWith('--cpus=')) &&
216
+ limits.some((s) => /^--cpu-(quota|period)=/.test(s))
217
+ ? ['--cpu-period=100000', `--cpu-quota=${Math.ceil(cpus * 100000)}`]
218
+ : ['--cpus', String(cpus)];
219
+ }
220
+ function withCpuLimit(limits, cpus) {
221
+ const args = cpuUpdateArgs(limits, cpus);
222
+ return [
223
+ ...limits.filter((s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
224
+ ...(args[0] === '--cpus' ? [`--cpus=${args[1]}`] : args),
225
+ ];
226
+ }
227
+
203
228
  module.exports = {
204
229
  DEFAULT_SHM_SIZE,
230
+ cpuCount,
231
+ cpuUpdateArgs,
232
+ withCpuLimit,
205
233
  buildResourceLimitsStatusLine,
206
234
  formatDockerBytes,
207
235
  formatDockerCpus,
@@ -0,0 +1,180 @@
1
+ /** Launch and recovery resource specifications, resolved against the daemon. */
2
+ const { getDockerCommand } = require('./docker-cleanup');
3
+ const { normalizeResourceLimits } = require('./docker-resource-limits');
4
+ const FIELDS = {
5
+ '--memory': 'memory',
6
+ '--memory-swap': 'memorySwap',
7
+ '--cpus': 'cpus',
8
+ '--on-kill-resume-memory': 'onKillResumeMemory',
9
+ };
10
+
11
+ function parseResourceSpec(value, cpu = false) {
12
+ const text = String(value).trim().toLowerCase();
13
+ const percent = /^(\d+(?:\.\d+)?)%(?:-(\d+(?:\.\d+)?)%)?$/.exec(text);
14
+ if (percent) {
15
+ const min = Number(percent[1]),
16
+ max = Number(percent[2] || percent[1]);
17
+ if (min > 0 && max <= 100 && min <= max) {
18
+ return { min, max, percent: true };
19
+ }
20
+ } else {
21
+ const absolute = /^(\d+(?:\.\d+)?)(b|k|m|g|t|kb|mb|gb|tb)?$/.exec(text);
22
+ if (absolute && (!cpu || !absolute[2])) {
23
+ const power = ['', 'k', 'm', 'g', 't'].indexOf(
24
+ (absolute[2] || '').replace('b', '')
25
+ );
26
+ const amount =
27
+ Number(absolute[1]) * (cpu ? 1 : 1024 ** Math.max(0, power));
28
+ if (
29
+ Number.isFinite(amount) &&
30
+ amount > 0 &&
31
+ amount <= Number.MAX_SAFE_INTEGER
32
+ ) {
33
+ return { amount };
34
+ }
35
+ }
36
+ }
37
+ throw new Error(
38
+ `Invalid resource limit: "${value}". Expected a positive ${cpu ? 'CPU count' : 'Docker size'}, N% or MIN%-MAX% within (0, 100].`
39
+ );
40
+ }
41
+
42
+ function parse(args, index, options) {
43
+ const [flag, inline] = args[index].split('=', 2);
44
+ if (!FIELDS[flag]) {
45
+ return 0;
46
+ }
47
+ const value = inline ?? args[index + 1];
48
+ if (!value || value.startsWith('-')) {
49
+ throw new Error(`Option ${flag} requires a resource limit`);
50
+ }
51
+ parseResourceSpec(value, flag === '--cpus');
52
+ options[FIELDS[flag]] = value;
53
+ return inline === undefined ? 2 : 1;
54
+ }
55
+
56
+ function validate(options) {
57
+ if (
58
+ !Object.values(FIELDS).some(
59
+ (field) => options[field] !== null && options[field] !== undefined
60
+ )
61
+ ) {
62
+ return;
63
+ }
64
+ if (
65
+ !options.resume &&
66
+ !(options.isolatedStack || [options.isolated]).includes('docker')
67
+ ) {
68
+ throw new Error('Resource options require Docker isolation or --resume');
69
+ }
70
+ if (options.memorySwap && !options.memory && !options.resume) {
71
+ throw new Error('--memory-swap requires --memory');
72
+ }
73
+ if (
74
+ options.onKillResumeMemory &&
75
+ !options.onKillResume &&
76
+ !options.recoveryCommand
77
+ ) {
78
+ throw new Error(
79
+ '--on-kill-resume-memory requires --on-kill-resume or --recovery-command'
80
+ );
81
+ }
82
+ }
83
+
84
+ function readDaemonCapacity(runner) {
85
+ const result = runner(getDockerCommand(), ['info', '--format', '{{json .}}']);
86
+ if (!result?.success) {
87
+ throw new Error('Cannot read resource capacity from Docker daemon');
88
+ }
89
+ const info = JSON.parse(result.stdout);
90
+ if (!(info.MemTotal > 0) || !(info.NCPU > 0)) {
91
+ throw new Error('Docker daemon did not report MemTotal/NCPU');
92
+ }
93
+ return { memory: info.MemTotal, cpus: info.NCPU };
94
+ }
95
+
96
+ function resolveResourceOptions(options, runner, random = Math.random) {
97
+ const specs = {};
98
+ for (const field of ['memory', 'memorySwap', 'cpus']) {
99
+ if (options[field] !== null && options[field] !== undefined) {
100
+ specs[field] = parseResourceSpec(options[field], field === 'cpus');
101
+ }
102
+ }
103
+ const capacity = Object.values(specs).some((s) => s.percent)
104
+ ? readDaemonCapacity(runner)
105
+ : null;
106
+ const resolved = {};
107
+ const limits = normalizeResourceLimits(options.resourceLimits);
108
+ for (const [field, spec] of Object.entries(specs)) {
109
+ const amount = spec.percent
110
+ ? (capacity[field === 'cpus' ? 'cpus' : 'memory'] *
111
+ (spec.min + (spec.max - spec.min) * random())) /
112
+ 100
113
+ : spec.amount;
114
+ resolved[field] =
115
+ field === 'cpus' ? Math.floor(amount * 1e9) / 1e9 : Math.floor(amount);
116
+ if (resolved[field] < (field === 'cpus' ? 0.01 : 6 * 1024 * 1024)) {
117
+ throw new Error(`Resolved ${field} is below Docker's minimum`);
118
+ }
119
+ }
120
+ if (
121
+ resolved.memory !== null &&
122
+ resolved.memory !== undefined &&
123
+ (resolved.memorySwap === null || resolved.memorySwap === undefined)
124
+ ) {
125
+ resolved.memorySwap = resolved.memory;
126
+ }
127
+ const existingMemory = limits
128
+ .find((flag) => flag.startsWith('--memory='))
129
+ ?.slice(9);
130
+ const memory =
131
+ resolved.memory ??
132
+ (existingMemory ? parseResourceSpec(existingMemory).amount : null);
133
+ if (
134
+ resolved.memorySwap !== null &&
135
+ resolved.memorySwap !== undefined &&
136
+ (memory === null || memory === undefined || resolved.memorySwap < memory)
137
+ ) {
138
+ throw new Error('memory-swap must be at least memory');
139
+ }
140
+ for (const [field, amount] of Object.entries(resolved)) {
141
+ const flag = field === 'memorySwap' ? '--memory-swap' : `--${field}`;
142
+ for (let i = limits.length - 1; i >= 0; i--) {
143
+ if (
144
+ limits[i].startsWith(`${flag}=`) ||
145
+ (field === 'cpus' && /^--cpu-(quota|period)=/.test(limits[i]))
146
+ ) {
147
+ limits.splice(i, 1);
148
+ }
149
+ }
150
+ limits.push(`${flag}=${amount}`);
151
+ }
152
+ return {
153
+ resourceLimits: limits,
154
+ resolvedLimits: resolved,
155
+ resourceLimitSpecs: Object.fromEntries(
156
+ Object.keys(specs).map((field) => [field, options[field]])
157
+ ),
158
+ };
159
+ }
160
+
161
+ function limitsLogLine(options) {
162
+ const values = options.resolvedLimits || {};
163
+ return Object.keys(values).length
164
+ ? `Limits: ${Object.entries(values)
165
+ .map(
166
+ ([field, value]) =>
167
+ `${field}=${value}${options.resourceLimitSpecs?.[field] ? ` (${options.resourceLimitSpecs[field]} -> ${value})` : ''}`
168
+ )
169
+ .join(' ')}`
170
+ : null;
171
+ }
172
+
173
+ module.exports = {
174
+ parse,
175
+ validate,
176
+ parseResourceSpec,
177
+ readDaemonCapacity,
178
+ resolveResourceOptions,
179
+ limitsLogLine,
180
+ };