start-command 0.38.0 → 0.39.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 +20 -0
- package/README.md +4 -2
- package/bunfig.toml +4 -0
- package/eslint.config.mjs +1 -0
- package/package.json +4 -4
- package/src/lib/args-parser.js +9 -0
- package/src/lib/cgroup-memory.js +95 -23
- package/src/lib/command-builder.js +4 -0
- package/src/lib/container-removal.js +4 -5
- package/src/lib/detached-finalize.js +1 -0
- package/src/lib/docker-init-option.js +65 -0
- package/src/lib/docker-inspect-batch.js +176 -0
- package/src/lib/docker-resource-options.js +21 -2
- package/src/lib/docker-runtime-args.js +12 -3
- package/src/lib/execution-control.js +8 -6
- package/src/lib/execution-recovery.js +4 -1
- package/src/lib/execution-resume.js +4 -1
- package/src/lib/execution-store.js +19 -1
- package/src/lib/exit-evidence.js +139 -7
- package/src/lib/output-blocks.js +11 -0
- package/src/lib/resume-resources.js +2 -2
- package/src/lib/status-formatter.js +34 -130
- package/src/lib/status-probe.js +149 -0
- package/src/lib/store-decode-cache.js +141 -0
- package/src/lib/usage.js +3 -0
- package/test/cargo-lock-version.mjs +121 -0
- package/test/check-rust-coverage.mjs +71 -0
- package/test/check-web-archive.mjs +154 -0
- package/test/ci-workflow-invariants.js +204 -0
- package/test/command-builder-docker-cleanup.js +24 -0
- package/test/command-exit-codes.mjs +91 -0
- package/test/docker-runtime-options.js +2 -0
- package/test/docker-snapshot-safety.js +39 -0
- package/test/execution-resume.js +2 -0
- package/test/fixtures/fake-npm.sh +64 -0
- package/test/fixtures/lychee-report.md +39 -0
- package/test/javascript-first-ci.js +69 -0
- package/test/links-workflow.mjs +127 -0
- package/test/lint-gate.mjs +56 -0
- package/test/npm-publish.mjs +181 -0
- package/test/recheck-broken-links.mjs +452 -0
- package/test/regression-176.js +2 -0
- package/test/regression-187.js +2 -0
- package/test/regression-191.js +4 -0
- package/test/regression-193.js +2 -0
- package/test/regression-198.js +2 -0
- package/test/regression-207.js +8 -2
- package/test/regression-224.js +2 -0
- package/test/regression-236.js +250 -0
- package/test/regression-238.js +129 -0
- package/test/regression-241.js +254 -0
- package/test/regression-242.js +241 -0
- package/test/regression-243.js +493 -0
- package/test/regression-244.js +471 -0
- package/test/release-push-retry.mjs +172 -0
- package/test/run-js-tests.mjs +15 -3
- package/test/setup/private-snapshot-lock.js +17 -0
- package/test/shared-golden.js +29 -2
- package/test/transient-links.mjs +139 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #242: when the task container's cgroup cannot be
|
|
3
|
+
* read where `$` runs (a mounted docker.sock), every sampler round started a
|
|
4
|
+
* new `docker exec` (sh, three `cat`, two `sed` inside the task container) and
|
|
5
|
+
* a `docker inspect -f {{.State.Running}}`.
|
|
6
|
+
*
|
|
7
|
+
* Now one long-lived `docker exec -i` answers one host tick per interval with
|
|
8
|
+
* shell builtins, liveness is probed only after a round without a sample, and
|
|
9
|
+
* `START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS=off` turns sampling off.
|
|
10
|
+
* The per-round counts are covered by regression-238 (shared fixture).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { describe, it, expect, afterEach } = require('bun:test');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { spawnSync } = require('child_process');
|
|
18
|
+
const {
|
|
19
|
+
CGROUP_SAMPLING_OFF_REASON,
|
|
20
|
+
CGROUP_STREAM_NAME,
|
|
21
|
+
buildCgroupStreamScript,
|
|
22
|
+
buildCgroupSamplerStartSnippet,
|
|
23
|
+
buildCgroupSamplerStopSnippet,
|
|
24
|
+
buildCgroupMemoryLogSnippet,
|
|
25
|
+
} = require('../src/lib/cgroup-memory');
|
|
26
|
+
|
|
27
|
+
const posix = process.platform === 'win32' ? describe.skip : describe;
|
|
28
|
+
const tempDirs = [];
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
while (tempDirs.length > 0) {
|
|
31
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function tempDir() {
|
|
36
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sampler-242-'));
|
|
37
|
+
tempDirs.push(dir);
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeExecutable(file, lines) {
|
|
42
|
+
fs.writeFileSync(file, [...lines, ''].join('\n'));
|
|
43
|
+
fs.chmodSync(file, 0o755);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Runs the in-container loop with an empty PATH, so any `cat`, `sed`, `cut`
|
|
48
|
+
* or `sleep` would fail, and `ticks` newlines from the host on stdin.
|
|
49
|
+
*/
|
|
50
|
+
function stream(files, ticks = 2) {
|
|
51
|
+
const dir = tempDir();
|
|
52
|
+
const cgroup = path.join(dir, 'cgroup');
|
|
53
|
+
const bin = path.join(dir, 'bin');
|
|
54
|
+
fs.mkdirSync(cgroup);
|
|
55
|
+
fs.mkdirSync(bin);
|
|
56
|
+
for (const [name, content] of Object.entries(files)) {
|
|
57
|
+
fs.writeFileSync(path.join(cgroup, name), content);
|
|
58
|
+
}
|
|
59
|
+
const result = spawnSync(
|
|
60
|
+
'/bin/sh',
|
|
61
|
+
['-c', buildCgroupStreamScript(), CGROUP_STREAM_NAME, cgroup],
|
|
62
|
+
{
|
|
63
|
+
encoding: 'utf8',
|
|
64
|
+
env: { PATH: bin },
|
|
65
|
+
input: '\n'.repeat(ticks),
|
|
66
|
+
timeout: 10000,
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
return { ...result, cgroup };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const EVENTS = 'low 0\nhigh 0\nmax 4\noom 2\noom_kill 1\noom_group_kill 0\n';
|
|
73
|
+
|
|
74
|
+
posix('issue #242: the long-lived sampler exec', () => {
|
|
75
|
+
it('prints one line per host tick using only builtins', () => {
|
|
76
|
+
const result = stream({
|
|
77
|
+
'memory.events': EVENTS,
|
|
78
|
+
'memory.max': 'max\n',
|
|
79
|
+
'memory.peak': '1234\n',
|
|
80
|
+
});
|
|
81
|
+
expect(result.stdout).toBe(
|
|
82
|
+
`max 1234 2 1 ${result.cgroup}\nmax 1234 2 1 ${result.cgroup}\n`
|
|
83
|
+
);
|
|
84
|
+
// End of input (the sampler is gone) ends the loop.
|
|
85
|
+
expect(result.status).toBe(0);
|
|
86
|
+
expect(result.stderr).toBe('');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('prints - for counters the kernel does not provide', () => {
|
|
90
|
+
const result = stream({ 'memory.events': EVENTS }, 1);
|
|
91
|
+
expect(result.stdout).toBe(`- - 2 1 ${result.cgroup}\n`);
|
|
92
|
+
expect(result.status).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('prints nothing before the first tick', () => {
|
|
96
|
+
const result = stream({ 'memory.events': EVENTS }, 0);
|
|
97
|
+
expect(result.stdout).toBe('');
|
|
98
|
+
expect(result.status).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
for (const [name, files] of [
|
|
102
|
+
['missing memory.events', {}],
|
|
103
|
+
['memory.events without oom_kill', { 'memory.events': 'oom 1\n' }],
|
|
104
|
+
['a malformed counter', { 'memory.events': 'oom x\noom_kill 1\n' }],
|
|
105
|
+
]) {
|
|
106
|
+
it(`exits 3 on ${name}`, () => {
|
|
107
|
+
const result = stream(files);
|
|
108
|
+
expect(result.stdout).toBe('');
|
|
109
|
+
expect(result.status).toBe(3);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
it('starts the stream under a recognisable name', () => {
|
|
114
|
+
const snippet = buildCgroupSamplerStartSnippet('task');
|
|
115
|
+
expect(snippet).toContain("| docker exec -i 'task' sh -c ");
|
|
116
|
+
expect(snippet).toContain(`${CGROUP_STREAM_NAME} /sys/fs/cgroup 2>&1`);
|
|
117
|
+
// The per-round exec stays as the fallback.
|
|
118
|
+
expect(snippet).toContain('__start_command_cgroup_read /sys/fs/cgroup');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('matches the Rust snippet byte for byte', () => {
|
|
122
|
+
const golden = fs.readFileSync(
|
|
123
|
+
path.resolve(
|
|
124
|
+
__dirname,
|
|
125
|
+
'../../parity/fixtures/cgroup-sampler-start-242.sh'
|
|
126
|
+
),
|
|
127
|
+
'utf8'
|
|
128
|
+
);
|
|
129
|
+
expect(`${buildCgroupSamplerStartSnippet("task '242'")}\n`).toBe(golden);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/** Runs the start snippet, waits, stops, and prints the results. */
|
|
134
|
+
function run(env, between) {
|
|
135
|
+
const dir = tempDir();
|
|
136
|
+
const bin = path.join(dir, 'bin');
|
|
137
|
+
fs.mkdirSync(bin);
|
|
138
|
+
// A daemon reached through a mounted socket: no local cgroup, private
|
|
139
|
+
// namespace, and an exec that answers each tick until its input ends.
|
|
140
|
+
writeExecutable(path.join(bin, 'docker'), [
|
|
141
|
+
'#!/bin/sh',
|
|
142
|
+
`printf '%s\\n' "$*" >> "$TMPDIR/calls"`,
|
|
143
|
+
'case "$1 $3" in',
|
|
144
|
+
' *HostConfig.Memory*) echo 0 ;;',
|
|
145
|
+
' *CgroupnsMode*) echo private ;;',
|
|
146
|
+
' *State.Pid*) echo 0 ;;',
|
|
147
|
+
// Never asked when the stream works; ends a regressed sampler at once.
|
|
148
|
+
' *State.Running*) echo false ;;',
|
|
149
|
+
' *Id*) echo remote242 ;;',
|
|
150
|
+
' exec*)',
|
|
151
|
+
` [ "$2 $7" = "-i ${CGROUP_STREAM_NAME}" ] || exit 1`,
|
|
152
|
+
' echo $$ > "$TMPDIR/exec.pid"',
|
|
153
|
+
" while read -r tick; do echo '67108864 1048576 0 0 /sys/fs/cgroup'; done ;;",
|
|
154
|
+
'esac',
|
|
155
|
+
]);
|
|
156
|
+
const script = [
|
|
157
|
+
buildCgroupSamplerStartSnippet('task-242'),
|
|
158
|
+
between,
|
|
159
|
+
buildCgroupSamplerStopSnippet(),
|
|
160
|
+
'printf "sample=%s\\n" "$__start_command_cgroup"',
|
|
161
|
+
buildCgroupMemoryLogSnippet('"$TMPDIR/memory.log"'),
|
|
162
|
+
'cat "$TMPDIR/memory.log"',
|
|
163
|
+
'for file in "$TMPDIR"/start-command-cgroup*; do [ ! -e "$file" ] || echo "left: $file"; done',
|
|
164
|
+
].join('; ');
|
|
165
|
+
const result = spawnSync('/bin/sh', ['-c', script], {
|
|
166
|
+
encoding: 'utf8',
|
|
167
|
+
timeout: 20000,
|
|
168
|
+
env: {
|
|
169
|
+
...process.env,
|
|
170
|
+
...env,
|
|
171
|
+
TMPDIR: dir,
|
|
172
|
+
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
|
173
|
+
START_COMMAND_CGROUP_ROOT: path.join(dir, 'hidden'),
|
|
174
|
+
START_COMMAND_PROC_ROOT: path.join(dir, 'hidden'),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
expect(result.status).toBe(0);
|
|
178
|
+
const calls = fs.existsSync(path.join(dir, 'calls'))
|
|
179
|
+
? fs.readFileSync(path.join(dir, 'calls'), 'utf8')
|
|
180
|
+
: '';
|
|
181
|
+
const pidFile = path.join(dir, 'exec.pid');
|
|
182
|
+
const pid = fs.existsSync(pidFile)
|
|
183
|
+
? Number(fs.readFileSync(pidFile, 'utf8'))
|
|
184
|
+
: null;
|
|
185
|
+
return { output: result.stdout, calls, pid };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const alive = (pid) => {
|
|
189
|
+
try {
|
|
190
|
+
process.kill(pid, 0);
|
|
191
|
+
return true;
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
posix('issue #242: sampler lifecycle against a mounted docker.sock', () => {
|
|
198
|
+
it('samples from one exec and stops it with the sampler', async () => {
|
|
199
|
+
const { output, calls, pid } = run(
|
|
200
|
+
{ START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS: '1' },
|
|
201
|
+
'sleep 2'
|
|
202
|
+
);
|
|
203
|
+
expect(output).toMatch(/^sample=67108864 1048576 0 0 0 \d+$/m);
|
|
204
|
+
expect(output).toContain(
|
|
205
|
+
'Memory: memory.max=67108864 memory.peak=1048576 oom=0 oom_kill=0'
|
|
206
|
+
);
|
|
207
|
+
expect(output).not.toContain('left:');
|
|
208
|
+
const lines = calls.split('\n');
|
|
209
|
+
expect(lines.filter((line) => line.startsWith('exec ')).length).toBe(1);
|
|
210
|
+
// A streaming exec proves liveness; Docker is never asked.
|
|
211
|
+
expect(calls).not.toContain('{{.State.Running}}');
|
|
212
|
+
// Once the reader is gone, the next tick ends the exec and the ticker.
|
|
213
|
+
expect(pid).toBeGreaterThan(0);
|
|
214
|
+
for (let i = 0; i < 50 && alive(pid); i++) {
|
|
215
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
216
|
+
}
|
|
217
|
+
expect(alive(pid)).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
for (const value of ['off', 'OFF', 'Off']) {
|
|
221
|
+
it(`turns sampling off with ${value}`, () => {
|
|
222
|
+
const { output, calls } = run(
|
|
223
|
+
{ START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS: value },
|
|
224
|
+
'wait "$__start_command_cgroup_sampler"'
|
|
225
|
+
);
|
|
226
|
+
expect(output).toContain('sample=\n');
|
|
227
|
+
expect(output).toContain(
|
|
228
|
+
`Memory: unavailable (${CGROUP_SAMPLING_OFF_REASON}) memory.limit=0 (HostConfig)`
|
|
229
|
+
);
|
|
230
|
+
expect(output).not.toContain('left:');
|
|
231
|
+
// Only the HostConfig.Memory lookup that the post-mortem prints.
|
|
232
|
+
expect(calls).toBe('inspect -f {{.HostConfig.Memory}} task-242\n');
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
it('keeps unrelated values on the default interval', () => {
|
|
237
|
+
const snippet = buildCgroupSamplerStartSnippet('task');
|
|
238
|
+
expect(snippet).toContain("[Oo][Ff][Ff]) printf '%s\\n' ");
|
|
239
|
+
expect(snippet).toContain("*[!0-9]*|'') __scs_interval=5;;");
|
|
240
|
+
});
|
|
241
|
+
});
|