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.
Files changed (62) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +4 -2
  3. package/bunfig.toml +4 -0
  4. package/eslint.config.mjs +1 -0
  5. package/package.json +4 -4
  6. package/src/lib/args-parser.js +9 -0
  7. package/src/lib/cgroup-memory.js +120 -24
  8. package/src/lib/command-builder.js +4 -0
  9. package/src/lib/container-removal.js +4 -5
  10. package/src/lib/detached-finalize.js +1 -0
  11. package/src/lib/docker-init-option.js +65 -0
  12. package/src/lib/docker-inspect-batch.js +176 -0
  13. package/src/lib/docker-resource-options.js +21 -2
  14. package/src/lib/docker-runtime-args.js +12 -3
  15. package/src/lib/execution-control.js +8 -6
  16. package/src/lib/execution-recovery.js +4 -1
  17. package/src/lib/execution-resume.js +4 -1
  18. package/src/lib/execution-store.js +19 -1
  19. package/src/lib/exit-evidence.js +139 -7
  20. package/src/lib/output-blocks.js +11 -0
  21. package/src/lib/resume-resources.js +2 -2
  22. package/src/lib/screen-isolation.js +72 -42
  23. package/src/lib/status-formatter.js +34 -130
  24. package/src/lib/status-probe.js +149 -0
  25. package/src/lib/store-decode-cache.js +141 -0
  26. package/src/lib/usage.js +3 -0
  27. package/test/cargo-lock-version.mjs +121 -0
  28. package/test/check-rust-coverage.mjs +71 -0
  29. package/test/check-web-archive.mjs +154 -0
  30. package/test/ci-workflow-invariants.js +204 -0
  31. package/test/command-builder-docker-cleanup.js +24 -0
  32. package/test/command-exit-codes.mjs +91 -0
  33. package/test/docker-runtime-options.js +2 -0
  34. package/test/docker-snapshot-safety.js +39 -0
  35. package/test/execution-resume.js +2 -0
  36. package/test/fixtures/fake-npm.sh +64 -0
  37. package/test/fixtures/lychee-report.md +39 -0
  38. package/test/isolation.js +32 -0
  39. package/test/javascript-first-ci.js +69 -0
  40. package/test/links-workflow.mjs +127 -0
  41. package/test/lint-gate.mjs +56 -0
  42. package/test/npm-publish.mjs +181 -0
  43. package/test/recheck-broken-links.mjs +452 -0
  44. package/test/regression-176.js +2 -0
  45. package/test/regression-187.js +2 -0
  46. package/test/regression-191.js +4 -0
  47. package/test/regression-193.js +2 -0
  48. package/test/regression-198.js +2 -0
  49. package/test/regression-207.js +8 -2
  50. package/test/regression-224.js +2 -0
  51. package/test/regression-236.js +250 -0
  52. package/test/regression-238.js +129 -0
  53. package/test/regression-241.js +254 -0
  54. package/test/regression-242.js +241 -0
  55. package/test/regression-243.js +493 -0
  56. package/test/regression-244.js +471 -0
  57. package/test/regression-247.js +140 -0
  58. package/test/release-push-retry.mjs +172 -0
  59. package/test/run-js-tests.mjs +15 -3
  60. package/test/setup/private-snapshot-lock.js +17 -0
  61. package/test/shared-golden.js +29 -2
  62. package/test/transient-links.mjs +139 -0
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Regression tests for issue #243: `$ --status` decoded the whole store up to
3
+ * three times, and `$ --list` started one or two `docker inspect` processes
4
+ * per record, one after another, including records that had long finished.
5
+ *
6
+ * Now the store is decoded once per content within a process, one batched
7
+ * `docker inspect` answers every probe of a query, and records whose
8
+ * container is already marked removed are not probed at all. The output is
9
+ * the same as before.
10
+ */
11
+
12
+ const { describe, it, expect, afterEach } = require('bun:test');
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+
17
+ const {
18
+ DECODED_COPY_FILE,
19
+ ExecutionRecord,
20
+ ExecutionStatus,
21
+ ExecutionStore,
22
+ } = require('../src/lib/execution-store');
23
+ const { listExecutions, queryStatus } = require('../src/lib/status-formatter');
24
+ const {
25
+ BATCH_FORMAT,
26
+ parseBatchInspect,
27
+ prefetchedInspect,
28
+ withDockerInspectBatch,
29
+ } = require('../src/lib/docker-inspect-batch');
30
+ const {
31
+ DECODED_COPY_FORMAT,
32
+ decodeCount,
33
+ decodeStoreContent,
34
+ forgetDecodedStore,
35
+ } = require('../src/lib/store-decode-cache');
36
+
37
+ const posix = process.platform === 'win32' ? describe.skip : describe;
38
+ // The fake docker is a shell script, which Windows cannot spawn.
39
+ const posixIt = process.platform === 'win32' ? it.skip : it;
40
+ const tempDirs = [];
41
+
42
+ function makeTempDir(prefix) {
43
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
44
+ tempDirs.push(dir);
45
+ return dir;
46
+ }
47
+
48
+ afterEach(() => {
49
+ while (tempDirs.length > 0) {
50
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ const CONTAINERS = [
55
+ ['run-a', 'aaaa1111', 4101, true, 0, false, '2026-10-20T10:00:00Z', ''],
56
+ [
57
+ 'run-b',
58
+ 'bbbb2222',
59
+ 0,
60
+ false,
61
+ 0,
62
+ false,
63
+ '2026-10-20T10:00:00Z',
64
+ '2026-10-20T10:05:00Z',
65
+ ],
66
+ [
67
+ 'kept-c',
68
+ 'cccc3333',
69
+ 0,
70
+ false,
71
+ 1,
72
+ false,
73
+ '2026-10-20T09:00:00Z',
74
+ '2026-10-20T09:01:00Z',
75
+ ],
76
+ ].map(([name, id, pid, running, exitCode, oom, startedAt, finishedAt]) => ({
77
+ '.Name': `/${name}`,
78
+ '.Id': id,
79
+ '.State.Pid': String(pid),
80
+ '.State.Running': String(running),
81
+ '.State.ExitCode': String(exitCode),
82
+ '.State.OOMKilled': String(oom),
83
+ '.State.StartedAt': startedAt || '0001-01-01T00:00:00Z',
84
+ '.State.FinishedAt': finishedAt || '0001-01-01T00:00:00Z',
85
+ }));
86
+
87
+ /**
88
+ * Fake `docker` that answers `inspect` like Docker 29: one rendered line per
89
+ * found target, one "No such container" line on stderr per missing one, and
90
+ * exit 1 when any is missing. In `legacy` mode it rejects `--type`, as a
91
+ * stubbed or old docker would, so every probe runs on its own as before.
92
+ */
93
+ function withFakeDocker(mode, fn) {
94
+ const dir = makeTempDir('fake-docker-243-');
95
+ const state = path.join(dir, 'containers.json');
96
+ const calls = path.join(dir, 'calls');
97
+ fs.writeFileSync(state, JSON.stringify(CONTAINERS));
98
+ const script = path.join(dir, 'docker.js');
99
+ fs.writeFileSync(
100
+ script,
101
+ `const fs = require('fs');
102
+ const args = process.argv.slice(2);
103
+ fs.appendFileSync(${JSON.stringify(calls)}, JSON.stringify(args) + '\\n');
104
+ if (args[0] !== 'inspect') process.exit(1);
105
+ let rest = args.slice(1);
106
+ if (rest[0] === '--type') {
107
+ if (${JSON.stringify(mode)} === 'legacy') {
108
+ process.stderr.write('unknown flag: --type\\n');
109
+ process.exit(125);
110
+ }
111
+ rest = rest.slice(2);
112
+ }
113
+ const format = rest[1];
114
+ const containers = JSON.parse(fs.readFileSync(${JSON.stringify(state)}, 'utf8'));
115
+ let missing = false;
116
+ for (const target of rest.slice(2)) {
117
+ const c = containers.find((x) => x['.Name'] === '/' + target) ||
118
+ containers.find((x) => x['.Id'].startsWith(target));
119
+ if (!c) {
120
+ missing = true;
121
+ process.stderr.write('Error response from daemon: No such container: ' + target + '\\n');
122
+ continue;
123
+ }
124
+ process.stdout.write(format.replace(/\\{\\{\\s*(\\.[A-Za-z.]+)\\s*\\}\\}/g, (_, f) => c[f]) + '\\n');
125
+ }
126
+ process.exit(missing ? 1 : 0);
127
+ `
128
+ );
129
+ const docker = path.join(dir, 'docker');
130
+ fs.writeFileSync(
131
+ docker,
132
+ `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} "$@"\n`
133
+ );
134
+ fs.chmodSync(docker, 0o755);
135
+ const original = process.env.START_DOCKER_BIN;
136
+ process.env.START_DOCKER_BIN = docker;
137
+ try {
138
+ return fn(() =>
139
+ fs.existsSync(calls)
140
+ ? fs
141
+ .readFileSync(calls, 'utf8')
142
+ .trim()
143
+ .split('\n')
144
+ .map((line) => JSON.parse(line))
145
+ : []
146
+ );
147
+ } finally {
148
+ if (original === undefined) {
149
+ delete process.env.START_DOCKER_BIN;
150
+ } else {
151
+ process.env.START_DOCKER_BIN = original;
152
+ }
153
+ }
154
+ }
155
+
156
+ function dockerRecord(sessionName, overrides = {}) {
157
+ return new ExecutionRecord({
158
+ uuid: `00000243-0000-4000-8000-${sessionName.padEnd(12, '0').slice(0, 12)}`,
159
+ command: `echo ${sessionName}`,
160
+ status: ExecutionStatus.EXECUTED,
161
+ exitCode: 1,
162
+ startTime: '2026-10-20T09:00:00.000Z',
163
+ endTime: '2026-10-20T09:01:00.000Z',
164
+ endTimeSource: 'docker-finished-at',
165
+ ...overrides,
166
+ options: {
167
+ isolated: 'docker',
168
+ isolationMode: 'detached',
169
+ sessionName,
170
+ ...(overrides.options || {}),
171
+ },
172
+ });
173
+ }
174
+
175
+ /** One store with every kind of record `--list` handles. */
176
+ function populatedStore() {
177
+ const store = new ExecutionStore({
178
+ appFolder: makeTempDir('store-243-'),
179
+ useLinks: false,
180
+ });
181
+ const executing = {
182
+ status: ExecutionStatus.EXECUTING,
183
+ exitCode: null,
184
+ endTime: null,
185
+ endTimeSource: undefined,
186
+ startTime: '2026-10-20T10:00:00.000Z',
187
+ };
188
+ const records = {
189
+ running: dockerRecord('run-a', executing),
190
+ exited: dockerRecord('run-b', executing),
191
+ kept: dockerRecord('kept-c', { options: { containerId: 'cccc3333' } }),
192
+ gone: dockerRecord('gone-d', { options: { containerId: 'dddd4444' } }),
193
+ removed: dockerRecord('removed-e', {
194
+ options: { containerId: 'eeee5555' },
195
+ containerState: 'removed',
196
+ containerRemovedObservedAt: '2026-10-20T09:30:00.000Z',
197
+ }),
198
+ plain: new ExecutionRecord({
199
+ uuid: '00000243-0000-4000-8000-000000000000',
200
+ command: 'echo plain',
201
+ status: ExecutionStatus.EXECUTED,
202
+ exitCode: 0,
203
+ startTime: '2026-10-20T08:00:00.000Z',
204
+ endTime: '2026-10-20T08:00:01.000Z',
205
+ }),
206
+ };
207
+ for (const record of Object.values(records)) {
208
+ store.save(record);
209
+ }
210
+ return { store, records };
211
+ }
212
+
213
+ /** Query-time clocks, which differ between any two runs. */
214
+ const CLOCKS = new Set(['currentTime', 'containerRemovedObservedAt']);
215
+
216
+ /** Parsed JSON output without the query-time clocks. */
217
+ function stable(output) {
218
+ return JSON.parse(output, (key, value) =>
219
+ CLOCKS.has(key) ? undefined : value
220
+ );
221
+ }
222
+
223
+ const inspects = (calls) => calls.filter((args) => args[0] === 'inspect');
224
+
225
+ posix('issue #243: one docker inspect per query, same output', () => {
226
+ it('lists every docker record with one docker process', () => {
227
+ const legacy = withFakeDocker('legacy', (readCalls) => {
228
+ const { store } = populatedStore();
229
+ const result = listExecutions(store, 'json');
230
+ expect(result.success).toBe(true);
231
+ return { output: stable(result.output), calls: inspects(readCalls()) };
232
+ });
233
+ const batched = withFakeDocker('batch', (readCalls) => {
234
+ const { store } = populatedStore();
235
+ const result = listExecutions(store, 'json');
236
+ expect(result.success).toBe(true);
237
+ return { output: stable(result.output), calls: inspects(readCalls()) };
238
+ });
239
+
240
+ // Before: one or two probes per docker record, after the rejected batch.
241
+ expect(legacy.calls.length).toBeGreaterThan(5);
242
+ expect(batched.calls).toHaveLength(1);
243
+ expect(batched.calls[0].slice(0, 5)).toEqual([
244
+ 'inspect',
245
+ '--type',
246
+ 'container',
247
+ '-f',
248
+ BATCH_FORMAT,
249
+ ]);
250
+ expect(batched.output).toEqual(legacy.output);
251
+ });
252
+
253
+ it('never asks docker about a container already marked removed', () => {
254
+ withFakeDocker('batch', (readCalls) => {
255
+ const { store, records } = populatedStore();
256
+ listExecutions(store, 'json');
257
+ queryStatus(store, records.removed.uuid, 'json');
258
+ const targets = inspects(readCalls()).flatMap((args) => args.slice(5));
259
+ expect(targets).not.toContain('removed-e');
260
+ expect(targets).not.toContain('eeee5555');
261
+ });
262
+ });
263
+
264
+ it('answers $ --status with one docker inspect', () => {
265
+ const run = (mode) =>
266
+ withFakeDocker(mode, (readCalls) => {
267
+ const { store, records } = populatedStore();
268
+ const result = queryStatus(store, records.kept.uuid, 'json');
269
+ expect(result.success).toBe(true);
270
+ return { output: stable(result.output), calls: inspects(readCalls()) };
271
+ });
272
+ const legacy = run('legacy');
273
+ const batched = run('batch');
274
+ expect(legacy.calls.length).toBeGreaterThanOrEqual(3);
275
+ expect(batched.calls).toHaveLength(1);
276
+ expect(batched.output).toEqual(legacy.output);
277
+ expect(batched.output.processIds).toEqual({ containerId: 'cccc3333' });
278
+ });
279
+
280
+ it('still records a removal the batch observed', () => {
281
+ withFakeDocker('batch', () => {
282
+ const { store, records } = populatedStore();
283
+ const status = stable(
284
+ queryStatus(store, records.gone.uuid, 'json').output
285
+ );
286
+ expect(status.containerState).toBe('removed');
287
+ expect(store.get(records.gone.uuid).containerState).toBe('removed');
288
+ });
289
+ });
290
+ });
291
+
292
+ describe('issue #243: batched inspect answers', () => {
293
+ const row = (name, id) => `/${name} ${id} 7 true 0 false s f`;
294
+
295
+ it('matches lines by name or ID prefix, not by order', () => {
296
+ const answers = parseBatchInspect(
297
+ ['beta', 'aaaa'],
298
+ `${row('alpha', 'aaaa9999')}\n${row('beta', 'bbbb0000')}\n`,
299
+ ''
300
+ );
301
+ expect(answers.get('beta')['.Id']).toBe('bbbb0000');
302
+ expect(answers.get('aaaa')['.Name']).toBe('/alpha');
303
+ });
304
+
305
+ it("takes docker's not-found over another container's ID prefix", () => {
306
+ const answers = parseBatchInspect(
307
+ ['bbbb', 'gone'],
308
+ `${row('beta', 'bbbb0000')}\n`,
309
+ 'Error response from daemon: No such container: gone\n'
310
+ );
311
+ expect(answers.get('gone')).toBeNull();
312
+ expect(answers.has('bbbb')).toBe(true);
313
+ });
314
+
315
+ it('leaves targets it cannot account for to their own probe', () => {
316
+ const answers = parseBatchInspect(
317
+ ['x'],
318
+ 'false 1 false\n',
319
+ 'unknown flag: --type'
320
+ );
321
+ expect(answers.has('x')).toBe(false);
322
+ });
323
+
324
+ posixIt(
325
+ 'renders the per-record templates from the batch only inside a query',
326
+ () => {
327
+ expect(prefetchedInspect('{{.Id}}', 'kept-c')).toBeNull();
328
+ withFakeDocker('batch', () => {
329
+ const docker = {
330
+ dockerCommand: process.env.START_DOCKER_BIN,
331
+ spawnOptions: { encoding: 'utf8' },
332
+ };
333
+ withDockerInspectBatch(
334
+ [dockerRecord('kept-c'), dockerRecord('nope')],
335
+ docker,
336
+ () => {
337
+ expect(
338
+ prefetchedInspect('{{.Id}} {{.State.Pid}}', 'kept-c')
339
+ ).toEqual({
340
+ success: true,
341
+ status: 0,
342
+ stdout: 'cccc3333 0\n',
343
+ stderr: '',
344
+ });
345
+ expect(prefetchedInspect('{{.Config.Image}}', 'kept-c')).toBeNull();
346
+ expect(prefetchedInspect('{{.Id}}', 'nope').stderr).toMatch(
347
+ /No such container: nope/
348
+ );
349
+ expect(prefetchedInspect('{{.Id}}', 'unrelated')).toBeNull();
350
+ }
351
+ );
352
+ });
353
+ expect(prefetchedInspect('{{.Id}}', 'kept-c')).toBeNull();
354
+ }
355
+ );
356
+ });
357
+
358
+ describe('issue #243: the store is decoded once per content', () => {
359
+ /** Number of store decodes `fn` caused, from the store's verbose log. */
360
+ function countDecodes(fn) {
361
+ const original = console.log;
362
+ let count = 0;
363
+ console.log = (message, ...rest) => {
364
+ if (/^\[ExecutionStore\] Decoded the store/.test(String(message))) {
365
+ count += 1;
366
+ return;
367
+ }
368
+ if (!/^\[ExecutionStore\]/.test(String(message))) {
369
+ original(message, ...rest);
370
+ }
371
+ };
372
+ try {
373
+ fn();
374
+ } finally {
375
+ console.log = original;
376
+ }
377
+ return count;
378
+ }
379
+
380
+ it('decodes once for every read of an unchanged file', () => {
381
+ const store = new ExecutionStore({
382
+ appFolder: makeTempDir('store-243-'),
383
+ useLinks: false,
384
+ verbose: true,
385
+ });
386
+ const record = new ExecutionRecord({ command: 'echo one', exitCode: 0 });
387
+ countDecodes(() => store.save(record));
388
+ forgetDecodedStore();
389
+ const decodes = countDecodes(() => {
390
+ expect(queryStatus(store, record.uuid, 'json').success).toBe(true);
391
+ store.getAll();
392
+ store.get(record.uuid);
393
+ });
394
+ expect(decodes).toBe(1);
395
+ });
396
+
397
+ it('sees every write and hands out independent copies', () => {
398
+ const appFolder = makeTempDir('store-243-');
399
+ const store = new ExecutionStore({ appFolder, useLinks: false });
400
+ store.save(new ExecutionRecord({ command: 'echo one', exitCode: 0 }));
401
+ const first = store.getAll();
402
+ first[0].command = 'mutated';
403
+ first[0].options.isolated = 'mutated';
404
+ expect(store.getAll()[0].command).toBe('echo one');
405
+ expect(store.getAll()[0].options.isolated).toBeUndefined();
406
+
407
+ // Another store object (as another process would) writes the file.
408
+ const other = new ExecutionStore({ appFolder, useLinks: false });
409
+ other.save(new ExecutionRecord({ command: 'echo two', exitCode: 0 }));
410
+ expect(
411
+ store
412
+ .getAll()
413
+ .map((r) => r.command)
414
+ .sort()
415
+ ).toEqual(['echo one', 'echo two']);
416
+ });
417
+
418
+ it('keys the cache on content, not on a clock', () => {
419
+ let decodes = 0;
420
+ const decode = (content) => {
421
+ decodes += 1;
422
+ return JSON.parse(content);
423
+ };
424
+ const file = '/virtual/executions.lino';
425
+ forgetDecodedStore(file);
426
+ expect(decodeStoreContent(file, '[1]', decode).data).toEqual([1]);
427
+ expect(decodeStoreContent(file, '[1]', decode).reused).toBe(true);
428
+ // Same size, same instant: still decoded again.
429
+ expect(decodeStoreContent(file, '[2]', decode).data).toEqual([2]);
430
+ expect(decodes).toBe(2);
431
+ expect(decodeCount(file)).toBe(2);
432
+ forgetDecodedStore(file);
433
+ });
434
+ });
435
+
436
+ describe('issue #243: the next process reads the decoded JSON copy', () => {
437
+ function storeWithOneRecord() {
438
+ const appFolder = makeTempDir('store-243-');
439
+ const store = new ExecutionStore({ appFolder, useLinks: false });
440
+ store.save(
441
+ new ExecutionRecord({
442
+ command: 'echo one',
443
+ exitCode: 0,
444
+ options: { isolated: 'docker', nested: { list: [1, 'a', null] } },
445
+ })
446
+ );
447
+ return { appFolder, store };
448
+ }
449
+
450
+ it('reads the copy instead of decoding when the store is unchanged', () => {
451
+ const { appFolder, store } = storeWithOneRecord();
452
+ const decoded = store.getAll();
453
+ const copy = JSON.parse(
454
+ fs.readFileSync(path.join(appFolder, DECODED_COPY_FILE), 'utf8')
455
+ );
456
+ expect(copy.format).toBe(DECODED_COPY_FORMAT);
457
+ expect(copy.sha256).toMatch(/^[0-9a-f]{64}$/);
458
+
459
+ // A new process starts with an empty in-memory cache.
460
+ forgetDecodedStore();
461
+ expect(store.getAll()).toEqual(decoded);
462
+ expect(decodeCount(store.linoDbPath)).toBe(0);
463
+ });
464
+
465
+ it('decodes again when the copy is of other content or damaged', () => {
466
+ const { appFolder, store } = storeWithOneRecord();
467
+ store.getAll();
468
+ const copyPath = path.join(appFolder, DECODED_COPY_FILE);
469
+ const stale = fs.readFileSync(copyPath, 'utf8');
470
+ new ExecutionStore({ appFolder, useLinks: false }).save(
471
+ new ExecutionRecord({ command: 'echo two', exitCode: 0 })
472
+ );
473
+ fs.writeFileSync(copyPath, stale);
474
+ forgetDecodedStore();
475
+ expect(store.getAll().map((r) => r.command)).toContain('echo two');
476
+ expect(decodeCount(store.linoDbPath)).toBe(1);
477
+
478
+ fs.writeFileSync(copyPath, '{not json');
479
+ forgetDecodedStore();
480
+ expect(store.getAll()).toHaveLength(2);
481
+ expect(decodeCount(store.linoDbPath)).toBe(1);
482
+ });
483
+
484
+ it('keeps no copy of records JSON cannot represent exactly', () => {
485
+ const copyPath = path.join(makeTempDir('store-243-'), 'copy.json');
486
+ const file = '/virtual/unrepresentable.lino';
487
+ decodeStoreContent(file, 'x', () => [{ when: new Date(0) }], {
488
+ copyPath,
489
+ });
490
+ expect(fs.existsSync(copyPath)).toBe(false);
491
+ forgetDecodedStore(file);
492
+ });
493
+ });