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
|
@@ -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
|
-
|
|
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
|
-
?
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
+
};
|