start-command 0.37.6 → 0.37.8
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/package.json +1 -1
- package/src/bin/cli.js +11 -0
- package/src/lib/cpu-penalty-monitor.js +4 -2
- package/src/lib/cpu-penalty.js +14 -6
- package/test/regression-218.js +101 -0
- package/test/regression-222.js +61 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.37.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9ffcae7: A closed stdout/stderr pipe (`$ -- cmd | head -1`) no longer crashes start with an unhandled `EPIPE` and exit code 1: the command keeps running, its log and record are written, and start exits with the command's own exit code (#222).
|
|
8
|
+
|
|
9
|
+
## 0.37.7
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 6499f91: `--cpu-penalty`: `penalizedMs` keeps the time since the last monitor tick when a graceful stop or a supervisor override ends a penalty, so it never decreases (#218).
|
|
14
|
+
|
|
3
15
|
## 0.37.6
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/bin/cli.js
CHANGED
|
@@ -42,6 +42,17 @@ const { resolveChildExitCode } = require('../lib/exit-reason');
|
|
|
42
42
|
const { printUsage } = require('../lib/usage');
|
|
43
43
|
const { buildDisplayCommand, getCommandName } = require('../lib/shell-utils');
|
|
44
44
|
|
|
45
|
+
// A closed stdout/stderr pipe (`$ --version | head -1`) must neither crash
|
|
46
|
+
// start nor replace the command's exit code: drop the failed output and keep
|
|
47
|
+
// running, logging and recording as usual (issue #222).
|
|
48
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
49
|
+
stream.on('error', (err) => {
|
|
50
|
+
if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
// Configuration from environment variables
|
|
46
57
|
const config = {
|
|
47
58
|
// Disable automatic issue creation (useful for testing)
|
|
@@ -4,6 +4,7 @@ const path = require('path');
|
|
|
4
4
|
const { spawnSync } = require('child_process');
|
|
5
5
|
const {
|
|
6
6
|
initialState,
|
|
7
|
+
accrue,
|
|
7
8
|
evaluate,
|
|
8
9
|
observeBase,
|
|
9
10
|
transitionLog,
|
|
@@ -289,11 +290,12 @@ async function monitor(
|
|
|
289
290
|
name,
|
|
290
291
|
]);
|
|
291
292
|
if (update.success) {
|
|
293
|
+
const now = Date.now();
|
|
292
294
|
state = {
|
|
293
|
-
...state,
|
|
295
|
+
...accrue(state, now),
|
|
294
296
|
phase: 'observing',
|
|
295
297
|
limitCpus: cpus,
|
|
296
|
-
since:
|
|
298
|
+
since: now,
|
|
297
299
|
};
|
|
298
300
|
log(`[start] CPU penalty lifted: ${cpus} CPUs (monitor stopped)`);
|
|
299
301
|
publish();
|
package/src/lib/cpu-penalty.js
CHANGED
|
@@ -85,13 +85,24 @@ function initialState(baseCpus = null, now = Date.now(), saved = null) {
|
|
|
85
85
|
capacity: null,
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
+
// Penalized time counts until the phase leaves `penalized`; `--status` only
|
|
89
|
+
// extrapolates while penalized, so every transition settles it first.
|
|
90
|
+
function accrue(state, now) {
|
|
91
|
+
return {
|
|
92
|
+
...state,
|
|
93
|
+
penalizedMs:
|
|
94
|
+
state.penalizedMs +
|
|
95
|
+
(state.phase === 'penalized' ? Math.max(0, now - state.lastAt) : 0),
|
|
96
|
+
lastAt: now,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
88
99
|
// A quota different from our cap is a supervisor override, not our penalty.
|
|
89
100
|
function observeBase(state, baseCpus, now) {
|
|
90
101
|
if (baseCpus === state.limitCpus) {
|
|
91
102
|
return state;
|
|
92
103
|
}
|
|
93
104
|
return {
|
|
94
|
-
...state,
|
|
105
|
+
...accrue(state, now),
|
|
95
106
|
phase: 'observing',
|
|
96
107
|
baseCpus,
|
|
97
108
|
limitCpus: baseCpus,
|
|
@@ -133,11 +144,7 @@ function evaluate(
|
|
|
133
144
|
{ now, cores, daemonCpus, maxGapMs },
|
|
134
145
|
config = DEFAULTS
|
|
135
146
|
) {
|
|
136
|
-
const s = { ...state, samples: [...state.samples] };
|
|
137
|
-
if (s.phase === 'penalized') {
|
|
138
|
-
s.penalizedMs += Math.max(0, now - s.lastAt);
|
|
139
|
-
}
|
|
140
|
-
s.lastAt = now;
|
|
147
|
+
const s = { ...accrue(state, now), samples: [...state.samples] };
|
|
141
148
|
const capacity = Number.isFinite(daemonCpus)
|
|
142
149
|
? Math.min(s.baseCpus || Infinity, daemonCpus)
|
|
143
150
|
: NaN;
|
|
@@ -197,6 +204,7 @@ module.exports = {
|
|
|
197
204
|
validate,
|
|
198
205
|
duration,
|
|
199
206
|
initialState,
|
|
207
|
+
accrue,
|
|
200
208
|
observeBase,
|
|
201
209
|
transitionLog,
|
|
202
210
|
average,
|
|
@@ -0,0 +1,101 @@
|
|
|
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 {
|
|
7
|
+
initialState,
|
|
8
|
+
accrue,
|
|
9
|
+
observeBase,
|
|
10
|
+
evaluate,
|
|
11
|
+
} = require('../src/lib/cpu-penalty');
|
|
12
|
+
const cpu = require('../src/lib/cpu-penalty-monitor');
|
|
13
|
+
|
|
14
|
+
const penalized = (lastAt) => ({
|
|
15
|
+
...initialState(4, 0),
|
|
16
|
+
phase: 'penalized',
|
|
17
|
+
limitCpus: 2,
|
|
18
|
+
penaltyCount: 1,
|
|
19
|
+
penalizedMs: 1000,
|
|
20
|
+
lastAt,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('accrue settles penalized time since the last tick', () => {
|
|
24
|
+
expect(accrue(penalized(1000), 4000)).toMatchObject({
|
|
25
|
+
penalizedMs: 4000,
|
|
26
|
+
lastAt: 4000,
|
|
27
|
+
});
|
|
28
|
+
expect(accrue(penalized(5000), 4000).penalizedMs).toBe(1000);
|
|
29
|
+
const observing = { ...penalized(1000), phase: 'observing' };
|
|
30
|
+
expect(accrue(observing, 4000).penalizedMs).toBe(1000);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('a supervisor override keeps the penalized time before it', () => {
|
|
34
|
+
const state = observeBase(penalized(1000), 3, 4000);
|
|
35
|
+
expect(state.phase).toBe('observing');
|
|
36
|
+
expect(state.penalizedMs).toBe(4000);
|
|
37
|
+
const next = evaluate(state, {
|
|
38
|
+
now: 5000,
|
|
39
|
+
cores: 0,
|
|
40
|
+
daemonCpus: 6,
|
|
41
|
+
maxGapMs: 5000,
|
|
42
|
+
}).state;
|
|
43
|
+
expect(next.penalizedMs).toBe(4000);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
for (const mode of ['graceful', 'supervise']) {
|
|
47
|
+
it(`graceful stop lifts at once and keeps penalizedMs: ${mode}`, () => {
|
|
48
|
+
if (process.platform === 'win32') {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cpu-monitor-218-'));
|
|
52
|
+
try {
|
|
53
|
+
const logPath = path.join(dir, 'task.log');
|
|
54
|
+
fs.writeFileSync(logPath, '');
|
|
55
|
+
const record = {
|
|
56
|
+
logPath,
|
|
57
|
+
options: {
|
|
58
|
+
sessionName: 'cpu-task',
|
|
59
|
+
isolated: 'docker',
|
|
60
|
+
// Default 15-minute windows: the monitor ticks every 30 s.
|
|
61
|
+
cpuPenaltyConfig: {
|
|
62
|
+
cpus: 2,
|
|
63
|
+
trigger: 95,
|
|
64
|
+
release: 65,
|
|
65
|
+
triggerWindowMs: 900000,
|
|
66
|
+
releaseWindowMs: 900000,
|
|
67
|
+
},
|
|
68
|
+
baseResourceLimits: [],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
let command = [
|
|
72
|
+
process.execPath,
|
|
73
|
+
require.resolve('../src/lib/cpu-penalty-monitor'),
|
|
74
|
+
'',
|
|
75
|
+
'cpu-task',
|
|
76
|
+
'1',
|
|
77
|
+
JSON.stringify(record),
|
|
78
|
+
];
|
|
79
|
+
if (mode === 'supervise') {
|
|
80
|
+
const script = `${cpu.startSnippet(null, 'cpu-task', 1, { ...record.options, logPath })}; read done; ${cpu.stopSnippet()}`;
|
|
81
|
+
command = ['sh', '-c', script];
|
|
82
|
+
}
|
|
83
|
+
const result = spawnSync(
|
|
84
|
+
'python3',
|
|
85
|
+
[
|
|
86
|
+
path.resolve(__dirname, '../../experiments/issue-218-monitor.py'),
|
|
87
|
+
mode,
|
|
88
|
+
logPath,
|
|
89
|
+
...command,
|
|
90
|
+
],
|
|
91
|
+
{ encoding: 'utf8' }
|
|
92
|
+
);
|
|
93
|
+
expect(result.status, result.stderr).toBe(0);
|
|
94
|
+
expect(fs.readFileSync(logPath, 'utf8')).toContain(
|
|
95
|
+
'CPU penalty lifted: 4 CPUs (monitor stopped)'
|
|
96
|
+
);
|
|
97
|
+
} finally {
|
|
98
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
}, 20000);
|
|
101
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const { describe, it, expect } = require('bun:test');
|
|
2
|
+
const { spawn } = require('child_process');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const CLI = path.join(__dirname, '../src/bin/cli.js');
|
|
8
|
+
|
|
9
|
+
// Run the CLI with the read ends of the given output pipes closed before it
|
|
10
|
+
// starts writing, like `$ --version | head -1` once `head` has exited.
|
|
11
|
+
function runWithClosedPipes(args, closed) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const child = spawn(process.execPath, [CLI, ...args], {
|
|
14
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
15
|
+
env: {
|
|
16
|
+
...process.env,
|
|
17
|
+
START_APP_FOLDER: fs.mkdtempSync(path.join(os.tmpdir(), 'start-222-')),
|
|
18
|
+
START_DISABLE_AUTO_ISSUE: '1',
|
|
19
|
+
START_DISABLE_LOG_UPLOAD: '1',
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
let stderr = '';
|
|
23
|
+
for (const name of ['stdout', 'stderr']) {
|
|
24
|
+
if (closed.includes(name)) {
|
|
25
|
+
child[name].destroy();
|
|
26
|
+
} else {
|
|
27
|
+
child[name].on('data', (chunk) => (stderr += chunk));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
child.on('close', (code) => resolve({ code, stderr }));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('closed output pipes (#222)', () => {
|
|
35
|
+
if (process.platform === 'win32') {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const big = 'seq 1 200000';
|
|
39
|
+
for (const args of [['--version'], ['-v'], ['--help']]) {
|
|
40
|
+
it(`${args[0]} exits 0 when stdout is closed`, async () => {
|
|
41
|
+
const result = await runWithClosedPipes(args, ['stdout']);
|
|
42
|
+
expect(result.stderr).not.toContain('EPIPE');
|
|
43
|
+
expect(result.code).toBe(0);
|
|
44
|
+
}, 15000);
|
|
45
|
+
}
|
|
46
|
+
it('a command keeps its exit code when stdout is closed', async () => {
|
|
47
|
+
const result = await runWithClosedPipes(
|
|
48
|
+
['--', 'sh', '-c', `${big}; exit 3`],
|
|
49
|
+
['stdout']
|
|
50
|
+
);
|
|
51
|
+
expect(result.stderr).not.toContain('EPIPE');
|
|
52
|
+
expect(result.code).toBe(3);
|
|
53
|
+
}, 15000);
|
|
54
|
+
it('a command keeps its exit code when stdout and stderr are closed', async () => {
|
|
55
|
+
const result = await runWithClosedPipes(
|
|
56
|
+
['--', 'sh', '-c', `${big}; ${big} >&2; exit 3`],
|
|
57
|
+
['stdout', 'stderr']
|
|
58
|
+
);
|
|
59
|
+
expect(result.code).toBe(3);
|
|
60
|
+
}, 15000);
|
|
61
|
+
});
|