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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.37.0",
3
+ "version": "0.37.1",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -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 { initialState, evaluate } = require('./cpu-penalty');
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 { cpuCount, cpuUpdateArgs } = require('./docker-resource-limits');
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 { success: result.status === 0, stdout: result.stdout || '' };
53
+ return {
54
+ success: result.status === 0,
55
+ stdout: result.stdout || '',
56
+ stderr: result.stderr || '',
57
+ };
44
58
  }
45
- async function monitor(uuid, name, attemptNumber, standalone) {
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
- const lock = new LockManager(`${statePath(record)}.lock`);
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
- const baseFlags =
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
- fs.writeFileSync(
106
+ filesystem.writeFileSync(
81
107
  tmp,
82
108
  JSON.stringify({
83
- ...publicState(state),
109
+ ...publicState(value),
84
110
  containerName: name,
85
111
  attemptNumber: Number(attemptNumber),
86
112
  updatedAt: Date.now(),
87
113
  })
88
114
  );
89
- fs.renameSync(tmp, file);
115
+ filesystem.renameSync(tmp, file);
90
116
  } finally {
91
117
  try {
92
- fs.unlinkSync(tmp);
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
- fs.appendFileSync(record.logPath, `${text}\n`);
126
+ filesystem.appendFileSync(record.logPath, `${text}\n`);
101
127
  } catch {
102
128
  /* Optional diagnostics are best effort. */
103
129
  }
104
130
  };
105
- const started = Date.now();
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
- publish();
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
- if (info.success) {
135
- daemonCpus = JSON.parse(info.stdout).NCPU;
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
- if (stats.success) {
138
- const row = stats.stdout
139
- .trim()
140
- .split('\n')
141
- .map((s) => JSON.parse(s))
142
- .find((s) => s.Name === name);
143
- if (row && /^\d+(?:\.\d+)?%$/.test(row.CPUPerc)) {
144
- cores = Number(row.CPUPerc.slice(0, -1)) / 100;
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
- } catch {
148
- /* Optional diagnostics are best effort. */
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
- const decision = evaluate(
151
- state,
152
- {
153
- now: Date.now(),
154
- cores,
155
- daemonCpus,
156
- maxGapMs: Math.max(5000, interval * 3),
157
- },
158
- config
159
- );
160
- if (decision.action) {
161
- const action = decision.action;
162
- const update = run(docker, [
163
- 'update',
164
- ...cpuUpdateArgs(baseFlags, action.cpus),
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
- if (update.success) {
168
- state = decision.state;
169
- log(
170
- `[start] CPU penalty ${action.kind === 'apply' ? 'applied' : 'lifted'}: ${action.cpus} CPUs (${action.average === null || action.average === undefined ? 'capacity changed' : `${action.average.toFixed(2)} of ${action.capacity} CPUs`})`
171
- );
172
- } else {
173
- state = {
174
- ...decision.state,
175
- phase: state.phase,
176
- since: state.since,
177
- limitCpus: state.limitCpus,
178
- penaltyCount: state.penaltyCount,
179
- samples: [],
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
- publish();
186
- await new Promise((resolve) => globalThis.setTimeout(resolve, interval));
303
+ } catch (error) {
304
+ debug(error);
187
305
  }
188
- } finally {
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
- return `${shellQuote(process.execPath)} ${shellQuote(__filename)} ${shellQuote(uuid || '')} ${shellQuote(name)} ${Number(attempt)} ${shellQuote(standalone || '')} >/dev/null 2>&1 & __start_cpu_monitor=$!`;
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
  }
@@ -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: saved?.phase === 'penalized' ? saved.limitCpus : baseCpus,
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
- const base = normalizeResourceLimits(
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(
@@ -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 temp = `${this.lockFilePath}.${process.pid}.${crypto.randomUUID()}`;
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
+ });
@@ -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
  });