start-command 0.37.0 → 0.37.2

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.
@@ -240,11 +240,16 @@ const FATAL_MARKER_TAIL_BYTES = 64 * 1024;
240
240
  * @param {number} [bytes] - Maximum number of trailing bytes to read
241
241
  * @returns {string|null} Tail content, or null when the file cannot be read
242
242
  */
243
- function readLogTail(logPath, bytes = LOG_TAIL_BYTES, offset = 0) {
243
+ function readLogTail(
244
+ logPath,
245
+ bytes = LOG_TAIL_BYTES,
246
+ offset = 0,
247
+ end = Infinity
248
+ ) {
244
249
  let fd;
245
250
  try {
246
251
  fd = fs.openSync(logPath, 'r');
247
- const size = fs.fstatSync(fd).size;
252
+ const size = Math.min(fs.fstatSync(fd).size, end);
248
253
  const length = Math.min(Math.max(0, size - offset), bytes);
249
254
  const buffer = Buffer.alloc(length);
250
255
  fs.readSync(fd, buffer, 0, length, size - length);
@@ -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(
@@ -500,7 +500,8 @@ function attachMemoryExhaustion(record, logTail) {
500
500
  if (!memory) {
501
501
  if (
502
502
  record.memoryExhaustedReason?.startsWith('Docker reported') ||
503
- record.memoryExhaustedReason?.startsWith('cgroup memory.events')
503
+ record.memoryExhaustedReason?.startsWith('cgroup memory.events') ||
504
+ record.memoryExhaustedReason?.startsWith('Exit-time evidence')
504
505
  ) {
505
506
  const cleared = Object.assign(
506
507
  Object.create(Object.getPrototypeOf(record)),
@@ -558,6 +559,14 @@ function enrichDetachedStatus(record) {
558
559
  Object.create(Object.getPrototypeOf(enriched)),
559
560
  enriched
560
561
  );
562
+ if (record.attemptHistory) {
563
+ copy.attemptHistory = require('./execution-attempt').enrichAttemptHistory(
564
+ record,
565
+ (attempt, tail) =>
566
+ attachMemoryExhaustion(attachExitReason(attempt, tail), tail),
567
+ FATAL_MARKER_TAIL_BYTES
568
+ );
569
+ }
561
570
  return readAttemptActivity(copy);
562
571
  }
563
572
 
@@ -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
  }
@@ -97,15 +97,32 @@ it('manual snapshot override applies limits to the new container at creation', a
97
97
  fs.rmSync(f.dir, { recursive: true, force: true });
98
98
  }
99
99
  });
100
- for (const [exitCode, oomKilled, sample, change] of [
100
+ for (const [exitCode, oomKilled, sample, change, marker] of [
101
101
  [137, true, '67108864 66000000 1 1 100 100', true],
102
102
  [137, true, '67108864 66000000 1 1 0 100', false],
103
103
  [137, false, '67108864 66000000 0 0 0 100', false],
104
104
  [1, true, '67108864 66000000 1 1 100 100', false],
105
+ [
106
+ 137,
107
+ true,
108
+ '67108864 66000000 1 1 62 62',
109
+ true,
110
+ 'Exit evidence: main-oom (recent Docker oom event)',
111
+ ],
112
+ [
113
+ 137,
114
+ true,
115
+ '67108864 66000000 1 1 62 62',
116
+ false,
117
+ 'Exit evidence: docker-daemon-restart (Docker service journal)',
118
+ ],
105
119
  ]) {
106
120
  it(`automatic memory change requires a qualifying OOM (exit=${exitCode}, fresh=${change})`, () => {
107
121
  const f = fixture();
108
122
  try {
123
+ if (marker) {
124
+ fs.writeFileSync(f.record.logPath, `${marker}\n`);
125
+ }
109
126
  const result = recoverKilledExecution({
110
127
  store: f.store,
111
128
  executionId: f.record.uuid,
@@ -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
+ });