start-command 0.37.9 → 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 +26 -0
- package/README.md +7 -2
- package/bunfig.toml +4 -0
- package/eslint.config.mjs +1 -0
- package/package.json +4 -4
- package/src/bin/cli.js +62 -26
- package/src/lib/args-parser-queries.js +45 -1
- package/src/lib/args-parser.js +19 -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-command-handoff.js +34 -6
- 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 +19 -3
- package/src/lib/execution-attempt.js +19 -9
- package/src/lib/execution-chain.js +182 -0
- package/src/lib/execution-control.js +8 -6
- package/src/lib/execution-env.js +120 -0
- package/src/lib/execution-identity.js +180 -0
- package/src/lib/execution-recovery.js +21 -1
- package/src/lib/execution-resume.js +21 -1
- package/src/lib/execution-store.js +30 -23
- package/src/lib/exit-evidence.js +139 -7
- package/src/lib/isolation.js +21 -3
- package/src/lib/launch-identity-options.js +81 -0
- package/src/lib/launch-identity.js +411 -0
- package/src/lib/log-uploader.js +15 -1
- package/src/lib/output-blocks.js +11 -0
- package/src/lib/query-commands.js +18 -3
- package/src/lib/resume-resources.js +2 -2
- package/src/lib/screen-isolation.js +11 -2
- package/src/lib/spawn-helpers.js +33 -16
- package/src/lib/status-formatter.js +39 -131
- package/src/lib/status-probe.js +149 -0
- package/src/lib/store-decode-cache.js +141 -0
- package/src/lib/usage.js +11 -1
- package/test/args-parser-attach-resume.js +1 -1
- package/test/args-parser-control.js +2 -2
- 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-229.js +267 -0
- package/test/regression-230.js +234 -0
- package/test/regression-231.js +298 -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 +44 -2
- package/test/transient-links.mjs +139 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #241: a daemon restart was attributed only from
|
|
3
|
+
* `journalctl -u docker.service`. With a mounted docker.sock
|
|
4
|
+
* (Docker-outside-of-Docker) there is no host journal, so a task that the
|
|
5
|
+
* restart force-killed ended with `Exit evidence: unavailable`.
|
|
6
|
+
*
|
|
7
|
+
* `docker events` cannot help (its buffer does not survive the restart), but
|
|
8
|
+
* `State.FinishedAt` / `State.StartedAt` of the other containers do.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { describe, it, expect, afterEach } = require('bun:test');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const evidence = require('../src/lib/exit-evidence');
|
|
17
|
+
const { resolveExitReason } = require('../src/lib/exit-reason');
|
|
18
|
+
|
|
19
|
+
const TASK = 'a'.repeat(64);
|
|
20
|
+
const PEER = 'b'.repeat(64);
|
|
21
|
+
const FINISHED = '2026-10-21T10:05:00.123456789Z';
|
|
22
|
+
const state = (id, finished, started, exit, oom = 'false') =>
|
|
23
|
+
`${id} ${finished} ${started} ${exit} ${oom}`;
|
|
24
|
+
// Two containers that finished in the same second, the shape of a restart.
|
|
25
|
+
const TOGETHER = [
|
|
26
|
+
state(TASK, FINISHED, '2026-10-21T10:00:00Z', 137),
|
|
27
|
+
state(PEER, '2026-10-21T10:05:00.9Z', '2026-10-21T09:00:00Z', 137),
|
|
28
|
+
].join('\n');
|
|
29
|
+
|
|
30
|
+
/** Runner answering like a daemon reached through a mounted socket. */
|
|
31
|
+
function socketOnlyRunner(states, calls = []) {
|
|
32
|
+
return (command, args) => {
|
|
33
|
+
calls.push([command, ...args]);
|
|
34
|
+
if (command === 'journalctl') {
|
|
35
|
+
// No host journal inside the container.
|
|
36
|
+
return { success: false, stdout: '' };
|
|
37
|
+
}
|
|
38
|
+
if (args[0] === 'inspect' && args[2] === '{{.Id}}') {
|
|
39
|
+
return { success: true, stdout: `${TASK}\n` };
|
|
40
|
+
}
|
|
41
|
+
if (args[0] === 'events') {
|
|
42
|
+
return { success: true, stdout: '' };
|
|
43
|
+
}
|
|
44
|
+
if (args[0] === 'ps') {
|
|
45
|
+
return { success: true, stdout: `${TASK}\n${PEER}\n` };
|
|
46
|
+
}
|
|
47
|
+
if (args[0] === 'inspect') {
|
|
48
|
+
return { success: true, stdout: states };
|
|
49
|
+
}
|
|
50
|
+
return { success: false, stdout: '' };
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const collect = (states, extra = {}, calls) =>
|
|
55
|
+
evidence.collectExitEvidence(
|
|
56
|
+
{
|
|
57
|
+
containerName: 'task',
|
|
58
|
+
sample: '',
|
|
59
|
+
finishedAt: FINISHED,
|
|
60
|
+
exitCode: 137,
|
|
61
|
+
oomKilled: false,
|
|
62
|
+
...extra,
|
|
63
|
+
},
|
|
64
|
+
socketOnlyRunner(states, calls)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
describe('issue #241: daemon-restart evidence without the host journal', () => {
|
|
68
|
+
it('attributes two containers that finished in the same second to the daemon', () => {
|
|
69
|
+
const calls = [];
|
|
70
|
+
const line = collect(TOGETHER, {}, calls);
|
|
71
|
+
expect(line).toBe(evidence.DAEMON_RESTART_API);
|
|
72
|
+
expect(calls).toContainEqual(['docker', 'ps', '-a', '-q', '--no-trunc']);
|
|
73
|
+
// The task itself is never its own witness.
|
|
74
|
+
const inspect = calls.find(
|
|
75
|
+
(call) => call[1] === 'inspect' && call[3] !== '{{.Id}}'
|
|
76
|
+
);
|
|
77
|
+
expect(inspect.slice(4)).toEqual([PEER]);
|
|
78
|
+
expect(
|
|
79
|
+
resolveExitReason({ exitCode: 137, oomKilled: false, logTail: line })
|
|
80
|
+
).toBe('killed (docker daemon restart)');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('reports which source supported the attribution', () => {
|
|
84
|
+
expect(evidence.fromLog(evidence.DAEMON_RESTART_API)).toEqual({
|
|
85
|
+
mainOom: false,
|
|
86
|
+
daemonRestart: true,
|
|
87
|
+
daemonRestartSource: 'api',
|
|
88
|
+
});
|
|
89
|
+
expect(evidence.fromLog(evidence.DAEMON_RESTART)).toEqual({
|
|
90
|
+
mainOom: false,
|
|
91
|
+
daemonRestart: true,
|
|
92
|
+
daemonRestartSource: 'journal',
|
|
93
|
+
});
|
|
94
|
+
expect(evidence.fromLog(evidence.MAIN_OOM)).toEqual({
|
|
95
|
+
mainOom: true,
|
|
96
|
+
daemonRestart: false,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('counts a container restarted after it finished, whose ExitCode moby reset to 0', () => {
|
|
101
|
+
const restarted = state(
|
|
102
|
+
PEER,
|
|
103
|
+
'2026-10-21T10:04:52Z',
|
|
104
|
+
'2026-10-21T10:05:20Z',
|
|
105
|
+
0
|
|
106
|
+
);
|
|
107
|
+
expect(evidence.daemonRestartApiEvidence(restarted, TASK, FINISHED)).toBe(
|
|
108
|
+
true
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
for (const [name, peer] of [
|
|
113
|
+
[
|
|
114
|
+
'an OOM-killed peer',
|
|
115
|
+
state(PEER, FINISHED, '2026-10-21T09:00:00Z', 137, 'true'),
|
|
116
|
+
],
|
|
117
|
+
[
|
|
118
|
+
'a peer that finished 30 s apart',
|
|
119
|
+
state(PEER, '2026-10-21T10:05:30.2Z', '2026-10-21T09:00:00Z', 137),
|
|
120
|
+
],
|
|
121
|
+
[
|
|
122
|
+
'a peer that exited on its own',
|
|
123
|
+
state(PEER, FINISHED, '2026-10-21T09:00:00Z', 0),
|
|
124
|
+
],
|
|
125
|
+
[
|
|
126
|
+
'a peer that never ran',
|
|
127
|
+
state(PEER, '0001-01-01T00:00:00Z', '0001-01-01T00:00:00Z', 0),
|
|
128
|
+
],
|
|
129
|
+
[
|
|
130
|
+
'only the task itself',
|
|
131
|
+
state(TASK, FINISHED, '2026-10-21T09:00:00Z', 137),
|
|
132
|
+
],
|
|
133
|
+
['malformed output', 'Error: No such object: gone'],
|
|
134
|
+
]) {
|
|
135
|
+
it(`does not attribute ${name}`, () => {
|
|
136
|
+
expect(evidence.daemonRestartApiEvidence(peer, TASK, FINISHED)).toBe(
|
|
137
|
+
false
|
|
138
|
+
);
|
|
139
|
+
expect(collect(peer)).toBe(
|
|
140
|
+
'Exit evidence: unavailable (no attributed exit-time OOM or daemon restart evidence)'
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
it('also works for a remote daemon, where no journal is consulted', () => {
|
|
146
|
+
const original = process.env.DOCKER_HOST;
|
|
147
|
+
process.env.DOCKER_HOST = 'tcp://docker.example:2376';
|
|
148
|
+
try {
|
|
149
|
+
const calls = [];
|
|
150
|
+
expect(collect(TOGETHER, {}, calls)).toBe(evidence.DAEMON_RESTART_API);
|
|
151
|
+
expect(calls.some(([command]) => command === 'journalctl')).toBe(false);
|
|
152
|
+
} finally {
|
|
153
|
+
if (original === undefined) {
|
|
154
|
+
delete process.env.DOCKER_HOST;
|
|
155
|
+
} else {
|
|
156
|
+
process.env.DOCKER_HOST = original;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('keeps journal and attempt-scoped OOM evidence ahead of the API check', () => {
|
|
162
|
+
const calls = [];
|
|
163
|
+
const line = collect(
|
|
164
|
+
TOGETHER,
|
|
165
|
+
{ oomKilled: true, sample: '64 63 3 2 1792577100 1792577100' },
|
|
166
|
+
calls
|
|
167
|
+
);
|
|
168
|
+
expect(line).toBe(evidence.MAIN_OOM);
|
|
169
|
+
expect(calls.some((call) => call[1] === 'ps')).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const tempDirs = [];
|
|
174
|
+
afterEach(() => {
|
|
175
|
+
while (tempDirs.length > 0) {
|
|
176
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('issue #241: --status collects evidence the lost watcher never wrote', () => {
|
|
181
|
+
it('reports a daemon restart for a DooD task whose watcher died with the daemon', () => {
|
|
182
|
+
if (process.platform === 'win32') {
|
|
183
|
+
console.log(' Skipping: the docker stub needs a POSIX /bin/sh');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evidence-241-'));
|
|
187
|
+
tempDirs.push(dir);
|
|
188
|
+
const docker = path.join(dir, 'docker');
|
|
189
|
+
fs.writeFileSync(
|
|
190
|
+
docker,
|
|
191
|
+
[
|
|
192
|
+
'#!/bin/sh',
|
|
193
|
+
'case "$1 $3" in',
|
|
194
|
+
` "inspect {{.Id}}") echo ${TASK} ;;`,
|
|
195
|
+
` "inspect {{.Id}} "*) echo '${state(PEER, '2026-10-21T10:05:00.9Z', '2026-10-21T10:05:30Z', 0)}' ;;`,
|
|
196
|
+
` "inspect "*) echo 'false 137 false 2026-10-21T10:00:00Z ${FINISHED}' ;;`,
|
|
197
|
+
` "ps "*) printf '%s\\n' ${TASK} ${PEER} ;;`,
|
|
198
|
+
' "events "*) ;;',
|
|
199
|
+
' *) exit 1 ;;',
|
|
200
|
+
'esac',
|
|
201
|
+
'',
|
|
202
|
+
].join('\n')
|
|
203
|
+
);
|
|
204
|
+
fs.chmodSync(docker, 0o755);
|
|
205
|
+
// No host journal, as inside a container with a mounted docker.sock.
|
|
206
|
+
fs.writeFileSync(path.join(dir, 'journalctl'), '#!/bin/sh\nexit 1\n');
|
|
207
|
+
fs.chmodSync(path.join(dir, 'journalctl'), 0o755);
|
|
208
|
+
const saved = {
|
|
209
|
+
START_DOCKER_BIN: process.env.START_DOCKER_BIN,
|
|
210
|
+
PATH: process.env.PATH,
|
|
211
|
+
DOCKER_HOST: process.env.DOCKER_HOST,
|
|
212
|
+
};
|
|
213
|
+
process.env.START_DOCKER_BIN = docker;
|
|
214
|
+
process.env.PATH = `${dir}${path.delimiter}${process.env.PATH}`;
|
|
215
|
+
delete process.env.DOCKER_HOST;
|
|
216
|
+
try {
|
|
217
|
+
const {
|
|
218
|
+
ExecutionStore,
|
|
219
|
+
ExecutionRecord,
|
|
220
|
+
} = require('../src/lib/execution-store');
|
|
221
|
+
const { queryStatus } = require('../src/lib/status-formatter');
|
|
222
|
+
const store = new ExecutionStore({ appFolder: dir, useLinks: false });
|
|
223
|
+
const record = new ExecutionRecord({
|
|
224
|
+
command: 'sleep 600',
|
|
225
|
+
options: {
|
|
226
|
+
isolated: 'docker',
|
|
227
|
+
isolationMode: 'detached',
|
|
228
|
+
sessionName: 'task-241',
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
record.logPath = path.join(dir, 'task.log');
|
|
232
|
+
// The watcher died before it could log anything after the output.
|
|
233
|
+
fs.writeFileSync(record.logPath, 'working\n');
|
|
234
|
+
store.save(record);
|
|
235
|
+
const status = JSON.parse(queryStatus(store, record.uuid, 'json').output);
|
|
236
|
+
expect(status.status).toBe('executed');
|
|
237
|
+
expect(status.exitCode).toBe(137);
|
|
238
|
+
expect(status.exitReason).toBe('killed (docker daemon restart)');
|
|
239
|
+
expect(status.options.exitEvidence).toEqual({
|
|
240
|
+
mainOom: false,
|
|
241
|
+
daemonRestart: true,
|
|
242
|
+
daemonRestartSource: 'api',
|
|
243
|
+
});
|
|
244
|
+
} finally {
|
|
245
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
246
|
+
if (value === undefined) {
|
|
247
|
+
delete process.env[key];
|
|
248
|
+
} else {
|
|
249
|
+
process.env[key] = value;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -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
|
+
});
|