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,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #231: executions get caller-chosen names (`--alias`, `--rename`) and
|
|
3
|
+
* can declare the execution they continue (`--continues`), which `--status`
|
|
4
|
+
* reports and `--upload-log` follows.
|
|
5
|
+
*/
|
|
6
|
+
const { describe, it, expect } = require('bun:test');
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
12
|
+
const {
|
|
13
|
+
ExecutionStore,
|
|
14
|
+
ExecutionRecord,
|
|
15
|
+
} = require('../src/lib/execution-store');
|
|
16
|
+
const {
|
|
17
|
+
executionAliases,
|
|
18
|
+
findRecord,
|
|
19
|
+
nameExecution,
|
|
20
|
+
} = require('../src/lib/execution-identity');
|
|
21
|
+
const {
|
|
22
|
+
attachIdentity,
|
|
23
|
+
buildChain,
|
|
24
|
+
identityTextLines,
|
|
25
|
+
prepareUploadLog,
|
|
26
|
+
resolveContinues,
|
|
27
|
+
} = require('../src/lib/execution-chain');
|
|
28
|
+
|
|
29
|
+
const CLI = path.join(__dirname, '../src/bin/cli.js');
|
|
30
|
+
const A = '23100000-0000-4000-8000-00000000000a';
|
|
31
|
+
const B = '23100000-0000-4000-8000-00000000000b';
|
|
32
|
+
const C = '23100000-0000-4000-8000-00000000000c';
|
|
33
|
+
|
|
34
|
+
function sandbox() {
|
|
35
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'start-231-'));
|
|
36
|
+
const appFolder = path.join(root, 'app');
|
|
37
|
+
return {
|
|
38
|
+
root,
|
|
39
|
+
store: new ExecutionStore({ appFolder }),
|
|
40
|
+
env: {
|
|
41
|
+
...process.env,
|
|
42
|
+
START_APP_FOLDER: appFolder,
|
|
43
|
+
START_LOG_DIR: path.join(root, 'logs'),
|
|
44
|
+
START_DISABLE_AUTO_ISSUE: '1',
|
|
45
|
+
START_DISABLE_LOG_UPLOAD: '1',
|
|
46
|
+
START_EXECUTION_ID: '',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function run(box, args) {
|
|
52
|
+
return spawnSync(process.execPath, [CLI, ...args], {
|
|
53
|
+
env: box.env,
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
timeout: 20000,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function record(uuid, options = {}, extra = {}) {
|
|
60
|
+
const r = new ExecutionRecord({ uuid, command: `cmd-${uuid}`, options });
|
|
61
|
+
Object.assign(r, extra);
|
|
62
|
+
return r;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('issue #231: execution names', () => {
|
|
66
|
+
it('resolves uuid, session name, earlier names, then aliases', () => {
|
|
67
|
+
const records = [
|
|
68
|
+
record(A, { aliases: [B] }),
|
|
69
|
+
record(B, { sessionName: 'live' }),
|
|
70
|
+
record(C, { sessionNameHistory: ['old'], name: 'task' }),
|
|
71
|
+
];
|
|
72
|
+
expect(findRecord(records, B).uuid).toBe(B);
|
|
73
|
+
expect(findRecord(records, 'live').uuid).toBe(B);
|
|
74
|
+
expect(findRecord(records, 'old').uuid).toBe(C);
|
|
75
|
+
expect(findRecord(records, 'task').uuid).toBe(C);
|
|
76
|
+
expect(findRecord(records, 'missing')).toBeNull();
|
|
77
|
+
expect(findRecord(records, '')).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('--alias adds a name and refuses one used by another execution', () => {
|
|
81
|
+
const { store } = sandbox();
|
|
82
|
+
store.save(record(A, { sessionName: 'screen-a' }));
|
|
83
|
+
store.save(record(B));
|
|
84
|
+
const added = nameExecution(store, 'screen-a', 'task-1');
|
|
85
|
+
expect(added).toEqual({
|
|
86
|
+
success: true,
|
|
87
|
+
output: `Aliased execution ${A}\naliases: [${A}, screen-a, task-1]`,
|
|
88
|
+
});
|
|
89
|
+
expect(store.get('task-1').uuid).toBe(A);
|
|
90
|
+
expect(nameExecution(store, A, 'task-1').output).toStartWith(
|
|
91
|
+
`Unchanged execution ${A}`
|
|
92
|
+
);
|
|
93
|
+
expect(nameExecution(store, B, 'task-1')).toEqual({
|
|
94
|
+
success: false,
|
|
95
|
+
error: `Name "task-1" already refers to execution ${A}`,
|
|
96
|
+
});
|
|
97
|
+
expect(nameExecution(store, B, A).success).toBe(false);
|
|
98
|
+
expect(nameExecution(store, 'nope', 'x').error).toBe(
|
|
99
|
+
'No execution found with UUID or session name: nope'
|
|
100
|
+
);
|
|
101
|
+
expect(nameExecution(store, A, '-x').success).toBe(false);
|
|
102
|
+
// The backend session name list is left alone (it drives --cleanup).
|
|
103
|
+
expect(store.get(A).options.sessionNameHistory).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('--rename sets the display name and keeps the old one resolving', () => {
|
|
107
|
+
const { store } = sandbox();
|
|
108
|
+
store.save(record(A, { sessionName: 'screen-a' }));
|
|
109
|
+
expect(nameExecution(store, A, 'first', { rename: true }).output).toBe(
|
|
110
|
+
`Renamed execution ${A}\naliases: [${A}, screen-a, first]`
|
|
111
|
+
);
|
|
112
|
+
nameExecution(store, 'first', 'second', { rename: true });
|
|
113
|
+
const renamed = store.get('second');
|
|
114
|
+
expect(renamed.options).toMatchObject({
|
|
115
|
+
sessionName: 'screen-a',
|
|
116
|
+
name: 'second',
|
|
117
|
+
aliases: ['first'],
|
|
118
|
+
});
|
|
119
|
+
expect(store.get('first').uuid).toBe(A);
|
|
120
|
+
expect(store.get('screen-a').uuid).toBe(A);
|
|
121
|
+
expect(executionAliases(renamed)).toEqual([
|
|
122
|
+
A,
|
|
123
|
+
'screen-a',
|
|
124
|
+
'first',
|
|
125
|
+
'second',
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('a UUID-shaped alias resolves like any other name', () => {
|
|
130
|
+
const { store } = sandbox();
|
|
131
|
+
store.save(record(A));
|
|
132
|
+
expect(nameExecution(store, A, C).success).toBe(true);
|
|
133
|
+
expect(store.get(C).uuid).toBe(A);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('parses --alias and --rename with their two arguments', () => {
|
|
137
|
+
expect(parseArgs(['--alias', A, 'task']).wrapperOptions).toMatchObject({
|
|
138
|
+
alias: A,
|
|
139
|
+
newName: 'task',
|
|
140
|
+
});
|
|
141
|
+
expect(parseArgs(['--rename', A, 'task']).wrapperOptions).toMatchObject({
|
|
142
|
+
rename: A,
|
|
143
|
+
newName: 'task',
|
|
144
|
+
});
|
|
145
|
+
expect(() => parseArgs(['--alias', A])).toThrow(
|
|
146
|
+
'Option --alias requires a UUID or session name and a new name'
|
|
147
|
+
);
|
|
148
|
+
expect(() => parseArgs(['--rename', A, '--status'])).toThrow(
|
|
149
|
+
'Option --rename requires a UUID or session name and a new name'
|
|
150
|
+
);
|
|
151
|
+
expect(() => parseArgs(['--alias', A, 'x', '--status', A])).toThrow(
|
|
152
|
+
/Cannot combine .* --alias, or --rename in the same invocation/
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('issue #231: continuation chains', () => {
|
|
158
|
+
const chainRecords = () => [
|
|
159
|
+
record(A, {}, { startTime: '2026-01-01T00:00:00.000Z' }),
|
|
160
|
+
record(B, {}, { continues: A, startTime: '2026-01-02T00:00:00.000Z' }),
|
|
161
|
+
record(C, {}, { continues: B, startTime: '2026-01-03T00:00:00.000Z' }),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
it('builds the chain from any member, oldest first', () => {
|
|
165
|
+
const records = chainRecords();
|
|
166
|
+
for (const member of records) {
|
|
167
|
+
expect(buildChain(records, member).map((r) => r.uuid)).toEqual([A, B, C]);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('stops at a cycle', () => {
|
|
172
|
+
const records = [
|
|
173
|
+
record(A, {}, { continues: B }),
|
|
174
|
+
record(B, {}, { continues: A }),
|
|
175
|
+
];
|
|
176
|
+
expect(buildChain(records, records[0]).map((r) => r.uuid)).toEqual([B, A]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('resolves --continues by any name and refuses unknown or itself', () => {
|
|
180
|
+
const { store } = sandbox();
|
|
181
|
+
store.save(record(A, { sessionName: 'first' }));
|
|
182
|
+
expect(resolveContinues(store, 'first', B)).toBe(A);
|
|
183
|
+
expect(() => resolveContinues(store, 'nope', B)).toThrow(
|
|
184
|
+
'No execution found with UUID or session name: nope'
|
|
185
|
+
);
|
|
186
|
+
expect(() => resolveContinues(store, A, A)).toThrow(
|
|
187
|
+
`Execution ${A} cannot continue itself`
|
|
188
|
+
);
|
|
189
|
+
expect(() => resolveContinues(null, A, B)).toThrow(
|
|
190
|
+
'--continues requires execution tracking.'
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('--status reports aliases and the chain right after uuid', () => {
|
|
195
|
+
const { store } = sandbox();
|
|
196
|
+
chainRecords().forEach((r) => store.save(r));
|
|
197
|
+
nameExecution(store, B, 'task');
|
|
198
|
+
const obj = attachIdentity(store.get(B), store).toObject();
|
|
199
|
+
expect(Object.keys(obj).slice(0, 3)).toEqual(['uuid', 'aliases', 'chain']);
|
|
200
|
+
expect(obj.aliases).toEqual([B, 'task']);
|
|
201
|
+
expect(obj.chain).toEqual([A, B, C]);
|
|
202
|
+
expect(obj.continues).toBe(A);
|
|
203
|
+
expect(identityTextLines(obj)).toEqual([
|
|
204
|
+
`Aliases: ${B}, task`,
|
|
205
|
+
`Continues: ${A}`,
|
|
206
|
+
`Chain: ${A} -> ${B} -> ${C}`,
|
|
207
|
+
]);
|
|
208
|
+
// A record without names or a chain is reported unchanged.
|
|
209
|
+
const plain = record(C);
|
|
210
|
+
expect(attachIdentity(plain, null)).toBe(plain);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('--upload-log joins the chain logs, oldest first', () => {
|
|
214
|
+
const { store, root } = sandbox();
|
|
215
|
+
chainRecords().forEach((r, i) => {
|
|
216
|
+
r.logPath = path.join(root, `${i}.log`);
|
|
217
|
+
fs.writeFileSync(r.logPath, `log-${i}\n`);
|
|
218
|
+
store.save(r);
|
|
219
|
+
});
|
|
220
|
+
const single = record('23100000-0000-4000-8000-0000000000dd');
|
|
221
|
+
single.logPath = '/x.log';
|
|
222
|
+
store.save(single);
|
|
223
|
+
expect(prepareUploadLog(store, single).path).toBe('/x.log');
|
|
224
|
+
|
|
225
|
+
const prepared = prepareUploadLog(store, store.get(B));
|
|
226
|
+
const text = fs.readFileSync(prepared.path, 'utf8');
|
|
227
|
+
expect(text.indexOf('log-0')).toBeLessThan(text.indexOf('log-1'));
|
|
228
|
+
expect(text.indexOf('log-1')).toBeLessThan(text.indexOf('log-2'));
|
|
229
|
+
expect(text.split('"event":"continuation"').length - 1).toBe(3);
|
|
230
|
+
expect(text).toContain(`"uuid":"${C}","chainIndex":3,"chainLength":3`);
|
|
231
|
+
prepared.cleanup();
|
|
232
|
+
expect(fs.existsSync(prepared.path)).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('parses --continues and keeps it to launches', () => {
|
|
236
|
+
expect(
|
|
237
|
+
parseArgs(['--continues', A, '--', 'echo']).wrapperOptions.continues
|
|
238
|
+
).toBe(A);
|
|
239
|
+
expect(
|
|
240
|
+
parseArgs([`--continues=${A}`, '--', 'echo']).wrapperOptions.continues
|
|
241
|
+
).toBe(A);
|
|
242
|
+
expect(() => parseArgs(['--continues', '--', 'echo'])).toThrow(
|
|
243
|
+
'Option --continues requires a UUID or session name argument'
|
|
244
|
+
);
|
|
245
|
+
expect(() => parseArgs(['--status', A, '--continues', B])).toThrow(
|
|
246
|
+
'--continues option is only valid when launching a command'
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('issue #231: CLI flow', () => {
|
|
252
|
+
if (process.platform === 'win32') {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
it('aliases, renames and chains executions end to end', () => {
|
|
257
|
+
const box = sandbox();
|
|
258
|
+
expect(run(box, ['--session-id', A, '--', 'echo', 'one']).status).toBe(0);
|
|
259
|
+
const alias = run(box, ['--alias', A, 'nightly']);
|
|
260
|
+
expect(alias.status).toBe(0);
|
|
261
|
+
expect(alias.stdout).toContain(`Aliased execution ${A}`);
|
|
262
|
+
const rename = run(box, ['--rename', 'nightly', 'nightly-1']);
|
|
263
|
+
expect(rename.stdout).toContain(`Renamed execution ${A}`);
|
|
264
|
+
|
|
265
|
+
const second = run(box, [
|
|
266
|
+
'--session-id',
|
|
267
|
+
B,
|
|
268
|
+
'--continues',
|
|
269
|
+
'nightly-1',
|
|
270
|
+
'--',
|
|
271
|
+
'echo',
|
|
272
|
+
'two',
|
|
273
|
+
]);
|
|
274
|
+
expect(second.status).toBe(0);
|
|
275
|
+
|
|
276
|
+
const status = run(box, [
|
|
277
|
+
'--status',
|
|
278
|
+
'nightly-1',
|
|
279
|
+
'--output-format',
|
|
280
|
+
'json',
|
|
281
|
+
]);
|
|
282
|
+
const obj = JSON.parse(status.stdout);
|
|
283
|
+
expect(obj.uuid).toBe(A);
|
|
284
|
+
expect(obj.aliases).toEqual([A, 'nightly', 'nightly-1']);
|
|
285
|
+
expect(obj.chain).toEqual([A, B]);
|
|
286
|
+
|
|
287
|
+
const text = run(box, ['--status', B, '--output-format', 'text']);
|
|
288
|
+
expect(text.stdout).toContain(`Continues: ${A}`);
|
|
289
|
+
expect(text.stdout).toContain(`Chain: ${A} -> ${B}`);
|
|
290
|
+
|
|
291
|
+
const missing = run(box, ['--continues', 'nope', '--', 'echo', 'x']);
|
|
292
|
+
expect(missing.status).toBe(1);
|
|
293
|
+
expect(missing.stderr).toContain(
|
|
294
|
+
'No execution found with UUID or session name: nope'
|
|
295
|
+
);
|
|
296
|
+
expect(missing.stdout).not.toContain('x\n');
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Own the snapshot lock when run without the default preloads.
|
|
2
|
+
require('./setup/private-snapshot-lock');
|
|
3
|
+
const { it, expect } = require('bun:test');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
8
|
+
const {
|
|
9
|
+
resolveResourceOptions,
|
|
10
|
+
} = require('../src/lib/docker-resource-options');
|
|
11
|
+
const {
|
|
12
|
+
buildDockerRuntimeArgs,
|
|
13
|
+
buildDockerRuntimeMetadata,
|
|
14
|
+
} = require('../src/lib/docker-runtime-args');
|
|
15
|
+
const {
|
|
16
|
+
ExecutionRecord,
|
|
17
|
+
ExecutionStore,
|
|
18
|
+
} = require('../src/lib/execution-store');
|
|
19
|
+
const { formatRecord } = require('../src/lib/status-formatter');
|
|
20
|
+
const { resumeExecution } = require('../src/lib/execution-resume');
|
|
21
|
+
|
|
22
|
+
it('parses the reported command and forwards both limits to Docker', () => {
|
|
23
|
+
for (const flag of [
|
|
24
|
+
['--memory-reservation', '64m'],
|
|
25
|
+
['--memory-reservation=64m'],
|
|
26
|
+
]) {
|
|
27
|
+
const options = parseArgs([
|
|
28
|
+
'--isolated',
|
|
29
|
+
'docker',
|
|
30
|
+
'--image',
|
|
31
|
+
'alpine:3.20',
|
|
32
|
+
'--memory',
|
|
33
|
+
'256m',
|
|
34
|
+
...flag,
|
|
35
|
+
'--',
|
|
36
|
+
'echo',
|
|
37
|
+
'hi',
|
|
38
|
+
]).wrapperOptions;
|
|
39
|
+
expect(options.memoryReservation).toBe('64m');
|
|
40
|
+
const resolved = resolveResourceOptions(options);
|
|
41
|
+
const argv = buildDockerRuntimeArgs(resolved);
|
|
42
|
+
expect(argv).toContain('--memory=268435456');
|
|
43
|
+
expect(argv).toContain('--memory-reservation=67108864');
|
|
44
|
+
const metadata = buildDockerRuntimeMetadata({ ...options, ...resolved });
|
|
45
|
+
const record = ExecutionRecord.fromObject(
|
|
46
|
+
new ExecutionRecord({ command: 'echo hi', options: metadata }).toObject()
|
|
47
|
+
);
|
|
48
|
+
expect(record.options.resourceLimitSpecs.memoryReservation).toBe('64m');
|
|
49
|
+
expect(record.options.resolvedLimits.memoryReservation).toBe(67108864);
|
|
50
|
+
for (const format of ['json', 'text', 'links-notation']) {
|
|
51
|
+
expect(formatRecord(record, format)).toContain('memoryReservation');
|
|
52
|
+
expect(formatRecord(record, format)).toContain('67108864');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('resolves reservation percentages from daemon RAM once, including ranges', () => {
|
|
58
|
+
let calls = 0;
|
|
59
|
+
const resolved = resolveResourceOptions(
|
|
60
|
+
{ memory: '50%', memoryReservation: '10%-20%' },
|
|
61
|
+
(_, argv) => {
|
|
62
|
+
calls++;
|
|
63
|
+
expect(argv[0]).toBe('info');
|
|
64
|
+
return {
|
|
65
|
+
success: true,
|
|
66
|
+
stdout: JSON.stringify({ MemTotal: 10 * 1024 ** 3, NCPU: 8 }),
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
() => 0.5
|
|
70
|
+
);
|
|
71
|
+
expect(calls).toBe(1);
|
|
72
|
+
expect(resolved.resolvedLimits.memoryReservation).toBe(1610612736);
|
|
73
|
+
expect(resolved.resourceLimitSpecs.memoryReservation).toBe('10%-20%');
|
|
74
|
+
expect(
|
|
75
|
+
resolveResourceOptions({ resourceLimits: resolved.resourceLimits }, () => {
|
|
76
|
+
throw Error('must preserve resolved limits');
|
|
77
|
+
}).resourceLimits
|
|
78
|
+
).toEqual(resolved.resourceLimits);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('rejects equal or larger reservations against new and inherited hard caps', () => {
|
|
82
|
+
for (const options of [
|
|
83
|
+
{ memory: '256m', memoryReservation: '512m' },
|
|
84
|
+
{ memory: '256m', memoryReservation: '256m' },
|
|
85
|
+
{ memoryReservation: '512m', resourceLimits: ['--memory=256m'] },
|
|
86
|
+
{
|
|
87
|
+
memory: '32m',
|
|
88
|
+
resourceLimits: ['--memory=256m', '--memory-reservation=64m'],
|
|
89
|
+
},
|
|
90
|
+
]) {
|
|
91
|
+
expect(() => resolveResourceOptions(options)).toThrow(
|
|
92
|
+
'memory-reservation must be less than memory'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
expect(
|
|
96
|
+
resolveResourceOptions({ memoryReservation: '64m' }).resourceLimits
|
|
97
|
+
).toEqual(['--memory-reservation=67108864']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('validates reservation syntax, missing values and Docker isolation', () => {
|
|
101
|
+
for (const value of ['0m', '0%', '101%', '10watts', '20%-10%', '']) {
|
|
102
|
+
expect(() =>
|
|
103
|
+
parseArgs([
|
|
104
|
+
'--isolated',
|
|
105
|
+
'docker',
|
|
106
|
+
`--memory-reservation=${value}`,
|
|
107
|
+
'--',
|
|
108
|
+
'true',
|
|
109
|
+
])
|
|
110
|
+
).toThrow();
|
|
111
|
+
}
|
|
112
|
+
expect(() =>
|
|
113
|
+
parseArgs(['--isolated', 'docker', '--memory-reservation'])
|
|
114
|
+
).toThrow();
|
|
115
|
+
expect(() => parseArgs(['--memory-reservation=64m', '--', 'true'])).toThrow(
|
|
116
|
+
'Resource options require Docker'
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('forwards reservations through a nested Docker isolation level', () => {
|
|
121
|
+
const options = parseArgs([
|
|
122
|
+
'--isolated',
|
|
123
|
+
'screen docker',
|
|
124
|
+
'--memory',
|
|
125
|
+
'256m',
|
|
126
|
+
'--memory-reservation',
|
|
127
|
+
'64m',
|
|
128
|
+
'--',
|
|
129
|
+
'work',
|
|
130
|
+
]).wrapperOptions;
|
|
131
|
+
expect(
|
|
132
|
+
require('../src/lib/command-builder').buildNextLevelCommand(options, 'work')
|
|
133
|
+
).toContain('--memory-reservation 64m');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
for (const mode of ['start', 'snapshot', 'missing', 'override', 'invalid']) {
|
|
137
|
+
it(`preserves or overrides the reservation on ${mode} resume`, async () => {
|
|
138
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-236-'));
|
|
139
|
+
try {
|
|
140
|
+
const store = new ExecutionStore({ appFolder: dir, useLinks: false });
|
|
141
|
+
const resolved = resolveResourceOptions({
|
|
142
|
+
memory: '256m',
|
|
143
|
+
memoryReservation: '64m',
|
|
144
|
+
});
|
|
145
|
+
const record = new ExecutionRecord({
|
|
146
|
+
command: 'work',
|
|
147
|
+
status: 'executed',
|
|
148
|
+
logPath: path.join(dir, 'run.log'),
|
|
149
|
+
options: {
|
|
150
|
+
isolated: 'docker',
|
|
151
|
+
isolationMode: 'detached',
|
|
152
|
+
sessionName: 'task',
|
|
153
|
+
image: 'alpine',
|
|
154
|
+
...resolved,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
store.save(record);
|
|
158
|
+
const calls = [];
|
|
159
|
+
const runner = (_, args) => {
|
|
160
|
+
calls.push(args);
|
|
161
|
+
if (args[0] === 'info') {
|
|
162
|
+
return {
|
|
163
|
+
success: true,
|
|
164
|
+
stdout: JSON.stringify({
|
|
165
|
+
MemTotal: 1024 ** 3,
|
|
166
|
+
NCPU: 4,
|
|
167
|
+
DockerRootDir: os.tmpdir(),
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (args.includes('--size')) {
|
|
172
|
+
return { success: true, stdout: '1024' };
|
|
173
|
+
}
|
|
174
|
+
if (args[0] === 'inspect') {
|
|
175
|
+
return {
|
|
176
|
+
success: true,
|
|
177
|
+
stdout: JSON.stringify({
|
|
178
|
+
Memory: 268435456,
|
|
179
|
+
MemorySwap: 268435456,
|
|
180
|
+
MemoryReservation: 67108864,
|
|
181
|
+
}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return { success: true, stdout: 'id' };
|
|
185
|
+
};
|
|
186
|
+
const result = await resumeExecution(store, record.uuid, {
|
|
187
|
+
command: mode === 'snapshot' ? 'new' : null,
|
|
188
|
+
resourceOptions:
|
|
189
|
+
mode === 'override'
|
|
190
|
+
? { memoryReservation: '32m' }
|
|
191
|
+
: mode === 'invalid'
|
|
192
|
+
? { memory: '32m' }
|
|
193
|
+
: {},
|
|
194
|
+
runIsolated: async (_backend, _command, options) => {
|
|
195
|
+
calls.push(['run', ...options.resourceLimits]);
|
|
196
|
+
return { success: true };
|
|
197
|
+
},
|
|
198
|
+
snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
|
|
199
|
+
runner,
|
|
200
|
+
probe: () => ({
|
|
201
|
+
alive: false,
|
|
202
|
+
state: mode === 'missing' ? 'missing' : 'stopped',
|
|
203
|
+
}),
|
|
204
|
+
startWatcher: () => true,
|
|
205
|
+
});
|
|
206
|
+
expect(result.success).toBe(mode !== 'invalid');
|
|
207
|
+
if (mode === 'invalid') {
|
|
208
|
+
expect(result.error).toContain(
|
|
209
|
+
'memory-reservation must be less than memory'
|
|
210
|
+
);
|
|
211
|
+
expect(
|
|
212
|
+
calls.some((a) => ['start', 'run', 'update', 'commit'].includes(a[0]))
|
|
213
|
+
).toBe(false);
|
|
214
|
+
} else {
|
|
215
|
+
const current = store.get(record.uuid);
|
|
216
|
+
const bytes = mode === 'override' ? 33554432 : 67108864;
|
|
217
|
+
expect(current.options.resolvedLimits.memoryReservation).toBe(bytes);
|
|
218
|
+
expect(current.options.resourceLimitSpecs.memoryReservation).toBe(
|
|
219
|
+
mode === 'override' ? '32m' : '64m'
|
|
220
|
+
);
|
|
221
|
+
if (mode === 'override') {
|
|
222
|
+
expect(calls.find((a) => a[0] === 'update')).toContain(
|
|
223
|
+
`--memory-reservation=${bytes}`
|
|
224
|
+
);
|
|
225
|
+
expect(calls.findIndex((a) => a[0] === 'update')).toBeLessThan(
|
|
226
|
+
calls.findIndex((a) => a[0] === 'start')
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (mode === 'snapshot' || mode === 'missing') {
|
|
230
|
+
const limits = calls.find((a) => a[0] === 'run');
|
|
231
|
+
expect(
|
|
232
|
+
limits.some((s) => /^--memory-reservation=(64m|67108864)$/.test(s))
|
|
233
|
+
).toBe(true);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} finally {
|
|
237
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
it('refuses reservation overrides when resuming a non-Docker execution', () => {
|
|
243
|
+
expect(() =>
|
|
244
|
+
require('../src/lib/resume-resources').prepareResumeResources(
|
|
245
|
+
{ options: { isolated: 'screen' } },
|
|
246
|
+
{ state: 'stopped' },
|
|
247
|
+
{ memoryReservation: '64m' }
|
|
248
|
+
)
|
|
249
|
+
).toThrow('Resource overrides require a Docker execution');
|
|
250
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const { describe, 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
|
+
buildCgroupSamplerStartSnippet,
|
|
8
|
+
buildCgroupSamplerStopSnippet,
|
|
9
|
+
buildCgroupMemoryLogSnippet,
|
|
10
|
+
} = require('../src/lib/cgroup-memory');
|
|
11
|
+
|
|
12
|
+
function sample(mode, interval, clockStep = '5') {
|
|
13
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sampler-238-'));
|
|
14
|
+
try {
|
|
15
|
+
const script = path.join(dir, 'sample.sh');
|
|
16
|
+
fs.writeFileSync(
|
|
17
|
+
script,
|
|
18
|
+
[
|
|
19
|
+
buildCgroupSamplerStartSnippet("task '238'"),
|
|
20
|
+
'wait "$__start_command_cgroup_sampler"',
|
|
21
|
+
mode === 'final'
|
|
22
|
+
? 'printf "oom 3\\noom_kill 3\\n" > "$START_COMMAND_CGROUP_ROOT/docker/238abc/memory.events"'
|
|
23
|
+
: ':',
|
|
24
|
+
buildCgroupSamplerStopSnippet(),
|
|
25
|
+
'printf "sample=%s\\n" "$__start_command_cgroup"',
|
|
26
|
+
buildCgroupMemoryLogSnippet('"$TMPDIR/memory.log"'),
|
|
27
|
+
'cat "$TMPDIR/memory.log" "$TMPDIR/calls"',
|
|
28
|
+
'sed "s/^/sleep=/" "$TMPDIR/sleeps"',
|
|
29
|
+
// Stop must remove every temporary sample file.
|
|
30
|
+
'for file in "$TMPDIR"/start-command-cgroup*; do [ ! -e "$file" ] || exit 1; done',
|
|
31
|
+
].join('; ')
|
|
32
|
+
);
|
|
33
|
+
const env = { ...process.env, START_SAMPLER_CLOCK_STEP: clockStep };
|
|
34
|
+
delete env.START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS;
|
|
35
|
+
if (interval !== undefined) {
|
|
36
|
+
env.START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS = interval;
|
|
37
|
+
}
|
|
38
|
+
const result = spawnSync(
|
|
39
|
+
'/bin/sh',
|
|
40
|
+
[
|
|
41
|
+
path.resolve(
|
|
42
|
+
__dirname,
|
|
43
|
+
'../../experiments/issue-238-cgroup-polling.sh'
|
|
44
|
+
),
|
|
45
|
+
mode,
|
|
46
|
+
script,
|
|
47
|
+
],
|
|
48
|
+
{ encoding: 'utf8', env }
|
|
49
|
+
);
|
|
50
|
+
expect(result.status).toBe(0);
|
|
51
|
+
return result.stdout;
|
|
52
|
+
} finally {
|
|
53
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const count = (output, text) =>
|
|
58
|
+
output.split('\n').filter((line) => line.includes(text)).length;
|
|
59
|
+
const posix = process.platform === 'win32' ? describe.skip : describe;
|
|
60
|
+
|
|
61
|
+
posix('issue #238: bounded Docker cgroup sampling', () => {
|
|
62
|
+
it('defaults to five seconds and inspects local liveness only after removal', () => {
|
|
63
|
+
const output = sample('local');
|
|
64
|
+
expect(count(output, 'sleep=5')).toBe(3);
|
|
65
|
+
expect(count(output, '{{.State.Running}}')).toBe(1);
|
|
66
|
+
expect(count(output, '{{.HostConfig.CgroupnsMode}}')).toBe(1);
|
|
67
|
+
// A readable local cgroup never starts a remote exec, even after removal
|
|
68
|
+
// (issue #242): the liveness probe ends the loop instead.
|
|
69
|
+
expect(count(output, "task '238' sh -c")).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('honors an override in the watcher environment and caches remote namespace', () => {
|
|
73
|
+
const output = sample('remote', '2');
|
|
74
|
+
expect(count(output, 'sleep=2')).toBe(3);
|
|
75
|
+
expect(count(output, '{{.HostConfig.CgroupnsMode}}')).toBe(1);
|
|
76
|
+
// One long-lived exec streams every round; liveness is probed once, after
|
|
77
|
+
// the stream ends (issue #242).
|
|
78
|
+
expect(count(output, '{{.State.Running}}')).toBe(1);
|
|
79
|
+
expect(count(output, "task '238' sh -c")).toBe(1);
|
|
80
|
+
expect(output).toContain(
|
|
81
|
+
'memory.max=67108864 memory.peak=33554432 oom=1 oom_kill=3'
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects zero, negative, fractional, malformed and unsafe intervals', () => {
|
|
86
|
+
for (const interval of [
|
|
87
|
+
'',
|
|
88
|
+
'0',
|
|
89
|
+
'000',
|
|
90
|
+
'-1',
|
|
91
|
+
'0.5',
|
|
92
|
+
'nope',
|
|
93
|
+
'1; exit 7',
|
|
94
|
+
'99999999999999999999999999',
|
|
95
|
+
]) {
|
|
96
|
+
expect(count(sample('remote', interval), 'sleep=5')).toBe(3);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('keeps unavailable diagnostics and the last sample across remote outages', () => {
|
|
101
|
+
for (const mode of ['shared', 'no-shell', 'outage']) {
|
|
102
|
+
const output = sample(mode);
|
|
103
|
+
expect(count(output, '{{.HostConfig.CgroupnsMode}}')).toBe(1);
|
|
104
|
+
// A shared namespace or a missing shell ends sampling at once; an outage
|
|
105
|
+
// falls back to per-round execs, each followed by a liveness probe.
|
|
106
|
+
expect(count(output, '{{.State.Running}}')).toBe(
|
|
107
|
+
mode === 'outage' ? 4 : 0
|
|
108
|
+
);
|
|
109
|
+
expect(count(output, "task '238' sh -c")).toBe(
|
|
110
|
+
{ shared: 0, 'no-shell': 1, outage: 4 }[mode]
|
|
111
|
+
);
|
|
112
|
+
if (mode === 'outage') {
|
|
113
|
+
expect(output).toContain('sample=67108864 33554432 1 3 0 100');
|
|
114
|
+
} else {
|
|
115
|
+
expect(output).toContain('Memory: unavailable');
|
|
116
|
+
expect(output).toContain(
|
|
117
|
+
mode === 'shared' ? 'namespace=host' : 'sh: not found'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('preserves a final read without treating a five-second counter gap as recent OOM', () => {
|
|
124
|
+
expect(sample('final')).toContain('sample=67108864 33554432 3 3 0 115');
|
|
125
|
+
expect(sample('final', '1', '1')).toContain(
|
|
126
|
+
'sample=67108864 33554432 3 3 103 103'
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|