start-command 0.35.0 → 0.35.2

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.
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Regression tests for issue #182:
3
+ *
4
+ * Docker's `State.OOMKilled` is container-wide and sticky (moby/moby#43564):
5
+ * it cannot say how many processes the OOM killer took, whether the
6
+ * container hit its own `--memory` limit or the whole host ran out, or how
7
+ * close the run came to its limit. The kernel counts all of that per cgroup
8
+ * (`memory.events` `oom`/`oom_kill`, `memory.peak`, `memory.max`), but the
9
+ * cgroup is gone once the container stops.
10
+ *
11
+ * The detached watcher now samples the container's cgroup v2 counters while
12
+ * it runs, writes a `Memory:` line into the post-mortem, stores them as
13
+ * `cgroupMemory` in the execution record (shown by `--status`), and hands
14
+ * them to the kill recovery, which notes them in `recoveryHistory`.
15
+ */
16
+
17
+ const { describe, it, expect, afterEach } = require('bun:test');
18
+ const { spawnSync } = require('child_process');
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const os = require('os');
22
+
23
+ const {
24
+ OOM_SCOPE,
25
+ buildCgroupMemoryLogSnippet,
26
+ buildCgroupSamplerStartSnippet,
27
+ buildCgroupSamplerStopSnippet,
28
+ describeCgroupOomScope,
29
+ formatCgroupMemory,
30
+ formatCgroupMemoryLogLine,
31
+ parseCgroupMemorySample,
32
+ } = require('../src/lib/cgroup-memory');
33
+ const {
34
+ buildDetachedDockerCompletionScript,
35
+ DOCKER_CONTAINER_CLEANUP_POLICY,
36
+ } = require('../src/lib/docker-cleanup');
37
+ const {
38
+ buildDetachedFinalizeSnippet,
39
+ finalizeDetachedExecution,
40
+ } = require('../src/lib/detached-finalize');
41
+ const {
42
+ buildRecoverySnippet,
43
+ recoverKilledExecution,
44
+ } = require('../src/lib/execution-recovery');
45
+ const {
46
+ ExecutionStore,
47
+ ExecutionRecord,
48
+ ExecutionStatus,
49
+ } = require('../src/lib/execution-store');
50
+ const { formatRecordAsText } = require('../src/lib/status-formatter');
51
+
52
+ const tempDirs = [];
53
+
54
+ function makeTempDir(prefix) {
55
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
56
+ tempDirs.push(dir);
57
+ return dir;
58
+ }
59
+
60
+ afterEach(() => {
61
+ while (tempDirs.length > 0) {
62
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ // The watcher body is a POSIX `sh` script; Windows has no `/bin/sh`.
67
+ const needsPosixShell = () => {
68
+ if (process.platform !== 'win32') {
69
+ return false;
70
+ }
71
+ console.log(' Skipping: the watcher needs a POSIX /bin/sh');
72
+ return true;
73
+ };
74
+
75
+ const CONTAINER_ID = `182c${'0'.repeat(60)}`;
76
+ const HOST_OOM_SAMPLE = '268435456 268300000 0 3';
77
+ const HOST_OOM = {
78
+ limitBytes: 268435456,
79
+ peakBytes: 268300000,
80
+ oomEvents: 0,
81
+ oomKills: 3,
82
+ };
83
+
84
+ /**
85
+ * A fake cgroup v2 tree, `/proc` and `docker`. `docker logs` runs `during`
86
+ * (a shell snippet) after 1.5s, so the sampler has taken a sample by then.
87
+ */
88
+ function makeFakeHost({ events, max = '268435456', peak = '268300000' }) {
89
+ const dir = makeTempDir('cgroup-182-');
90
+ const cgroupRoot = path.join(dir, 'cgroup');
91
+ const scope = path.join(
92
+ cgroupRoot,
93
+ 'system.slice',
94
+ `docker-${CONTAINER_ID}.scope`
95
+ );
96
+ fs.mkdirSync(scope, { recursive: true });
97
+ fs.writeFileSync(path.join(scope, 'memory.events'), events);
98
+ fs.writeFileSync(path.join(scope, 'memory.max'), `${max}\n`);
99
+ if (peak !== null) {
100
+ fs.writeFileSync(path.join(scope, 'memory.peak'), `${peak}\n`);
101
+ }
102
+ const procRoot = path.join(dir, 'proc');
103
+ fs.mkdirSync(path.join(procRoot, '4242'), { recursive: true });
104
+ fs.writeFileSync(
105
+ path.join(procRoot, '4242', 'cgroup'),
106
+ `0::/system.slice/docker-${CONTAINER_ID}.scope\n`
107
+ );
108
+ const binDir = path.join(dir, 'bin');
109
+ fs.mkdirSync(binDir);
110
+ return {
111
+ dir,
112
+ scope,
113
+ binDir,
114
+ env: {
115
+ ...process.env,
116
+ PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
117
+ START_COMMAND_CGROUP_ROOT: cgroupRoot,
118
+ START_COMMAND_PROC_ROOT: procRoot,
119
+ TMPDIR: dir,
120
+ },
121
+ };
122
+ }
123
+
124
+ function writeFakeDocker(host, { state, during }) {
125
+ fs.writeFileSync(path.join(host.dir, 'during'), `${during}\n`);
126
+ const dockerPath = path.join(host.binDir, 'docker');
127
+ fs.writeFileSync(
128
+ dockerPath,
129
+ [
130
+ '#!/bin/sh',
131
+ `dir='${host.dir}'`,
132
+ 'case "$1" in',
133
+ ' inspect)',
134
+ ' case "$3" in',
135
+ ` *'{{.Id}}'*) echo ${CONTAINER_ID} ;;`,
136
+ ' *State.Pid*) echo 4242 ;;',
137
+ ' *State.Running*) echo false ;;',
138
+ ' *State.Error*) echo "" ;;',
139
+ ` *) echo '${state}' ;;`,
140
+ ' esac ;;',
141
+ ' logs) echo work; sleep 1.5; sh "$dir/during" ;;',
142
+ ' rm) echo removed ;;',
143
+ 'esac',
144
+ '',
145
+ ].join('\n'),
146
+ 'utf8'
147
+ );
148
+ fs.chmodSync(dockerPath, 0o755);
149
+ }
150
+
151
+ const KILLED_STATE =
152
+ '137 true 2026-10-01T10:00:00.000000000Z 2026-10-01T10:05:00.000000000Z';
153
+
154
+ function runWatcher({ events, during, policy, state = KILLED_STATE, max }) {
155
+ const host = makeFakeHost({ events, max });
156
+ writeFakeDocker(host, { state, during: during(host) });
157
+ const appFolder = path.join(host.dir, 'app');
158
+ const store = new ExecutionStore({ appFolder, useLinks: false });
159
+ const logPath = path.join(host.dir, 'run.log');
160
+ const record = new ExecutionRecord({
161
+ status: ExecutionStatus.EXECUTING,
162
+ command: 'cargo test',
163
+ logPath,
164
+ options: {
165
+ isolated: 'docker',
166
+ isolationMode: 'detached',
167
+ sessionName: 'box',
168
+ },
169
+ });
170
+ store.save(record);
171
+ const result = spawnSync(
172
+ '/bin/sh',
173
+ [
174
+ '-c',
175
+ buildDetachedDockerCompletionScript('box', policy, logPath, record.uuid),
176
+ ],
177
+ {
178
+ encoding: 'utf8',
179
+ env: {
180
+ ...host.env,
181
+ START_APP_FOLDER: appFolder,
182
+ START_DISABLE_TRACKING: '',
183
+ },
184
+ timeout: 20000,
185
+ }
186
+ );
187
+ return {
188
+ status: result.status,
189
+ log: fs.readFileSync(logPath, 'utf8'),
190
+ record: new ExecutionStore({ appFolder, useLinks: false }).get(record.uuid),
191
+ leftovers: fs
192
+ .readdirSync(host.dir)
193
+ .filter((name) => name.startsWith('start-command-cgroup')),
194
+ };
195
+ }
196
+
197
+ describe('issue #182: reading cgroup v2 memory counters', () => {
198
+ it('parses the sample the watcher writes', () => {
199
+ expect(parseCgroupMemorySample(HOST_OOM_SAMPLE)).toEqual(HOST_OOM);
200
+ expect(parseCgroupMemorySample('max - 1 1\n')).toEqual({
201
+ limitBytes: null,
202
+ peakBytes: null,
203
+ oomEvents: 1,
204
+ oomKills: 1,
205
+ });
206
+ for (const empty of ['', null, undefined, '- - - -', '1 2 3']) {
207
+ expect(parseCgroupMemorySample(empty)).toBeNull();
208
+ }
209
+ });
210
+
211
+ it('tells a container limit from a host-wide or parent OOM', () => {
212
+ expect(describeCgroupOomScope(HOST_OOM)).toBe(OOM_SCOPE.HOST_OR_PARENT);
213
+ expect(describeCgroupOomScope({ oomEvents: 2, oomKills: 2 })).toBe(
214
+ OOM_SCOPE.CONTAINER_LIMIT
215
+ );
216
+ expect(describeCgroupOomScope({ oomEvents: 4, oomKills: 0 })).toBeNull();
217
+ expect(describeCgroupOomScope(null)).toBeNull();
218
+ });
219
+
220
+ it('formats the counters for --status and for the log', () => {
221
+ expect(formatCgroupMemory(HOST_OOM)).toBe(
222
+ 'peak 255.9 MiB of 256.0 MiB limit, oom 0, oom_kill 3 ' +
223
+ '(oom_kill > oom: a host-wide or parent cgroup OOM killed processes here)'
224
+ );
225
+ expect(
226
+ formatCgroupMemory({
227
+ limitBytes: null,
228
+ peakBytes: null,
229
+ oomEvents: 1,
230
+ oomKills: 1,
231
+ })
232
+ ).toBe(
233
+ 'peak unknown of no limit, oom 1, oom_kill 1 (the container hit its own memory limit)'
234
+ );
235
+ expect(formatCgroupMemoryLogLine('max 1024 0 0')).toBe(
236
+ 'Memory: memory.max=max memory.peak=1024 oom=0 oom_kill=0'
237
+ );
238
+ });
239
+ });
240
+
241
+ describe('issue #182: the watcher samples the cgroup while the container runs', () => {
242
+ it('starts the sampler first and stops it before reading the state', () => {
243
+ for (const logPath of ['/tmp/run.log', null]) {
244
+ const script = buildDetachedDockerCompletionScript(
245
+ 'box',
246
+ DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
247
+ logPath,
248
+ 'uuid-182',
249
+ { recoverOnKill: true }
250
+ );
251
+ expect(script.startsWith(buildCgroupSamplerStartSnippet('box'))).toBe(
252
+ true
253
+ );
254
+ const stop = script.indexOf(buildCgroupSamplerStopSnippet());
255
+ expect(stop).toBeGreaterThan(script.indexOf('docker wait'));
256
+ expect(stop).toBeLessThan(script.indexOf('__start_command_state='));
257
+ expect(script).toContain(buildDetachedFinalizeSnippet('uuid-182'));
258
+ expect(script).toContain(buildRecoverySnippet('uuid-182'));
259
+ }
260
+ expect(buildDetachedFinalizeSnippet('u')).toContain(
261
+ '"$__start_command_cgroup" >/dev/null'
262
+ );
263
+ expect(buildRecoverySnippet('u')).toContain(
264
+ '"$__start_command_cgroup" >/dev/null'
265
+ );
266
+ });
267
+
268
+ it('keeps the last sample once the cgroup is gone', () => {
269
+ if (needsPosixShell()) {
270
+ return;
271
+ }
272
+ const host = makeFakeHost({ events: 'oom 0\noom_kill 3\n' });
273
+ writeFakeDocker(host, { state: KILLED_STATE, during: '' });
274
+ const logPath = path.join(host.dir, 'log');
275
+ const script = [
276
+ buildCgroupSamplerStartSnippet('box'),
277
+ 'sleep 1.5',
278
+ `rm -rf '${host.scope}'`,
279
+ buildCgroupSamplerStopSnippet(),
280
+ `printf '%s' "$__start_command_cgroup" > '${host.dir}/sample'`,
281
+ buildCgroupMemoryLogSnippet(`'${logPath}'`),
282
+ ].join('; ');
283
+ const result = spawnSync('/bin/sh', ['-c', script], {
284
+ env: host.env,
285
+ timeout: 20000,
286
+ });
287
+ expect(result.status).toBe(0);
288
+ expect(fs.readFileSync(path.join(host.dir, 'sample'), 'utf8')).toBe(
289
+ HOST_OOM_SAMPLE
290
+ );
291
+ expect(fs.readFileSync(logPath, 'utf8')).toBe(
292
+ `${formatCgroupMemoryLogLine(HOST_OOM_SAMPLE)}\n`
293
+ );
294
+ });
295
+
296
+ it('writes the counters into the post-mortem and the record', () => {
297
+ if (needsPosixShell()) {
298
+ return;
299
+ }
300
+ const run = runWatcher({
301
+ events: 'low 0\nhigh 0\nmax 9\noom 1\noom_kill 1\n',
302
+ // The cgroup outlives the main process for a moment: the final read
303
+ // still sees the kills of the last second.
304
+ during: (host) =>
305
+ `printf 'oom 1\\noom_kill 3\\n' > '${host.scope}/memory.events'`,
306
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
307
+ });
308
+ expect(run.status).toBe(0);
309
+ expect(run.log).toContain('Exit Code: 137 (SIGKILL - 128+9)');
310
+ expect(run.log).toContain(
311
+ 'Memory: memory.max=268435456 memory.peak=268300000 oom=1 oom_kill=3 ' +
312
+ '(oom_kill > oom: a host-wide or parent cgroup OOM killed processes here)'
313
+ );
314
+ expect(run.record.status).toBe(ExecutionStatus.EXECUTED);
315
+ expect(run.record.cgroupMemory).toEqual({
316
+ limitBytes: 268435456,
317
+ peakBytes: 268300000,
318
+ oomEvents: 1,
319
+ oomKills: 3,
320
+ });
321
+ expect(run.leftovers).toEqual([]);
322
+ expect(formatRecordAsText(run.record)).toContain(
323
+ 'Cgroup Memory: peak 255.9 MiB of 256.0 MiB limit, oom 1, oom_kill 3'
324
+ );
325
+ });
326
+
327
+ it('notes the counters of a removed container too', () => {
328
+ if (needsPosixShell()) {
329
+ return;
330
+ }
331
+ const run = runWatcher({
332
+ events: 'oom 0\noom_kill 0\n',
333
+ max: 'max',
334
+ during: (host) => `rm -rf '${host.scope}'`,
335
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
336
+ state:
337
+ '0 false 2026-10-01T10:00:00.000000000Z 2026-10-01T10:05:00.000000000Z',
338
+ });
339
+ expect(run.log).toContain('Container removed: box');
340
+ expect(run.log).toContain(
341
+ 'Memory: memory.max=max memory.peak=268300000 oom=0 oom_kill=0\n'
342
+ );
343
+ expect(run.record.cgroupMemory).toEqual({
344
+ limitBytes: null,
345
+ peakBytes: 268300000,
346
+ oomEvents: 0,
347
+ oomKills: 0,
348
+ });
349
+ });
350
+
351
+ it('records nothing without a cgroup v2 sample', () => {
352
+ const dir = makeTempDir('finalize-182-');
353
+ const store = new ExecutionStore({ appFolder: dir, useLinks: false });
354
+ const record = new ExecutionRecord({ command: 'x' });
355
+ store.save(record);
356
+ finalizeDetachedExecution({
357
+ store,
358
+ executionId: record.uuid,
359
+ exitCode: '0',
360
+ oomKilled: 'false',
361
+ finishedAt: '2026-10-01T10:05:00Z',
362
+ cgroupMemory: '',
363
+ });
364
+ const stored = store.get(record.uuid);
365
+ expect(stored.cgroupMemory).toBeUndefined();
366
+ expect(stored.toObject()).not.toHaveProperty('cgroupMemory');
367
+ expect(formatRecordAsText(stored)).not.toContain('Cgroup Memory');
368
+ });
369
+
370
+ it('explains an unknown exit with the per-run oom_kill count', () => {
371
+ const dir = makeTempDir('finalize-182-');
372
+ const store = new ExecutionStore({ appFolder: dir, useLinks: false });
373
+ const record = new ExecutionRecord({ command: 'x' });
374
+ store.save(record);
375
+ const { record: finalized } = finalizeDetachedExecution({
376
+ store,
377
+ executionId: record.uuid,
378
+ exitCode: '137',
379
+ oomKilled: 'false',
380
+ finishedAt: '2026-10-01T10:05:00Z',
381
+ cgroupMemory: HOST_OOM_SAMPLE,
382
+ });
383
+ expect(finalized.cgroupMemory).toEqual(HOST_OOM);
384
+ expect(finalized.exitReason).toBeTruthy();
385
+ });
386
+ });
387
+
388
+ describe('issue #182: the kill recovery keeps the killed run counters', () => {
389
+ function ok(stdout = '') {
390
+ return { success: true, stdout, stderr: '', status: 0, error: null };
391
+ }
392
+
393
+ it('logs them and notes them in recoveryHistory', () => {
394
+ const dir = makeTempDir('recovery-182-');
395
+ const store = new ExecutionStore({
396
+ appFolder: path.join(dir, 'app'),
397
+ useLinks: false,
398
+ });
399
+ const logPath = path.join(dir, 'run.log');
400
+ fs.writeFileSync(logPath, 'main output\n');
401
+ const record = new ExecutionRecord({
402
+ status: ExecutionStatus.EXECUTING,
403
+ command: 'cargo test',
404
+ logPath,
405
+ cgroupMemory: HOST_OOM,
406
+ options: {
407
+ isolated: 'docker',
408
+ isolationMode: 'detached',
409
+ sessionName: 'box',
410
+ onKillResume: 2,
411
+ },
412
+ });
413
+ store.save(record);
414
+ const outcome = recoverKilledExecution({
415
+ store,
416
+ executionId: record.uuid,
417
+ exitCode: '137',
418
+ oomKilled: 'true',
419
+ startedAt: '2026-10-01T10:00:00Z',
420
+ finishedAt: '2026-10-01T10:05:00Z',
421
+ containerError: '',
422
+ cgroupMemory: HOST_OOM_SAMPLE,
423
+ runner: (command, args) => ok(args[0] === 'inspect' ? '{}\n' : 'cid\n'),
424
+ startWatcher: () => {},
425
+ now: () => new Date('2026-10-01T10:06:00.000Z'),
426
+ });
427
+ expect(outcome.recovered).toBe(true);
428
+ expect(fs.readFileSync(logPath, 'utf8')).toContain(
429
+ `${formatCgroupMemoryLogLine(HOST_OOM_SAMPLE)}\n\n[Recovery 1/2]`
430
+ );
431
+ const stored = store.get(record.uuid);
432
+ expect(stored.options.recoveryHistory).toEqual([
433
+ '1: exit 137, oomKilled=true, oomEvents=0, oomKills=3, resumed at 2026-10-01T10:06:00.000Z',
434
+ ]);
435
+ // The resumed run gets a fresh cgroup: the old counters must not explain
436
+ // its exit.
437
+ expect(stored.cgroupMemory).toBeUndefined();
438
+ });
439
+ });