start-command 0.37.0 → 0.37.1
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 +6 -0
- package/package.json +1 -1
- package/src/lib/cpu-penalty-monitor.js +209 -87
- package/src/lib/cpu-penalty.js +30 -1
- package/src/lib/resume-resources.js +15 -3
- package/src/lib/store-lock.js +54 -3
- package/test/regression-207.js +249 -0
- package/test/shared-golden.js +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.37.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c67c92f: Observe Docker CPU limits applied after launch, restore the remembered base on lift and resume, and recover CPU penalty monitors after crashes, zombie/reused-PID locks and transient tick failures.
|
|
8
|
+
|
|
3
9
|
## 0.37.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { spawnSync } = require('child_process');
|
|
5
|
-
const {
|
|
5
|
+
const {
|
|
6
|
+
initialState,
|
|
7
|
+
evaluate,
|
|
8
|
+
observeBase,
|
|
9
|
+
transitionLog,
|
|
10
|
+
} = require('./cpu-penalty');
|
|
6
11
|
const { LockManager } = require('./store-lock');
|
|
7
12
|
const { shellQuote } = require('./isolation-log-utils');
|
|
8
|
-
const {
|
|
13
|
+
const {
|
|
14
|
+
cpuCount,
|
|
15
|
+
cpuUpdateArgs,
|
|
16
|
+
normalizeResourceLimits,
|
|
17
|
+
readDockerResourceLimits,
|
|
18
|
+
} = require('./docker-resource-limits');
|
|
9
19
|
function statePath(record) {
|
|
10
20
|
return `${record.logPath}.cpu-penalty.json`;
|
|
11
21
|
}
|
|
@@ -40,9 +50,21 @@ function publicState(state) {
|
|
|
40
50
|
}
|
|
41
51
|
function run(command, args) {
|
|
42
52
|
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
43
|
-
return {
|
|
53
|
+
return {
|
|
54
|
+
success: result.status === 0,
|
|
55
|
+
stdout: result.stdout || '',
|
|
56
|
+
stderr: result.stderr || '',
|
|
57
|
+
};
|
|
44
58
|
}
|
|
45
|
-
async function monitor(
|
|
59
|
+
async function monitor(
|
|
60
|
+
uuid,
|
|
61
|
+
name,
|
|
62
|
+
attemptNumber,
|
|
63
|
+
standalone,
|
|
64
|
+
dependencies = {}
|
|
65
|
+
) {
|
|
66
|
+
const filesystem = dependencies.fs || fs;
|
|
67
|
+
const runner = dependencies.run || run;
|
|
46
68
|
const { ExecutionStore } = require('./execution-store');
|
|
47
69
|
const store = uuid
|
|
48
70
|
? new ExecutionStore(
|
|
@@ -60,36 +82,40 @@ async function monitor(uuid, name, attemptNumber, standalone) {
|
|
|
60
82
|
return;
|
|
61
83
|
}
|
|
62
84
|
const config = record.options.cpuPenaltyConfig;
|
|
63
|
-
|
|
85
|
+
if ((record.attempt?.number || 1) !== Number(attemptNumber)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const lock = new LockManager(`${statePath(record)}.lock`, filesystem, true);
|
|
64
89
|
if (!lock.acquire(100)) {
|
|
65
90
|
return;
|
|
66
91
|
}
|
|
67
92
|
const docker = require('./docker-cleanup').getDockerCommand();
|
|
68
|
-
|
|
69
|
-
record.options.baseResourceLimits || record.options.resourceLimits
|
|
93
|
+
let baseFlags = normalizeResourceLimits(
|
|
94
|
+
record.options.baseResourceLimits || record.options.resourceLimits
|
|
95
|
+
);
|
|
70
96
|
const baseCpus = cpuCount(baseFlags);
|
|
71
97
|
let state = initialState(baseCpus, Date.now(), readState(record));
|
|
72
98
|
const interval = Math.min(
|
|
73
99
|
30000,
|
|
74
100
|
Math.max(50, Math.min(config.triggerWindowMs, config.releaseWindowMs) / 8)
|
|
75
101
|
);
|
|
76
|
-
const publish = () => {
|
|
102
|
+
const publish = (value = state) => {
|
|
77
103
|
const file = statePath(record),
|
|
78
104
|
tmp = `${file}.${process.pid}.tmp`;
|
|
79
105
|
try {
|
|
80
|
-
|
|
106
|
+
filesystem.writeFileSync(
|
|
81
107
|
tmp,
|
|
82
108
|
JSON.stringify({
|
|
83
|
-
...publicState(
|
|
109
|
+
...publicState(value),
|
|
84
110
|
containerName: name,
|
|
85
111
|
attemptNumber: Number(attemptNumber),
|
|
86
112
|
updatedAt: Date.now(),
|
|
87
113
|
})
|
|
88
114
|
);
|
|
89
|
-
|
|
115
|
+
filesystem.renameSync(tmp, file);
|
|
90
116
|
} finally {
|
|
91
117
|
try {
|
|
92
|
-
|
|
118
|
+
filesystem.unlinkSync(tmp);
|
|
93
119
|
} catch {
|
|
94
120
|
/* Optional diagnostics are best effort. */
|
|
95
121
|
}
|
|
@@ -97,95 +123,188 @@ async function monitor(uuid, name, attemptNumber, standalone) {
|
|
|
97
123
|
};
|
|
98
124
|
const log = (text) => {
|
|
99
125
|
try {
|
|
100
|
-
|
|
126
|
+
filesystem.appendFileSync(record.logPath, `${text}\n`);
|
|
101
127
|
} catch {
|
|
102
128
|
/* Optional diagnostics are best effort. */
|
|
103
129
|
}
|
|
104
130
|
};
|
|
105
|
-
const
|
|
131
|
+
const debug = (error) => {
|
|
132
|
+
if (process.env.START_DEBUG === '1') {
|
|
133
|
+
const message = `[DEBUG] CPU monitor: ${error.stack || error}`;
|
|
134
|
+
console.error(message);
|
|
135
|
+
log(message);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
let stopping = false,
|
|
139
|
+
wake;
|
|
140
|
+
const stop = () => {
|
|
141
|
+
stopping = true;
|
|
142
|
+
wake?.();
|
|
143
|
+
};
|
|
144
|
+
process.on('SIGTERM', stop);
|
|
145
|
+
process.on('SIGINT', stop);
|
|
106
146
|
try {
|
|
107
|
-
|
|
108
|
-
while (true) {
|
|
109
|
-
const current = store ? store.get(uuid) : record;
|
|
110
|
-
if (
|
|
111
|
-
!current ||
|
|
112
|
-
current.options.sessionName !== name ||
|
|
113
|
-
(current.attempt?.number || 1) !== Number(attemptNumber)
|
|
114
|
-
) {
|
|
115
|
-
break;
|
|
116
|
-
}
|
|
117
|
-
const live = run(docker, ['inspect', '-f', '{{.State.Running}}', name]);
|
|
118
|
-
if (live.success && live.stdout.trim() === 'false') {
|
|
119
|
-
break;
|
|
120
|
-
}
|
|
121
|
-
if (!live.success && Date.now() - started > 30000) {
|
|
122
|
-
break;
|
|
123
|
-
}
|
|
124
|
-
const info = run(docker, ['info', '--format', '{{json .}}']);
|
|
125
|
-
const stats = run(docker, [
|
|
126
|
-
'stats',
|
|
127
|
-
'--no-stream',
|
|
128
|
-
'--format',
|
|
129
|
-
'{{json .}}',
|
|
130
|
-
]);
|
|
131
|
-
let daemonCpus = NaN,
|
|
132
|
-
cores = NaN;
|
|
147
|
+
while (!stopping) {
|
|
133
148
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
149
|
+
const current = store ? store.get(uuid) : record;
|
|
150
|
+
if (
|
|
151
|
+
!current ||
|
|
152
|
+
current.options.sessionName !== name ||
|
|
153
|
+
(current.attempt?.number || 1) !== Number(attemptNumber)
|
|
154
|
+
) {
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
const live = runner(docker, [
|
|
158
|
+
'inspect',
|
|
159
|
+
'-f',
|
|
160
|
+
'{{.State.Running}}',
|
|
161
|
+
name,
|
|
162
|
+
]);
|
|
163
|
+
if (live.success && live.stdout.trim() === 'false') {
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
if (
|
|
167
|
+
!live.success &&
|
|
168
|
+
/No such (object|container)/i.test(live.stderr || '')
|
|
169
|
+
) {
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
const observed = readDockerResourceLimits(name, runner);
|
|
173
|
+
if (observed !== null) {
|
|
174
|
+
baseFlags = observed;
|
|
175
|
+
state = observeBase(state, cpuCount(observed), Date.now());
|
|
136
176
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
177
|
+
const info = runner(docker, ['info', '--format', '{{json .}}']);
|
|
178
|
+
const stats = runner(docker, [
|
|
179
|
+
'stats',
|
|
180
|
+
'--no-stream',
|
|
181
|
+
'--format',
|
|
182
|
+
'{{json .}}',
|
|
183
|
+
]);
|
|
184
|
+
let daemonCpus = NaN,
|
|
185
|
+
cores = NaN;
|
|
186
|
+
try {
|
|
187
|
+
if (info.success) {
|
|
188
|
+
daemonCpus = JSON.parse(info.stdout).NCPU;
|
|
189
|
+
}
|
|
190
|
+
if (stats.success && live.success && observed !== null) {
|
|
191
|
+
const row = stats.stdout
|
|
192
|
+
.trim()
|
|
193
|
+
.split('\n')
|
|
194
|
+
.map((s) => JSON.parse(s))
|
|
195
|
+
.find((s) => s.Name === name);
|
|
196
|
+
if (row && /^\d+(?:\.\d+)?%$/.test(row.CPUPerc)) {
|
|
197
|
+
cores = Number(row.CPUPerc.slice(0, -1)) / 100;
|
|
198
|
+
}
|
|
145
199
|
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
debug(error);
|
|
146
202
|
}
|
|
147
|
-
|
|
148
|
-
|
|
203
|
+
debug(
|
|
204
|
+
`sample: phase=${state.phase} base=${state.baseCpus} limit=${state.limitCpus} cores=${cores} daemon=${daemonCpus}`
|
|
205
|
+
);
|
|
206
|
+
const decision = evaluate(
|
|
207
|
+
state,
|
|
208
|
+
{
|
|
209
|
+
now: Date.now(),
|
|
210
|
+
cores,
|
|
211
|
+
daemonCpus,
|
|
212
|
+
maxGapMs: Math.max(5000, interval * 3),
|
|
213
|
+
},
|
|
214
|
+
config
|
|
215
|
+
);
|
|
216
|
+
if (decision.action) {
|
|
217
|
+
const action = decision.action;
|
|
218
|
+
// Persist the base and intent before applying a cap. If this fails,
|
|
219
|
+
// leave Docker alone; a killed monitor can always recover its base.
|
|
220
|
+
if (action.kind === 'apply') {
|
|
221
|
+
publish(decision.state);
|
|
222
|
+
}
|
|
223
|
+
const update = runner(docker, [
|
|
224
|
+
'update',
|
|
225
|
+
...cpuUpdateArgs(baseFlags, action.cpus),
|
|
226
|
+
name,
|
|
227
|
+
]);
|
|
228
|
+
if (update.success) {
|
|
229
|
+
state = decision.state;
|
|
230
|
+
log(transitionLog(action, config));
|
|
231
|
+
} else {
|
|
232
|
+
debug(update.stderr || 'docker update failed');
|
|
233
|
+
state = {
|
|
234
|
+
...decision.state,
|
|
235
|
+
phase: state.phase,
|
|
236
|
+
since: state.since,
|
|
237
|
+
limitCpus: state.limitCpus,
|
|
238
|
+
penaltyCount: state.penaltyCount,
|
|
239
|
+
samples: [],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
state = decision.state;
|
|
244
|
+
}
|
|
245
|
+
publish();
|
|
246
|
+
} catch (error) {
|
|
247
|
+
state.samples = [];
|
|
248
|
+
debug(`tick failed: ${error.stack || error}`);
|
|
149
249
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
{
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
250
|
+
await new Promise((resolve) => {
|
|
251
|
+
const timer = globalThis.setTimeout(resolve, interval);
|
|
252
|
+
wake = () => {
|
|
253
|
+
globalThis.clearTimeout(timer);
|
|
254
|
+
resolve();
|
|
255
|
+
};
|
|
256
|
+
if (stopping) {
|
|
257
|
+
wake();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
try {
|
|
263
|
+
const current = store ? store.get(uuid) : record;
|
|
264
|
+
if (
|
|
265
|
+
state.phase === 'penalized' &&
|
|
266
|
+
current?.options.sessionName === name &&
|
|
267
|
+
(current.attempt?.number || 1) === Number(attemptNumber)
|
|
268
|
+
) {
|
|
269
|
+
const live = runner(docker, [
|
|
270
|
+
'inspect',
|
|
271
|
+
'-f',
|
|
272
|
+
'{{.State.Running}}',
|
|
165
273
|
name,
|
|
166
274
|
]);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
275
|
+
const observed = readDockerResourceLimits(name, runner);
|
|
276
|
+
const info = runner(docker, ['info', '--format', '{{json .}}']);
|
|
277
|
+
const capacity = info.success ? JSON.parse(info.stdout).NCPU : NaN;
|
|
278
|
+
if (
|
|
279
|
+
live.success &&
|
|
280
|
+
live.stdout.trim() === 'true' &&
|
|
281
|
+
observed !== null &&
|
|
282
|
+
cpuCount(observed) === state.limitCpus &&
|
|
283
|
+
capacity > 0
|
|
284
|
+
) {
|
|
285
|
+
const cpus = Math.min(state.baseCpus || capacity, capacity);
|
|
286
|
+
const update = runner(docker, [
|
|
287
|
+
'update',
|
|
288
|
+
...cpuUpdateArgs(observed, cpus),
|
|
289
|
+
name,
|
|
290
|
+
]);
|
|
291
|
+
if (update.success) {
|
|
292
|
+
state = {
|
|
293
|
+
...state,
|
|
294
|
+
phase: 'observing',
|
|
295
|
+
limitCpus: cpus,
|
|
296
|
+
since: Date.now(),
|
|
297
|
+
};
|
|
298
|
+
log(`[start] CPU penalty lifted: ${cpus} CPUs (monitor stopped)`);
|
|
299
|
+
publish();
|
|
300
|
+
}
|
|
181
301
|
}
|
|
182
|
-
} else {
|
|
183
|
-
state = decision.state;
|
|
184
302
|
}
|
|
185
|
-
|
|
186
|
-
|
|
303
|
+
} catch (error) {
|
|
304
|
+
debug(error);
|
|
187
305
|
}
|
|
188
|
-
|
|
306
|
+
process.off('SIGTERM', stop);
|
|
307
|
+
process.off('SIGINT', stop);
|
|
189
308
|
lock.release();
|
|
190
309
|
}
|
|
191
310
|
}
|
|
@@ -204,7 +323,9 @@ function startSnippet(uuid, name, attempt = 1, options = null) {
|
|
|
204
323
|
if (!uuid && !standalone) {
|
|
205
324
|
return '';
|
|
206
325
|
}
|
|
207
|
-
|
|
326
|
+
const command = `${shellQuote(process.execPath)} ${shellQuote(__filename)} ${shellQuote(uuid || '')} ${shellQuote(name)} ${Number(attempt)} ${shellQuote(standalone || '')}`;
|
|
327
|
+
// wait reaps a crashed child even in containers whose PID 1 never reaps.
|
|
328
|
+
return `( __start_cpu_child=; trap 'if [ -n "$__start_cpu_child" ]; then kill "$__start_cpu_child" 2>/dev/null; wait "$__start_cpu_child" 2>/dev/null; fi; exit 0' TERM INT HUP; while :; do ${command} >/dev/null 2>&1 & __start_cpu_child=$!; wait "$__start_cpu_child"; __start_cpu_code=$?; [ "$__start_cpu_code" -eq 0 ] && break; sleep 1 & __start_cpu_child=$!; wait "$__start_cpu_child"; done ) & __start_cpu_monitor=$!`;
|
|
208
329
|
}
|
|
209
330
|
function stopSnippet() {
|
|
210
331
|
return 'if [ -n "$__start_cpu_monitor" ]; then kill "$__start_cpu_monitor" 2>/dev/null; wait "$__start_cpu_monitor" 2>/dev/null; fi';
|
|
@@ -219,6 +340,7 @@ module.exports = {
|
|
|
219
340
|
};
|
|
220
341
|
if (require.main === module) {
|
|
221
342
|
monitor(...process.argv.slice(2)).catch((error) => {
|
|
343
|
+
process.exitCode = 1;
|
|
222
344
|
if (process.env.START_DEBUG === '1') {
|
|
223
345
|
console.error(`[DEBUG] CPU monitor: ${error.stack}`);
|
|
224
346
|
}
|
package/src/lib/cpu-penalty.js
CHANGED
|
@@ -70,10 +70,13 @@ function validate(options) {
|
|
|
70
70
|
options.cpuPenaltyConfig = { ...DEFAULTS, ...options.cpuPenaltyConfig };
|
|
71
71
|
}
|
|
72
72
|
function initialState(baseCpus = null, now = Date.now(), saved = null) {
|
|
73
|
+
baseCpus =
|
|
74
|
+
saved && Object.hasOwn(saved, 'baseCpus') ? saved.baseCpus : baseCpus;
|
|
73
75
|
return {
|
|
74
76
|
phase: saved?.phase === 'penalized' ? 'penalized' : 'observing',
|
|
75
77
|
since: saved?.phase === 'penalized' ? saved.since : now,
|
|
76
|
-
limitCpus:
|
|
78
|
+
limitCpus:
|
|
79
|
+
saved && Object.hasOwn(saved, 'limitCpus') ? saved.limitCpus : baseCpus,
|
|
77
80
|
baseCpus,
|
|
78
81
|
penaltyCount: saved?.penaltyCount || 0,
|
|
79
82
|
penalizedMs: saved?.penalizedMs || 0,
|
|
@@ -82,6 +85,30 @@ function initialState(baseCpus = null, now = Date.now(), saved = null) {
|
|
|
82
85
|
capacity: null,
|
|
83
86
|
};
|
|
84
87
|
}
|
|
88
|
+
// A quota different from our cap is a supervisor override, not our penalty.
|
|
89
|
+
function observeBase(state, baseCpus, now) {
|
|
90
|
+
if (baseCpus === state.limitCpus) {
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
...state,
|
|
95
|
+
phase: 'observing',
|
|
96
|
+
baseCpus,
|
|
97
|
+
limitCpus: baseCpus,
|
|
98
|
+
since: now,
|
|
99
|
+
samples: [],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function transitionLog(action, config) {
|
|
103
|
+
const applying = action.kind === 'apply';
|
|
104
|
+
const window = applying ? config.triggerWindowMs : config.releaseWindowMs;
|
|
105
|
+
const duration = window % 60000 === 0 ? `${window / 60000}m` : `${window}ms`;
|
|
106
|
+
const measured =
|
|
107
|
+
action.average === null || action.average === undefined
|
|
108
|
+
? 'capacity changed'
|
|
109
|
+
: `${action.average.toFixed(2)} of ${applying ? action.capacity : config.cpus} CPUs for ${duration}`;
|
|
110
|
+
return `[start] CPU penalty ${applying ? 'applied' : 'lifted'}: ${action.cpus} CPUs (${measured})`;
|
|
111
|
+
}
|
|
85
112
|
function average(samples, now, window) {
|
|
86
113
|
const start = now - window;
|
|
87
114
|
if (
|
|
@@ -170,6 +197,8 @@ module.exports = {
|
|
|
170
197
|
validate,
|
|
171
198
|
duration,
|
|
172
199
|
initialState,
|
|
200
|
+
observeBase,
|
|
201
|
+
transitionLog,
|
|
173
202
|
average,
|
|
174
203
|
evaluate,
|
|
175
204
|
};
|
|
@@ -7,13 +7,23 @@ const {
|
|
|
7
7
|
const { resolveResourceOptions } = require('./docker-resource-options');
|
|
8
8
|
|
|
9
9
|
// Temporary penalty quotas must never become a resumed execution's base quota.
|
|
10
|
-
function restoreBaseCpu(limits, options) {
|
|
10
|
+
function restoreBaseCpu(limits, options, saved = null, inspected = false) {
|
|
11
11
|
if (!options.cpuPenaltyConfig) {
|
|
12
12
|
return limits;
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
if (
|
|
15
|
+
saved &&
|
|
16
|
+
inspected &&
|
|
17
|
+
!(saved.phase === 'penalized' && cpuCount(limits) === saved.limitCpus)
|
|
18
|
+
) {
|
|
19
|
+
return limits;
|
|
20
|
+
}
|
|
21
|
+
let base = normalizeResourceLimits(
|
|
15
22
|
options.baseResourceLimits || options.resourceLimits
|
|
16
23
|
);
|
|
24
|
+
if (saved && Object.hasOwn(saved, 'baseCpus')) {
|
|
25
|
+
base = saved.baseCpus > 0 ? withCpuLimit(limits, saved.baseCpus) : [];
|
|
26
|
+
}
|
|
17
27
|
return [
|
|
18
28
|
...limits.filter((s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
|
|
19
29
|
...base.filter((s) => /^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
|
|
@@ -38,7 +48,9 @@ function prepareResumeResources(record, probe, overrides, runner, random) {
|
|
|
38
48
|
: null;
|
|
39
49
|
const base = restoreBaseCpu(
|
|
40
50
|
normalizeResourceLimits(live || options.resourceLimits),
|
|
41
|
-
options
|
|
51
|
+
options,
|
|
52
|
+
require('./cpu-penalty-monitor').readState(record),
|
|
53
|
+
live !== null
|
|
42
54
|
);
|
|
43
55
|
const oldMemory = base.find((s) => s.startsWith('--memory='))?.slice(9);
|
|
44
56
|
const result = resolveResourceOptions(
|
package/src/lib/store-lock.js
CHANGED
|
@@ -7,11 +7,13 @@ const LOCK_STALE_MS = 60000;
|
|
|
7
7
|
const MALFORMED_LOCK_GRACE_MS = 3000;
|
|
8
8
|
|
|
9
9
|
class LockManager {
|
|
10
|
-
constructor(lockFilePath, filesystem = fs) {
|
|
10
|
+
constructor(lockFilePath, filesystem = fs, monitorOwner = false) {
|
|
11
11
|
this.lockFilePath = lockFilePath;
|
|
12
12
|
this.fs = filesystem;
|
|
13
13
|
this.lockAcquired = false;
|
|
14
14
|
this.identity = null;
|
|
15
|
+
this.token = null;
|
|
16
|
+
this.monitorOwner = monitorOwner;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
acquire(timeout = LOCK_TIMEOUT_MS) {
|
|
@@ -22,7 +24,8 @@ class LockManager {
|
|
|
22
24
|
}
|
|
23
25
|
};
|
|
24
26
|
while (Date.now() - started < timeout) {
|
|
25
|
-
const
|
|
27
|
+
const token = crypto.randomUUID();
|
|
28
|
+
const temp = `${this.lockFilePath}.${process.pid}.${token}`;
|
|
26
29
|
try {
|
|
27
30
|
try {
|
|
28
31
|
const stat = this.fs.statSync(this.lockFilePath);
|
|
@@ -44,6 +47,8 @@ class LockManager {
|
|
|
44
47
|
pid: process.pid,
|
|
45
48
|
timestamp: Date.now(),
|
|
46
49
|
hostname: os.hostname(),
|
|
50
|
+
processStart: this.processInfo(process.pid)?.start,
|
|
51
|
+
token,
|
|
47
52
|
}),
|
|
48
53
|
{ flag: 'wx' }
|
|
49
54
|
);
|
|
@@ -56,6 +61,7 @@ class LockManager {
|
|
|
56
61
|
const identity = this.fs.statSync(temp);
|
|
57
62
|
this.fs.linkSync(temp, this.lockFilePath);
|
|
58
63
|
this.identity = identity;
|
|
64
|
+
this.token = token;
|
|
59
65
|
this.lockAcquired = true;
|
|
60
66
|
debug(`acquired after ${Date.now() - started}ms`);
|
|
61
67
|
return true;
|
|
@@ -84,7 +90,8 @@ class LockManager {
|
|
|
84
90
|
const current = this.fs.statSync(this.lockFilePath);
|
|
85
91
|
if (
|
|
86
92
|
current.dev === this.identity.dev &&
|
|
87
|
-
current.ino === this.identity.ino
|
|
93
|
+
current.ino === this.identity.ino &&
|
|
94
|
+
this.readLockFile()?.token === this.token
|
|
88
95
|
) {
|
|
89
96
|
this.fs.unlinkSync(this.lockFilePath);
|
|
90
97
|
}
|
|
@@ -92,6 +99,7 @@ class LockManager {
|
|
|
92
99
|
/* The lock may already be gone. */
|
|
93
100
|
}
|
|
94
101
|
this.lockAcquired = false;
|
|
102
|
+
this.token = null;
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
readLockFile() {
|
|
@@ -117,6 +125,32 @@ class LockManager {
|
|
|
117
125
|
if (data.hostname === os.hostname()) {
|
|
118
126
|
try {
|
|
119
127
|
process.kill(data.pid, 0);
|
|
128
|
+
const info = this.processInfo(data.pid);
|
|
129
|
+
if (
|
|
130
|
+
info &&
|
|
131
|
+
(['Z', 'X', 'x'].includes(info.state) ||
|
|
132
|
+
(data.processStart && data.processStart !== info.start))
|
|
133
|
+
) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (
|
|
137
|
+
this.monitorOwner &&
|
|
138
|
+
!data.processStart &&
|
|
139
|
+
process.platform === 'linux'
|
|
140
|
+
) {
|
|
141
|
+
try {
|
|
142
|
+
const command = this.fs.readFileSync(
|
|
143
|
+
`/proc/${data.pid}/cmdline`,
|
|
144
|
+
'utf8'
|
|
145
|
+
);
|
|
146
|
+
return (
|
|
147
|
+
!command.includes('cpu-penalty-monitor') &&
|
|
148
|
+
!command.includes('__start-cpu-monitor')
|
|
149
|
+
);
|
|
150
|
+
} catch {
|
|
151
|
+
/* Proc may be restricted; do not evict an unverified live owner. */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
120
154
|
return false;
|
|
121
155
|
} catch (error) {
|
|
122
156
|
return error.code === 'ESRCH';
|
|
@@ -125,6 +159,23 @@ class LockManager {
|
|
|
125
159
|
return Date.now() - data.timestamp > LOCK_STALE_MS;
|
|
126
160
|
}
|
|
127
161
|
|
|
162
|
+
processInfo(pid) {
|
|
163
|
+
if (process.platform !== 'linux') {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const stat = this.fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
168
|
+
// comm is parenthesized and can contain spaces and closing parentheses.
|
|
169
|
+
const fields = stat
|
|
170
|
+
.slice(stat.lastIndexOf(')') + 2)
|
|
171
|
+
.trim()
|
|
172
|
+
.split(/\s+/);
|
|
173
|
+
return { state: fields[0], start: fields[19] };
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
128
179
|
sleep(ms) {
|
|
129
180
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
130
181
|
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
const { it, expect } = require('bun:test');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const { initialState, transitionLog } = require('../src/lib/cpu-penalty');
|
|
7
|
+
const cpu = require('../src/lib/cpu-penalty-monitor');
|
|
8
|
+
|
|
9
|
+
it('restarted monitors retain an observed base absent from launch flags', () => {
|
|
10
|
+
const saved = { ...initialState(4, 0), phase: 'penalized', limitCpus: 2 };
|
|
11
|
+
expect(initialState(null, 100, saved).baseCpus).toBe(4);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('saved observing limits preserve the base after a daemon-clamped lift', () => {
|
|
15
|
+
const saved = { ...initialState(4, 0), limitCpus: 1 };
|
|
16
|
+
const { observeBase } = require('../src/lib/cpu-penalty');
|
|
17
|
+
expect(observeBase(initialState(null, 100, saved), 1, 100).baseCpus).toBe(4);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
for (const mode of [
|
|
21
|
+
'nano',
|
|
22
|
+
'quota',
|
|
23
|
+
'restart',
|
|
24
|
+
'zombie',
|
|
25
|
+
'reused',
|
|
26
|
+
'supervise',
|
|
27
|
+
'publish-fault',
|
|
28
|
+
'graceful',
|
|
29
|
+
'override',
|
|
30
|
+
]) {
|
|
31
|
+
it(`CPU monitor recovers external base and idle release: ${mode}`, () => {
|
|
32
|
+
if (
|
|
33
|
+
process.platform === 'win32' ||
|
|
34
|
+
(['zombie', 'reused'].includes(mode) && process.platform !== 'linux')
|
|
35
|
+
) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cpu-monitor-207-'));
|
|
39
|
+
try {
|
|
40
|
+
const logPath = path.join(dir, 'task.log');
|
|
41
|
+
fs.writeFileSync(logPath, '');
|
|
42
|
+
const record = {
|
|
43
|
+
logPath,
|
|
44
|
+
options: {
|
|
45
|
+
sessionName: 'cpu-task',
|
|
46
|
+
isolated: 'docker',
|
|
47
|
+
cpuPenaltyConfig: {
|
|
48
|
+
cpus: 2,
|
|
49
|
+
trigger: 95,
|
|
50
|
+
release: 65,
|
|
51
|
+
triggerWindowMs: 400,
|
|
52
|
+
releaseWindowMs: 400,
|
|
53
|
+
},
|
|
54
|
+
baseResourceLimits: [],
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
const command = [
|
|
58
|
+
process.execPath,
|
|
59
|
+
require.resolve('../src/lib/cpu-penalty-monitor'),
|
|
60
|
+
'',
|
|
61
|
+
'cpu-task',
|
|
62
|
+
'1',
|
|
63
|
+
JSON.stringify(record),
|
|
64
|
+
];
|
|
65
|
+
if (mode === 'supervise') {
|
|
66
|
+
const script = `${cpu.startSnippet(null, 'cpu-task', 1, { ...record.options, logPath })}; read done; ${cpu.stopSnippet()}`;
|
|
67
|
+
command.splice(0, command.length, 'sh', '-c', script);
|
|
68
|
+
}
|
|
69
|
+
const result = spawnSync(
|
|
70
|
+
'python3',
|
|
71
|
+
[
|
|
72
|
+
path.resolve(__dirname, '../../experiments/issue-207-monitor.py'),
|
|
73
|
+
mode,
|
|
74
|
+
logPath,
|
|
75
|
+
...command,
|
|
76
|
+
],
|
|
77
|
+
{ encoding: 'utf8' }
|
|
78
|
+
);
|
|
79
|
+
expect(result.status, result.stderr).toBe(0);
|
|
80
|
+
const state = cpu.readState(record);
|
|
81
|
+
const expected = mode === 'override' ? 3 : 4;
|
|
82
|
+
expect(state.baseCpus).toBe(expected);
|
|
83
|
+
expect(state.phase).toBe('observing');
|
|
84
|
+
expect(state.limitCpus).toBe(expected);
|
|
85
|
+
if (mode !== 'override') {
|
|
86
|
+
expect(fs.readFileSync(logPath, 'utf8')).toContain(
|
|
87
|
+
'CPU penalty lifted: 4'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const { ExecutionRecord } = require('../src/lib/execution-store');
|
|
91
|
+
const {
|
|
92
|
+
enrichDetachedStatus,
|
|
93
|
+
formatRecord,
|
|
94
|
+
} = require('../src/lib/status-formatter');
|
|
95
|
+
const statusRecord = new ExecutionRecord({
|
|
96
|
+
...record,
|
|
97
|
+
options: { ...record.options, isolated: undefined },
|
|
98
|
+
});
|
|
99
|
+
const status = JSON.parse(
|
|
100
|
+
formatRecord(enrichDetachedStatus(statusRecord), 'json')
|
|
101
|
+
);
|
|
102
|
+
expect(status.cpuPenalty.baseCpus).toBe(expected);
|
|
103
|
+
expect(status.cpuPenalty.limitCpus).toBe(expected);
|
|
104
|
+
} finally {
|
|
105
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
}, 15000);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const failure of ['inspect', 'stats', 'update', 'publish']) {
|
|
111
|
+
it(`an exception in ${failure} resets coverage and monitoring continues`, async () => {
|
|
112
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cpu-tick-207-'));
|
|
113
|
+
const logPath = path.join(dir, 'task.log');
|
|
114
|
+
const record = {
|
|
115
|
+
logPath,
|
|
116
|
+
options: {
|
|
117
|
+
sessionName: 'cpu-task',
|
|
118
|
+
cpuPenaltyConfig: {
|
|
119
|
+
cpus: 2,
|
|
120
|
+
trigger: 95,
|
|
121
|
+
release: 65,
|
|
122
|
+
triggerWindowMs: 100,
|
|
123
|
+
releaseWindowMs: 100,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
let ticks = 0;
|
|
128
|
+
let injected = false,
|
|
129
|
+
limit = 4,
|
|
130
|
+
lifted = false;
|
|
131
|
+
const filesystem = Object.create(fs);
|
|
132
|
+
filesystem.renameSync = (from, to) => {
|
|
133
|
+
if (
|
|
134
|
+
failure === 'publish' &&
|
|
135
|
+
!injected &&
|
|
136
|
+
JSON.parse(fs.readFileSync(from)).phase === 'penalized'
|
|
137
|
+
) {
|
|
138
|
+
injected = true;
|
|
139
|
+
throw new Error('injected ENOSPC');
|
|
140
|
+
}
|
|
141
|
+
fs.renameSync(from, to);
|
|
142
|
+
};
|
|
143
|
+
try {
|
|
144
|
+
await cpu.monitor('', 'cpu-task', 1, JSON.stringify(record), {
|
|
145
|
+
fs: filesystem,
|
|
146
|
+
run: (_command, args) => {
|
|
147
|
+
if (args[0] === failure && !injected) {
|
|
148
|
+
injected = true;
|
|
149
|
+
throw new Error(`injected ${failure}`);
|
|
150
|
+
}
|
|
151
|
+
const result = (stdout) => ({ success: true, stdout });
|
|
152
|
+
if (args[0] === 'inspect') {
|
|
153
|
+
if (!args[2].includes('HostConfig') && ++ticks > 60) {
|
|
154
|
+
lifted = false;
|
|
155
|
+
return result('false');
|
|
156
|
+
}
|
|
157
|
+
return result(
|
|
158
|
+
args[2].includes('HostConfig')
|
|
159
|
+
? JSON.stringify({ NanoCpus: limit * 1e9 })
|
|
160
|
+
: String(!lifted)
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (args[0] === 'info') {
|
|
164
|
+
return result('{"NCPU":6}');
|
|
165
|
+
}
|
|
166
|
+
if (args[0] === 'stats') {
|
|
167
|
+
return result(
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
Name: 'cpu-task',
|
|
170
|
+
CPUPerc: limit === 2 ? '20%' : '400%',
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (args[0] === 'update') {
|
|
175
|
+
limit = Number(args[2]);
|
|
176
|
+
lifted = limit === 4;
|
|
177
|
+
return result('');
|
|
178
|
+
}
|
|
179
|
+
throw new Error('unexpected command');
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
expect(injected).toBe(true);
|
|
183
|
+
expect(lifted).toBe(true);
|
|
184
|
+
expect(cpu.readState(record).baseCpus).toBe(4);
|
|
185
|
+
} finally {
|
|
186
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
}, 5000);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
it('stopped resume uses the observed base rather than the temporary quota', () => {
|
|
192
|
+
const { restoreBaseCpu } = require('../src/lib/resume-resources');
|
|
193
|
+
expect(
|
|
194
|
+
restoreBaseCpu(
|
|
195
|
+
['--memory=512m', '--cpus=2'],
|
|
196
|
+
{ cpuPenaltyConfig: {}, baseResourceLimits: [] },
|
|
197
|
+
{ baseCpus: 4 }
|
|
198
|
+
)
|
|
199
|
+
).toEqual(['--memory=512m', '--cpus=4']);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('stopped resume preserves a later supervisor override instead of the stale cap', () => {
|
|
203
|
+
const { restoreBaseCpu } = require('../src/lib/resume-resources');
|
|
204
|
+
const options = { cpuPenaltyConfig: {}, baseResourceLimits: [] };
|
|
205
|
+
const saved = { phase: 'penalized', baseCpus: 4, limitCpus: 2 };
|
|
206
|
+
expect(restoreBaseCpu(['--cpus=2'], options, saved, true)).toEqual([
|
|
207
|
+
'--cpus=4',
|
|
208
|
+
]);
|
|
209
|
+
expect(restoreBaseCpu(['--cpus=3'], options, saved, true)).toEqual([
|
|
210
|
+
'--cpus=3',
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('transition logs explain the window and phase denominator', () => {
|
|
215
|
+
const config = { cpus: 2, triggerWindowMs: 900000, releaseWindowMs: 400 };
|
|
216
|
+
expect(
|
|
217
|
+
transitionLog({ kind: 'apply', cpus: 2, capacity: 4, average: 100 }, config)
|
|
218
|
+
).toContain('of 4 CPUs for 15m');
|
|
219
|
+
expect(
|
|
220
|
+
transitionLog({ kind: 'lift', cpus: 4, capacity: 4, average: 10 }, config)
|
|
221
|
+
).toContain('of 2 CPUs for 400ms');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('process birth identity reclaims reused PIDs without evicting a live lock', () => {
|
|
225
|
+
if (process.platform !== 'linux') {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const { LockManager } = require('../src/lib/store-lock');
|
|
229
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cpu-lock-207-'));
|
|
230
|
+
const file = path.join(dir, 'lock');
|
|
231
|
+
const first = new LockManager(file);
|
|
232
|
+
const replacement = new LockManager(file);
|
|
233
|
+
try {
|
|
234
|
+
expect(first.acquire(100)).toBe(true);
|
|
235
|
+
const data = JSON.parse(fs.readFileSync(file));
|
|
236
|
+
expect(data.processStart).toBeTruthy();
|
|
237
|
+
expect(replacement.acquire(100)).toBe(false);
|
|
238
|
+
fs.writeFileSync(
|
|
239
|
+
file,
|
|
240
|
+
JSON.stringify({ ...data, processStart: 'different-birth' })
|
|
241
|
+
);
|
|
242
|
+
expect(replacement.acquire(100)).toBe(true);
|
|
243
|
+
first.release();
|
|
244
|
+
expect(fs.existsSync(file)).toBe(true);
|
|
245
|
+
} finally {
|
|
246
|
+
replacement.release();
|
|
247
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
package/test/shared-golden.js
CHANGED
|
@@ -51,4 +51,27 @@ describe('shared JavaScript and Rust golden contracts', () => {
|
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
|
+
for (const fixture of fixtures['cpu-penalty-state']) {
|
|
55
|
+
it(`CPU state: ${fixture.name}`, () => {
|
|
56
|
+
const {
|
|
57
|
+
initialState,
|
|
58
|
+
observeBase,
|
|
59
|
+
evaluate,
|
|
60
|
+
} = require('../src/lib/cpu-penalty');
|
|
61
|
+
let state = initialState(fixture.baseCpus, 0, fixture.saved);
|
|
62
|
+
for (const step of fixture.steps) {
|
|
63
|
+
state = observeBase(state, step.baseCpus, step.now);
|
|
64
|
+
const decision = evaluate(
|
|
65
|
+
state,
|
|
66
|
+
{ now: step.now, cores: step.cores, daemonCpus: 6, maxGapMs: 50 },
|
|
67
|
+
fixture.config
|
|
68
|
+
);
|
|
69
|
+
assert.equal(decision.action?.kind || null, step.action);
|
|
70
|
+
state = decision.state;
|
|
71
|
+
}
|
|
72
|
+
for (const [key, value] of Object.entries(fixture.expected)) {
|
|
73
|
+
assert.deepEqual(state[key], value, key);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
54
77
|
});
|