start-command 0.38.0 → 0.39.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 +28 -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 +120 -24
- 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/screen-isolation.js +72 -42
- 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/isolation.js +32 -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/regression-247.js +140 -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,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #244: `--init` / `--no-init` run Docker's bundled tini as PID 1, so
|
|
3
|
+
* orphaned processes of a long-running task are reaped instead of piling up
|
|
4
|
+
* as zombies. The default stays off; START_COMMAND_DOCKER_INIT=1 turns it on
|
|
5
|
+
* per host. The flag is stored with the record, carried into resumes and
|
|
6
|
+
* stacked launches, and changes neither exit codes nor OOM evidence.
|
|
7
|
+
*/
|
|
8
|
+
// Own snapshot lock even without the bunfig.toml preload (issue #234).
|
|
9
|
+
require('./setup/private-snapshot-lock');
|
|
10
|
+
const { describe, it, expect } = require('bun:test');
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
16
|
+
const {
|
|
17
|
+
DOCKER_INIT_ENV,
|
|
18
|
+
dockerInitFromEnv,
|
|
19
|
+
validate: validateDockerInitOption,
|
|
20
|
+
} = require('../src/lib/docker-init-option');
|
|
21
|
+
const {
|
|
22
|
+
buildDockerRuntimeArgs,
|
|
23
|
+
buildDockerRuntimeStatusLines,
|
|
24
|
+
buildDockerRuntimeMetadata,
|
|
25
|
+
} = require('../src/lib/docker-runtime-args');
|
|
26
|
+
const {
|
|
27
|
+
buildLaunchOptions,
|
|
28
|
+
buildResumePlan,
|
|
29
|
+
} = require('../src/lib/execution-resume');
|
|
30
|
+
const { SessionState } = require('../src/lib/session-probe');
|
|
31
|
+
const { buildNextLevelCommand } = require('../src/lib/command-builder');
|
|
32
|
+
const {
|
|
33
|
+
parseIsolationMetadata,
|
|
34
|
+
generateIsolationLines,
|
|
35
|
+
} = require('../src/lib/output-blocks');
|
|
36
|
+
const { canRunLinuxDockerImages } = require('../src/lib/isolation');
|
|
37
|
+
|
|
38
|
+
const CLI = path.join(__dirname, '../src/bin/cli.js');
|
|
39
|
+
const IMAGE = 'alpine:3.23';
|
|
40
|
+
|
|
41
|
+
function parse(args, env = {}) {
|
|
42
|
+
const saved = process.env[DOCKER_INIT_ENV];
|
|
43
|
+
if (env[DOCKER_INIT_ENV] === undefined) {
|
|
44
|
+
delete process.env[DOCKER_INIT_ENV];
|
|
45
|
+
} else {
|
|
46
|
+
process.env[DOCKER_INIT_ENV] = env[DOCKER_INIT_ENV];
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return parseArgs(args).wrapperOptions;
|
|
50
|
+
} finally {
|
|
51
|
+
if (saved === undefined) {
|
|
52
|
+
delete process.env[DOCKER_INIT_ENV];
|
|
53
|
+
} else {
|
|
54
|
+
process.env[DOCKER_INIT_ENV] = saved;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('issue #244: parsing --init / --no-init', () => {
|
|
60
|
+
it('turns init on with --init and off with --no-init', () => {
|
|
61
|
+
expect(parse(['-i', 'docker', '--init', '--', 'ls']).init).toBe(true);
|
|
62
|
+
expect(parse(['-i', 'docker', '--no-init', '--', 'ls']).init).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('defaults to off', () => {
|
|
66
|
+
expect(parse(['-i', 'docker', '--', 'ls']).init).toBe(false);
|
|
67
|
+
expect(parse(['--', 'ls']).init).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('takes the default from START_COMMAND_DOCKER_INIT', () => {
|
|
71
|
+
for (const value of ['1', 'true', 'YES', ' on ']) {
|
|
72
|
+
const env = { [DOCKER_INIT_ENV]: value };
|
|
73
|
+
expect(parse(['-i', 'docker', '--', 'ls'], env).init).toBe(true);
|
|
74
|
+
}
|
|
75
|
+
for (const value of ['0', 'false', 'off', '']) {
|
|
76
|
+
const env = { [DOCKER_INIT_ENV]: value };
|
|
77
|
+
expect(parse(['-i', 'docker', '--', 'ls'], env).init).toBe(false);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('lets --no-init override the environment default', () => {
|
|
82
|
+
const env = { [DOCKER_INIT_ENV]: '1' };
|
|
83
|
+
expect(parse(['-i', 'docker', '--no-init', '--', 'ls'], env).init).toBe(
|
|
84
|
+
false
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('ignores the environment default without docker, without an error', () => {
|
|
89
|
+
const env = { [DOCKER_INIT_ENV]: '1' };
|
|
90
|
+
expect(parse(['--', 'ls'], env).init).toBe(false);
|
|
91
|
+
expect(parse(['-i', 'screen', '-d', '--', 'ls'], env).init).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('rejects --init without docker in the stack', () => {
|
|
95
|
+
expect(() => parse(['--init', '--', 'ls'])).toThrow(
|
|
96
|
+
'--init option is only valid when isolation stack includes docker'
|
|
97
|
+
);
|
|
98
|
+
expect(() => parse(['-i', 'screen', '-d', '--init', '--', 'ls'])).toThrow(
|
|
99
|
+
'--init option is only valid when isolation stack includes docker'
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('accepts --no-init without docker, since it is the default', () => {
|
|
104
|
+
expect(parse(['--no-init', '--', 'ls']).init).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('validates again without changing the result', () => {
|
|
108
|
+
const { validateOptions } = require('../src/lib/args-parser');
|
|
109
|
+
const env = { [DOCKER_INIT_ENV]: '1' };
|
|
110
|
+
for (const args of [
|
|
111
|
+
['--', 'ls'],
|
|
112
|
+
['-i', 'docker', '--', 'ls'],
|
|
113
|
+
['-i', 'docker', '--no-init', '--', 'ls'],
|
|
114
|
+
['-i', 'docker', '--init', '--', 'ls'],
|
|
115
|
+
]) {
|
|
116
|
+
const options = parse(args, env);
|
|
117
|
+
const before = options.init;
|
|
118
|
+
validateOptions(options);
|
|
119
|
+
expect(options.init).toBe(before);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('accepts a stack that contains docker', () => {
|
|
124
|
+
expect(
|
|
125
|
+
parse(['-i', 'screen docker', '--init', '--', 'ls']).init
|
|
126
|
+
).toBeTrue();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('reads the environment value case-insensitively', () => {
|
|
130
|
+
expect(dockerInitFromEnv({ [DOCKER_INIT_ENV]: 'True' })).toBe(true);
|
|
131
|
+
expect(dockerInitFromEnv({})).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('validates with an explicit environment', () => {
|
|
135
|
+
const options = { isolated: 'docker', isolatedStack: ['docker'] };
|
|
136
|
+
options.init = null;
|
|
137
|
+
validateDockerInitOption(options, { [DOCKER_INIT_ENV]: 'on' });
|
|
138
|
+
expect(options.init).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('issue #244: docker run arguments, status and metadata', () => {
|
|
143
|
+
it('puts --init right after --privileged and before -e', () => {
|
|
144
|
+
expect(
|
|
145
|
+
buildDockerRuntimeArgs({ privileged: true, init: true, env: ['A=1'] })
|
|
146
|
+
).toEqual(['--privileged', '--init', '-e', 'A=1']);
|
|
147
|
+
expect(buildDockerRuntimeArgs({ init: true })).toEqual(['--init']);
|
|
148
|
+
expect(buildDockerRuntimeArgs({ init: false })).not.toContain('--init');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('shows the init status line only when on', () => {
|
|
152
|
+
expect(buildDockerRuntimeStatusLines({ init: true })).toContain(
|
|
153
|
+
'[Isolation] Init: true'
|
|
154
|
+
);
|
|
155
|
+
expect(
|
|
156
|
+
buildDockerRuntimeStatusLines({ init: false }).join('\n')
|
|
157
|
+
).not.toContain('Init');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('stores init in the record metadata', () => {
|
|
161
|
+
expect(buildDockerRuntimeMetadata({ init: true }).init).toBe(true);
|
|
162
|
+
expect(buildDockerRuntimeMetadata({ init: false }).init).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('renders an init row in the start block, before user', () => {
|
|
166
|
+
const metadata = parseIsolationMetadata([
|
|
167
|
+
'[Isolation] Environment: docker, Mode: detached',
|
|
168
|
+
'[Isolation] Image: alpine:3.23',
|
|
169
|
+
'[Isolation] Init: true',
|
|
170
|
+
'[Isolation] User: box (isolated)',
|
|
171
|
+
]);
|
|
172
|
+
expect(metadata.init).toBe(true);
|
|
173
|
+
const lines = generateIsolationLines(metadata, 'box').join('\n');
|
|
174
|
+
expect(lines).toMatch(/init\s+true/);
|
|
175
|
+
expect(lines.indexOf('init')).toBeLessThan(lines.indexOf('user'));
|
|
176
|
+
const off = parseIsolationMetadata(['[Isolation] Environment: docker']);
|
|
177
|
+
expect(generateIsolationLines(off, 'box').join('\n')).not.toMatch(/init/);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
function makeRecord(options = {}) {
|
|
182
|
+
return {
|
|
183
|
+
uuid: '24400000-0000-4000-8000-000000000244',
|
|
184
|
+
status: 'executed',
|
|
185
|
+
command: 'npm test',
|
|
186
|
+
logPath: '/tmp/session.log',
|
|
187
|
+
options: {
|
|
188
|
+
isolated: 'docker',
|
|
189
|
+
isolationMode: 'detached',
|
|
190
|
+
sessionName: 'box',
|
|
191
|
+
image: 'ubuntu:24.04',
|
|
192
|
+
...options,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const stopped = {
|
|
198
|
+
backend: 'docker',
|
|
199
|
+
sessionName: 'box',
|
|
200
|
+
state: SessionState.STOPPED,
|
|
201
|
+
alive: false,
|
|
202
|
+
containerStatus: 'exited',
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
describe('issue #244: resume and stacked launches keep the choice', () => {
|
|
206
|
+
it('relaunches with the stored init flag', () => {
|
|
207
|
+
expect(buildLaunchOptions(makeRecord({ init: true })).init).toBe(true);
|
|
208
|
+
expect(buildLaunchOptions(makeRecord()).init).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('carries --init into the snapshot run', () => {
|
|
212
|
+
const plan = buildResumePlan(
|
|
213
|
+
makeRecord({ init: true }),
|
|
214
|
+
'echo hi',
|
|
215
|
+
stopped
|
|
216
|
+
);
|
|
217
|
+
expect(plan.steps[1].args).toContain('--init');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('starts records from before #244 without --init', () => {
|
|
221
|
+
const plan = buildResumePlan(makeRecord(), 'echo hi', stopped);
|
|
222
|
+
expect(plan.steps[1].args).not.toContain('--init');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('passes --init to the nested level of a stack', () => {
|
|
226
|
+
const command = buildNextLevelCommand(
|
|
227
|
+
{
|
|
228
|
+
isolated: 'screen',
|
|
229
|
+
isolatedStack: ['screen', 'docker'],
|
|
230
|
+
imageStack: [null, IMAGE],
|
|
231
|
+
init: true,
|
|
232
|
+
},
|
|
233
|
+
'ls'
|
|
234
|
+
);
|
|
235
|
+
expect(command).toContain('--init');
|
|
236
|
+
expect(
|
|
237
|
+
buildNextLevelCommand(
|
|
238
|
+
{ isolated: 'screen', isolatedStack: ['screen', 'docker'] },
|
|
239
|
+
'ls'
|
|
240
|
+
)
|
|
241
|
+
).not.toContain('--init');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const dockerReady = (() => {
|
|
246
|
+
try {
|
|
247
|
+
return canRunLinuxDockerImages();
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
})();
|
|
252
|
+
|
|
253
|
+
function docker(...args) {
|
|
254
|
+
const r = spawnSync('docker', args, { encoding: 'utf8', timeout: 60000 });
|
|
255
|
+
return { status: r.status, out: (r.stdout || '').trim(), err: r.stderr };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function waitUntil(check, timeoutMs) {
|
|
259
|
+
const end = Date.now() + timeoutMs;
|
|
260
|
+
while (Date.now() < end) {
|
|
261
|
+
const value = check();
|
|
262
|
+
if (value) {
|
|
263
|
+
return value;
|
|
264
|
+
}
|
|
265
|
+
spawnSync('sleep', ['0.2']);
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Start a detached, kept container through the CLI. */
|
|
271
|
+
function launch(name, flags, command, root) {
|
|
272
|
+
return spawnSync(
|
|
273
|
+
process.execPath,
|
|
274
|
+
[
|
|
275
|
+
CLI,
|
|
276
|
+
'--isolated',
|
|
277
|
+
'docker',
|
|
278
|
+
...flags,
|
|
279
|
+
'--detached',
|
|
280
|
+
'--keep-container',
|
|
281
|
+
'--image',
|
|
282
|
+
IMAGE,
|
|
283
|
+
'--session',
|
|
284
|
+
name,
|
|
285
|
+
'--',
|
|
286
|
+
command,
|
|
287
|
+
],
|
|
288
|
+
{
|
|
289
|
+
encoding: 'utf8',
|
|
290
|
+
timeout: 60000,
|
|
291
|
+
env: {
|
|
292
|
+
...process.env,
|
|
293
|
+
HOME: root,
|
|
294
|
+
START_APP_FOLDER: path.join(root, 'app'),
|
|
295
|
+
START_LOG_DIR: path.join(root, 'logs'),
|
|
296
|
+
START_DISABLE_AUTO_ISSUE: '1',
|
|
297
|
+
START_DISABLE_LOG_UPLOAD: '1',
|
|
298
|
+
START_EXECUTION_ID: '',
|
|
299
|
+
[DOCKER_INIT_ENV]: '',
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function status(name, root) {
|
|
306
|
+
const r = spawnSync(
|
|
307
|
+
process.execPath,
|
|
308
|
+
[CLI, '--status', name, '--output-format', 'json'],
|
|
309
|
+
{
|
|
310
|
+
encoding: 'utf8',
|
|
311
|
+
timeout: 60000,
|
|
312
|
+
env: {
|
|
313
|
+
...process.env,
|
|
314
|
+
HOME: root,
|
|
315
|
+
START_APP_FOLDER: path.join(root, 'app'),
|
|
316
|
+
START_LOG_DIR: path.join(root, 'logs'),
|
|
317
|
+
START_DISABLE_AUTO_ISSUE: '1',
|
|
318
|
+
},
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
return r.stdout || '';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
describe('issue #244: real docker', () => {
|
|
325
|
+
it.skipIf(!dockerReady)(
|
|
326
|
+
'runs docker-init as PID 1 and reaps an orphan, recorded in --status',
|
|
327
|
+
() => {
|
|
328
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'start-244-'));
|
|
329
|
+
const names = [`t244-init-${process.pid}`, `t244-plain-${process.pid}`];
|
|
330
|
+
try {
|
|
331
|
+
// The subshell exits at once; its `sleep 0.2` child becomes an
|
|
332
|
+
// orphan of PID 1 and exits 0.2 s later.
|
|
333
|
+
const command = '(sleep 0.2 &) ; sleep 30';
|
|
334
|
+
for (const [name, flags] of [
|
|
335
|
+
[names[0], ['--init']],
|
|
336
|
+
[names[1], []],
|
|
337
|
+
]) {
|
|
338
|
+
const r = launch(name, flags, command, root);
|
|
339
|
+
expect(r.status).toBe(0);
|
|
340
|
+
}
|
|
341
|
+
spawnSync('sleep', ['1.5']);
|
|
342
|
+
const probe = (name) => ({
|
|
343
|
+
init: docker('inspect', '-f', '{{.HostConfig.Init}}', name).out,
|
|
344
|
+
pid1: docker('exec', name, 'cat', '/proc/1/comm').out,
|
|
345
|
+
zombies: docker(
|
|
346
|
+
'exec',
|
|
347
|
+
name,
|
|
348
|
+
'sh',
|
|
349
|
+
'-c',
|
|
350
|
+
'grep -l "^State:.*Z" /proc/[0-9]*/status 2>/dev/null | wc -l'
|
|
351
|
+
).out,
|
|
352
|
+
});
|
|
353
|
+
const withInit = probe(names[0]);
|
|
354
|
+
const without = probe(names[1]);
|
|
355
|
+
expect(withInit).toEqual({
|
|
356
|
+
init: 'true',
|
|
357
|
+
pid1: 'docker-init',
|
|
358
|
+
zombies: '0',
|
|
359
|
+
});
|
|
360
|
+
// Unset, Docker reports `<nil>` (no --init flag on the run).
|
|
361
|
+
expect(['false', '<nil>']).toContain(without.init);
|
|
362
|
+
expect(without.pid1).not.toBe('docker-init');
|
|
363
|
+
expect(without.zombies).toBe('1');
|
|
364
|
+
|
|
365
|
+
expect(status(names[0], root)).toMatch(/"init":\s*true/);
|
|
366
|
+
expect(status(names[1], root)).not.toMatch(/"init":\s*true/);
|
|
367
|
+
} finally {
|
|
368
|
+
docker('rm', '-f', ...names);
|
|
369
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
90000
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
it.skipIf(!dockerReady)(
|
|
376
|
+
'keeps exit codes the same with and without --init',
|
|
377
|
+
() => {
|
|
378
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'start-244-'));
|
|
379
|
+
const names = [];
|
|
380
|
+
try {
|
|
381
|
+
const cases = [
|
|
382
|
+
['exit3', 'exit 3', 3],
|
|
383
|
+
['segv', 'sh -c "kill -SEGV \\$\\$"; exit $?', 139],
|
|
384
|
+
];
|
|
385
|
+
for (const [label, command, expected] of cases) {
|
|
386
|
+
for (const flags of [['--init'], []]) {
|
|
387
|
+
const name = `t244-${label}-${flags.length}-${process.pid}`;
|
|
388
|
+
names.push(name);
|
|
389
|
+
expect(launch(name, flags, command, root).status).toBe(0);
|
|
390
|
+
const code = waitUntil(() => {
|
|
391
|
+
const r = docker(
|
|
392
|
+
'inspect',
|
|
393
|
+
'-f',
|
|
394
|
+
'{{.State.Status}} {{.State.ExitCode}}',
|
|
395
|
+
name
|
|
396
|
+
);
|
|
397
|
+
const [state, exitCode] = r.out.split(' ');
|
|
398
|
+
return state === 'exited' ? Number(exitCode) : null;
|
|
399
|
+
}, 30000);
|
|
400
|
+
expect(code).toBe(expected);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// Both editions' --status agree on the exit code once the watcher
|
|
404
|
+
// has recorded it.
|
|
405
|
+
for (const name of names) {
|
|
406
|
+
const expected = name.includes('exit3') ? 3 : 139;
|
|
407
|
+
const recorded = waitUntil(() => {
|
|
408
|
+
const match = status(name, root).match(/"exitCode":\s*(-?\d+)/);
|
|
409
|
+
return match && Number(match[1]) === expected;
|
|
410
|
+
}, 30000);
|
|
411
|
+
expect(recorded).toBe(true);
|
|
412
|
+
}
|
|
413
|
+
} finally {
|
|
414
|
+
if (names.length) {
|
|
415
|
+
docker('rm', '-f', ...names);
|
|
416
|
+
}
|
|
417
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
120000
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
it.skipIf(!dockerReady)(
|
|
424
|
+
'docker kill gives 137 either way; docker stop gives 143 under tini',
|
|
425
|
+
() => {
|
|
426
|
+
const names = [`t244-k1-${process.pid}`, `t244-k0-${process.pid}`];
|
|
427
|
+
try {
|
|
428
|
+
const results = {};
|
|
429
|
+
for (const name of names) {
|
|
430
|
+
const init = name.includes('-k1-');
|
|
431
|
+
const run = docker(
|
|
432
|
+
'run',
|
|
433
|
+
'-d',
|
|
434
|
+
'--name',
|
|
435
|
+
name,
|
|
436
|
+
...(init ? ['--init'] : []),
|
|
437
|
+
IMAGE,
|
|
438
|
+
'sleep',
|
|
439
|
+
'30'
|
|
440
|
+
);
|
|
441
|
+
expect(run.status).toBe(0);
|
|
442
|
+
docker('stop', '-t', '2', name);
|
|
443
|
+
results[init ? 'stopInit' : 'stopPlain'] = docker(
|
|
444
|
+
'inspect',
|
|
445
|
+
'-f',
|
|
446
|
+
'{{.State.ExitCode}}',
|
|
447
|
+
name
|
|
448
|
+
).out;
|
|
449
|
+
docker('start', name);
|
|
450
|
+
docker('kill', name);
|
|
451
|
+
docker('wait', name);
|
|
452
|
+
results[init ? 'killInit' : 'killPlain'] = docker(
|
|
453
|
+
'inspect',
|
|
454
|
+
'-f',
|
|
455
|
+
'{{.State.ExitCode}}',
|
|
456
|
+
name
|
|
457
|
+
).out;
|
|
458
|
+
}
|
|
459
|
+
expect(results).toEqual({
|
|
460
|
+
stopInit: '143',
|
|
461
|
+
stopPlain: '137',
|
|
462
|
+
killInit: '137',
|
|
463
|
+
killPlain: '137',
|
|
464
|
+
});
|
|
465
|
+
} finally {
|
|
466
|
+
docker('rm', '-f', ...names);
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
90000
|
|
470
|
+
);
|
|
471
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #247: with cgroup counters unreadable on both
|
|
3
|
+
* sides (no local cgroup, no `memory.events` in the container because the
|
|
4
|
+
* `memory` controller is not delegated), the sampler kept starting one
|
|
5
|
+
* `docker exec` and one `docker inspect -f {{.State.Running}}` every interval
|
|
6
|
+
* for the whole life of the task, although no round could ever record a
|
|
7
|
+
* sample.
|
|
8
|
+
*
|
|
9
|
+
* Now exit 3 (unreadable counters) from the stream or from the per-round read
|
|
10
|
+
* records the reason and stops, as exit 126/127 already did, and other failed
|
|
11
|
+
* rounds back off exponentially. The no-memory fixture modes run the real
|
|
12
|
+
* in-container scripts against a cgroup directory without `memory.events`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { describe, it, expect } = require('bun:test');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { spawnSync } = require('child_process');
|
|
20
|
+
const {
|
|
21
|
+
CGROUP_SAMPLE_BACKOFF_MAX_SECONDS,
|
|
22
|
+
CGROUP_UNREADABLE_EXIT,
|
|
23
|
+
buildCgroupSamplerStartSnippet,
|
|
24
|
+
buildCgroupSamplerStopSnippet,
|
|
25
|
+
buildCgroupMemoryLogSnippet,
|
|
26
|
+
} = require('../src/lib/cgroup-memory');
|
|
27
|
+
|
|
28
|
+
/** Runs the shared fixture of issue #238 and returns its trace. */
|
|
29
|
+
function sample(mode, interval) {
|
|
30
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sampler-247-'));
|
|
31
|
+
try {
|
|
32
|
+
const script = path.join(dir, 'sample.sh');
|
|
33
|
+
fs.writeFileSync(
|
|
34
|
+
script,
|
|
35
|
+
[
|
|
36
|
+
buildCgroupSamplerStartSnippet("task '238'"),
|
|
37
|
+
'wait "$__start_command_cgroup_sampler"',
|
|
38
|
+
buildCgroupSamplerStopSnippet(),
|
|
39
|
+
'printf "sample=%s\\n" "$__start_command_cgroup"',
|
|
40
|
+
buildCgroupMemoryLogSnippet('"$TMPDIR/memory.log"'),
|
|
41
|
+
'cat "$TMPDIR/memory.log" "$TMPDIR/calls"',
|
|
42
|
+
'sed "s/^/sleep=/" "$TMPDIR/sleeps"',
|
|
43
|
+
'for file in "$TMPDIR"/start-command-cgroup*; do [ ! -e "$file" ] || exit 1; done',
|
|
44
|
+
].join('; ')
|
|
45
|
+
);
|
|
46
|
+
const env = { ...process.env };
|
|
47
|
+
delete env.START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS;
|
|
48
|
+
if (interval !== undefined) {
|
|
49
|
+
env.START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS = interval;
|
|
50
|
+
}
|
|
51
|
+
const result = spawnSync(
|
|
52
|
+
'/bin/sh',
|
|
53
|
+
[
|
|
54
|
+
path.resolve(
|
|
55
|
+
__dirname,
|
|
56
|
+
'../../experiments/issue-238-cgroup-polling.sh'
|
|
57
|
+
),
|
|
58
|
+
mode,
|
|
59
|
+
script,
|
|
60
|
+
],
|
|
61
|
+
{ encoding: 'utf8', env, timeout: 30000 }
|
|
62
|
+
);
|
|
63
|
+
expect(result.status).toBe(0);
|
|
64
|
+
return result.stdout;
|
|
65
|
+
} finally {
|
|
66
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const count = (output, text) =>
|
|
71
|
+
output.split('\n').filter((line) => line.includes(text)).length;
|
|
72
|
+
const sleeps = (output) =>
|
|
73
|
+
output
|
|
74
|
+
.split('\n')
|
|
75
|
+
.filter((line) => line.startsWith('sleep='))
|
|
76
|
+
.map((line) => Number(line.slice('sleep='.length)));
|
|
77
|
+
const posix = process.platform === 'win32' ? describe.skip : describe;
|
|
78
|
+
const unreadable = `Memory: unavailable (local cgroup unavailable; Docker exec exit=${CGROUP_UNREADABLE_EXIT}: private cgroup v2 counters unreadable)`;
|
|
79
|
+
|
|
80
|
+
posix('issue #247: unreadable cgroup counters stop the sampler', () => {
|
|
81
|
+
it('stops after the stream exits 3, without per-round execs or liveness probes', () => {
|
|
82
|
+
const output = sample('no-memory');
|
|
83
|
+
expect(output).toContain('sample=\n');
|
|
84
|
+
expect(output).toContain(unreadable);
|
|
85
|
+
expect(count(output, "exec -i task '238' sh -c")).toBe(1);
|
|
86
|
+
expect(count(output, "task '238' sh -c")).toBe(1);
|
|
87
|
+
expect(count(output, '{{.State.Running}}')).toBe(0);
|
|
88
|
+
expect(sleeps(output)).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('stops after a per-round read exits 3', () => {
|
|
92
|
+
// The stream fails for a transient reason first, so one per-round exec
|
|
93
|
+
// runs; its real read finds no memory.events.
|
|
94
|
+
const output = sample('no-memory-outage');
|
|
95
|
+
expect(output).toContain(unreadable);
|
|
96
|
+
expect(count(output, "exec -i task '238' sh -c")).toBe(1);
|
|
97
|
+
expect(count(output, "exec task '238' sh -c")).toBe(1);
|
|
98
|
+
expect(count(output, '{{.State.Running}}')).toBe(1);
|
|
99
|
+
expect(sleeps(output)).toHaveLength(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('stops after a per-round exec finds no shell', () => {
|
|
103
|
+
const output = sample('no-shell-outage');
|
|
104
|
+
expect(output).toContain('Docker exec exit=127: sh: not found');
|
|
105
|
+
expect(count(output, "exec task '238' sh -c")).toBe(1);
|
|
106
|
+
expect(count(output, '{{.State.Running}}')).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('records the per-round reason as exit 3, not as an exec failure', () => {
|
|
110
|
+
const snippet = buildCgroupSamplerStartSnippet('task');
|
|
111
|
+
expect(snippet).toContain(
|
|
112
|
+
`__start_command_cgroup_read /sys/fs/cgroup || exit ${CGROUP_UNREADABLE_EXIT}`
|
|
113
|
+
);
|
|
114
|
+
expect(snippet).toContain('3|126|127) break;;');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
posix('issue #247: failed rounds back off exponentially', () => {
|
|
119
|
+
it('doubles the wait after each round without a sample', () => {
|
|
120
|
+
// Round 0 streams a sample; the stream, then per-round execs, fail.
|
|
121
|
+
expect(sleeps(sample('outage'))).toEqual([5, 10, 20]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it(`caps the wait at ${CGROUP_SAMPLE_BACKOFF_MAX_SECONDS} seconds`, () => {
|
|
125
|
+
expect(sleeps(sample('outage', '40'))).toEqual([
|
|
126
|
+
40,
|
|
127
|
+
CGROUP_SAMPLE_BACKOFF_MAX_SECONDS,
|
|
128
|
+
CGROUP_SAMPLE_BACKOFF_MAX_SECONDS,
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('never waits less than a longer interval', () => {
|
|
133
|
+
expect(sleeps(sample('outage', '100'))).toEqual([100, 100, 100]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('keeps the interval while samples arrive', () => {
|
|
137
|
+
expect(sleeps(sample('remote', '2'))).toEqual([2, 2, 2]);
|
|
138
|
+
expect(sleeps(sample('local'))).toEqual([5, 5, 5]);
|
|
139
|
+
});
|
|
140
|
+
});
|