start-command 0.33.0 → 0.34.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.
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Regression tests for issue #174: the detached docker watcher `docker rm -f`ed
3
+ * a still-running container when `docker logs -f` failed (ENOSPC) and
4
+ * finalized it as executed / exit 0.
5
+ *
6
+ * - `docker logs -f C >> LOG` also returns when its own write to LOG fails,
7
+ * and on a dockerd restart; the watcher must wait for the real exit;
8
+ * - a container that is still running must never be removed, get an
9
+ * `Exit Code:` footer, or be finalized;
10
+ * - `ExitCode=0` next to a zero `FinishedAt` is Docker's zero value, never an
11
+ * observed exit 0;
12
+ * - a child killed by a signal (`code === null`) must never become exit 0.
13
+ */
14
+
15
+ const { describe, it, expect, afterEach } = require('bun:test');
16
+ const { spawnSync } = require('child_process');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+
21
+ const {
22
+ ExecutionStore,
23
+ ExecutionRecord,
24
+ ExecutionStatus,
25
+ } = require('../src/lib/execution-store');
26
+ const {
27
+ buildDetachedDockerCompletionScript,
28
+ DOCKER_CONTAINER_CLEANUP_POLICY,
29
+ } = require('../src/lib/docker-cleanup');
30
+ const {
31
+ LOG_CAPTURE_STOPPED_NOTE,
32
+ STILL_RUNNING_NOTE,
33
+ } = require('../src/lib/docker-post-mortem');
34
+ const {
35
+ WATCHER_LOST_CONTAINER,
36
+ finalizeDetachedExecution,
37
+ } = require('../src/lib/detached-finalize');
38
+ const { resolveChildExitCode } = require('../src/lib/exit-reason');
39
+ const { runWithNodeSpawn } = require('../src/lib/spawn-helpers');
40
+
41
+ const tempDirs = [];
42
+
43
+ function makeTempDir(prefix) {
44
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
45
+ tempDirs.push(dir);
46
+ return dir;
47
+ }
48
+
49
+ afterEach(() => {
50
+ while (tempDirs.length > 0) {
51
+ const dir = tempDirs.pop();
52
+ fs.rmSync(dir, { recursive: true, force: true });
53
+ }
54
+ });
55
+
56
+ function detachedDockerRecord(logPath) {
57
+ return new ExecutionRecord({
58
+ status: ExecutionStatus.EXECUTING,
59
+ command: 'work',
60
+ logPath,
61
+ options: {
62
+ isolated: 'docker',
63
+ isolationMode: 'detached',
64
+ sessionName: 'start-command-174',
65
+ },
66
+ });
67
+ }
68
+
69
+ // The watcher body is a POSIX `sh` script; Windows has no `/bin/sh`.
70
+ const needsPosixShell = () => {
71
+ if (process.platform !== 'win32') {
72
+ return false;
73
+ }
74
+ console.log(' Skipping: the watcher needs a POSIX /bin/sh');
75
+ return true;
76
+ };
77
+
78
+ /**
79
+ * Run the real watcher script against a scripted fake `docker`.
80
+ *
81
+ * `running` lists the successive answers to `docker inspect -f
82
+ * '{{.State.Running}}'` (`true`, `false`, or `fail` for an inspect that
83
+ * errors, as during a dockerd restart); the last answer sticks. `docker logs`
84
+ * writes one line and fails, exactly like `docker logs -f C >> LOG` whose write
85
+ * hit ENOSPC. Every call is recorded, and `rm` records whether the container was
86
+ * running at that moment.
87
+ */
88
+ function runWatcher({ policy, running, state, withLog = true }) {
89
+ const dir = makeTempDir('watcher-174-');
90
+ const binDir = path.join(dir, 'bin');
91
+ const appFolder = path.join(dir, 'app');
92
+ fs.mkdirSync(binDir);
93
+ const logPath = path.join(dir, 'run.log');
94
+ fs.writeFileSync(path.join(dir, 'running'), `${running.join('\n')}\n`);
95
+ fs.writeFileSync(path.join(dir, 'state'), `${state}\n`);
96
+ const calls = path.join(dir, 'calls');
97
+ const dockerPath = path.join(binDir, 'docker');
98
+ fs.writeFileSync(
99
+ dockerPath,
100
+ [
101
+ '#!/bin/sh',
102
+ `dir='${dir}'`,
103
+ 'case "$1" in',
104
+ ' inspect)',
105
+ ' case "$3" in',
106
+ ' *State.Running*)',
107
+ ' answer=$(head -n 1 "$dir/running")',
108
+ ' if [ "$(wc -l < "$dir/running")" -gt 1 ]; then',
109
+ ' tail -n +2 "$dir/running" > "$dir/running.next" && mv "$dir/running.next" "$dir/running"',
110
+ ' fi',
111
+ ' echo "inspect-running $answer" >> "$dir/calls"',
112
+ ' [ "$answer" = fail ] && exit 1',
113
+ ' echo "$answer" > "$dir/current"',
114
+ ' echo "$answer" ;;',
115
+ ' *State.Error*) echo "" ;;',
116
+ ' *) echo "inspect-state" >> "$dir/calls"; cat "$dir/state" ;;',
117
+ ' esac ;;',
118
+ ' logs) echo logs >> "$dir/calls"; echo work; exit 1 ;;',
119
+ ' wait) echo wait >> "$dir/calls"; echo 0 ;;',
120
+ ' rm) echo "rm running=$(cat "$dir/current" 2>/dev/null)" >> "$dir/calls" ;;',
121
+ 'esac',
122
+ '',
123
+ ].join('\n'),
124
+ 'utf8'
125
+ );
126
+ fs.chmodSync(dockerPath, 0o755);
127
+
128
+ const store = new ExecutionStore({ appFolder, useLinks: false });
129
+ const record = detachedDockerRecord(withLog ? logPath : null);
130
+ store.save(record);
131
+
132
+ const script = buildDetachedDockerCompletionScript(
133
+ 'demo',
134
+ policy,
135
+ withLog ? logPath : null,
136
+ record.uuid
137
+ );
138
+ const result = spawnSync('/bin/sh', ['-c', script], {
139
+ encoding: 'utf8',
140
+ env: {
141
+ ...process.env,
142
+ PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
143
+ START_APP_FOLDER: appFolder,
144
+ START_DISABLE_TRACKING: '',
145
+ },
146
+ timeout: 20000,
147
+ });
148
+ return {
149
+ status: result.status,
150
+ calls: fs.readFileSync(calls, 'utf8').trim().split('\n'),
151
+ log: fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '',
152
+ record: new ExecutionStore({ appFolder, useLinks: false }).get(record.uuid),
153
+ };
154
+ }
155
+
156
+ const FINISHED_7 =
157
+ '7 false 2026-09-27T10:00:00.000000000Z 2026-09-27T10:00:05.000000000Z';
158
+ const FINISHED_0 =
159
+ '0 false 2026-09-27T10:00:00.000000000Z 2026-09-27T10:00:05.000000000Z';
160
+ const NOT_FINISHED = '0 false 2026-09-27T10:00:00Z 0001-01-01T00:00:00Z';
161
+
162
+ describe('issue #174: docker logs -f returning is not the container exiting', () => {
163
+ it('waits for the real exit after docker logs -f fails (ENOSPC)', () => {
164
+ if (needsPosixShell()) {
165
+ return;
166
+ }
167
+ const run = runWatcher({
168
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
169
+ running: ['true', 'true', 'false'],
170
+ state: FINISHED_7,
171
+ });
172
+
173
+ expect(run.status).toBe(0);
174
+ expect(run.calls).toContain('wait');
175
+ expect(run.calls.indexOf('wait')).toBeGreaterThan(
176
+ run.calls.indexOf('logs')
177
+ );
178
+ expect(run.calls.indexOf('inspect-state')).toBeGreaterThan(
179
+ run.calls.lastIndexOf('wait')
180
+ );
181
+ expect(run.log).toContain(LOG_CAPTURE_STOPPED_NOTE);
182
+ expect(run.log).toContain('Exit Code: 7');
183
+ expect(run.log).not.toContain('Exit Code: 0');
184
+ expect(run.record.status).toBe(ExecutionStatus.EXECUTED);
185
+ expect(run.record.exitCode).toBe(7);
186
+ expect(run.record.endTimeSource).toBe('docker-finished-at');
187
+ });
188
+
189
+ it('only removes the container once it has really exited', () => {
190
+ if (needsPosixShell()) {
191
+ return;
192
+ }
193
+ const run = runWatcher({
194
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
195
+ running: ['true', 'true', 'false'],
196
+ state: FINISHED_0,
197
+ });
198
+
199
+ expect(run.calls).toContain('rm running=false');
200
+ expect(run.calls).not.toContain('rm running=true');
201
+ expect(run.log).toContain('Exit Code: 0');
202
+ expect(run.record.exitCode).toBe(0);
203
+ });
204
+
205
+ it('keeps waiting across a failed docker inspect (dockerd restart)', () => {
206
+ if (needsPosixShell()) {
207
+ return;
208
+ }
209
+ const run = runWatcher({
210
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
211
+ running: ['true', 'true', 'true', 'false'],
212
+ state: FINISHED_7,
213
+ });
214
+
215
+ expect(run.calls.filter((call) => call === 'wait').length).toBe(2);
216
+ expect(run.record.exitCode).toBe(7);
217
+ });
218
+
219
+ it('applies the same wait to the no-log watcher', () => {
220
+ if (needsPosixShell()) {
221
+ return;
222
+ }
223
+ const run = runWatcher({
224
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
225
+ running: ['true', 'false'],
226
+ state: FINISHED_7,
227
+ withLog: false,
228
+ });
229
+
230
+ expect(run.calls.filter((call) => call === 'wait').length).toBe(2);
231
+ expect(run.calls).toContain('rm running=false');
232
+ expect(run.calls).not.toContain('rm running=true');
233
+ expect(run.record.exitCode).toBe(7);
234
+ });
235
+ });
236
+
237
+ describe('issue #174: a container that is still running is left alone', () => {
238
+ it('never removes, footers or finalizes a running container', () => {
239
+ if (needsPosixShell()) {
240
+ return;
241
+ }
242
+ // logs -f fails -> running; the wait loop's inspect fails (daemon down) ->
243
+ // the loop ends; once the daemon is back the container is still running.
244
+ const run = runWatcher({
245
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
246
+ running: ['true', 'fail', 'true'],
247
+ state: NOT_FINISHED,
248
+ });
249
+
250
+ expect(run.calls.some((call) => call.startsWith('rm'))).toBe(false);
251
+ expect(run.log).toContain('Container still running: demo');
252
+ expect(run.log).toContain(STILL_RUNNING_NOTE);
253
+ expect(run.log).not.toContain('Exit Code:');
254
+ expect(run.record.status).toBe(ExecutionStatus.EXECUTING);
255
+ expect(run.record.exitCode).toBeNull();
256
+ });
257
+
258
+ it('never removes a running container without a log either', () => {
259
+ if (needsPosixShell()) {
260
+ return;
261
+ }
262
+ const run = runWatcher({
263
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
264
+ running: ['fail', 'true'],
265
+ state: NOT_FINISHED,
266
+ withLog: false,
267
+ });
268
+
269
+ expect(run.calls.some((call) => call.startsWith('rm'))).toBe(false);
270
+ expect(run.record.status).toBe(ExecutionStatus.EXECUTING);
271
+ });
272
+ });
273
+
274
+ describe('issue #174: a zero FinishedAt is never an exit 0', () => {
275
+ it('keeps the container and records no success in the watcher', () => {
276
+ if (needsPosixShell()) {
277
+ return;
278
+ }
279
+ const run = runWatcher({
280
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
281
+ running: ['false'],
282
+ state: NOT_FINISHED,
283
+ });
284
+
285
+ expect(run.calls.some((call) => call.startsWith('rm'))).toBe(false);
286
+ expect(run.log).toContain('Exit Code: -1');
287
+ expect(run.log).not.toContain('Exit Code: 0');
288
+ expect(run.record.status).toBe(ExecutionStatus.EXECUTED);
289
+ expect(run.record.exitCode).toBe(-1);
290
+ expect(run.record.exitReason).toBe(WATCHER_LOST_CONTAINER);
291
+ expect(run.record.endTimeSource).toBe('observed-at');
292
+ });
293
+
294
+ it('refuses to finalize a record whose container is still running', () => {
295
+ const appFolder = makeTempDir('finalize-174-running-');
296
+ const store = new ExecutionStore({ appFolder, useLinks: false });
297
+ const record = detachedDockerRecord(null);
298
+ store.save(record);
299
+
300
+ const outcome = finalizeDetachedExecution({
301
+ store,
302
+ executionId: record.uuid,
303
+ exitCode: '0',
304
+ oomKilled: 'false',
305
+ startedAt: '2026-09-27T10:00:00Z',
306
+ finishedAt: '0001-01-01T00:00:00Z',
307
+ containerError: '',
308
+ running: 'true',
309
+ });
310
+
311
+ expect(outcome).toEqual({ updated: false, reason: 'still-running' });
312
+ expect(store.get(record.uuid).status).toBe(ExecutionStatus.EXECUTING);
313
+ });
314
+
315
+ it('does not trust exit 0 without a FinishedAt', () => {
316
+ const appFolder = makeTempDir('finalize-174-zero-');
317
+ const store = new ExecutionStore({ appFolder, useLinks: false });
318
+ const record = detachedDockerRecord(null);
319
+ store.save(record);
320
+
321
+ finalizeDetachedExecution({
322
+ store,
323
+ executionId: record.uuid,
324
+ exitCode: '0',
325
+ oomKilled: 'false',
326
+ startedAt: '2026-09-27T10:00:00Z',
327
+ finishedAt: '0001-01-01T00:00:00Z',
328
+ running: 'false',
329
+ });
330
+
331
+ const stored = store.get(record.uuid);
332
+ expect(stored.exitCode).toBe(-1);
333
+ expect(stored.exitReason).toBe(WATCHER_LOST_CONTAINER);
334
+ });
335
+
336
+ it('still records a real exit 0', () => {
337
+ const appFolder = makeTempDir('finalize-174-real-zero-');
338
+ const store = new ExecutionStore({ appFolder, useLinks: false });
339
+ const record = detachedDockerRecord(null);
340
+ store.save(record);
341
+
342
+ finalizeDetachedExecution({
343
+ store,
344
+ executionId: record.uuid,
345
+ exitCode: '0',
346
+ oomKilled: 'false',
347
+ startedAt: '2026-09-27T10:00:00Z',
348
+ finishedAt: '2026-09-27T10:00:05Z',
349
+ running: 'false',
350
+ });
351
+
352
+ const stored = store.get(record.uuid);
353
+ expect(stored.exitCode).toBe(0);
354
+ expect(stored.exitReason).toBeUndefined();
355
+ expect(stored.endTimeSource).toBe('docker-finished-at');
356
+ });
357
+ });
358
+
359
+ describe('issue #174: a signal death is never exit 0', () => {
360
+ it('maps a null code with a signal to 128+n', () => {
361
+ expect(resolveChildExitCode(null, 'SIGKILL')).toBe(137);
362
+ expect(resolveChildExitCode(null, 'SIGTERM')).toBe(143);
363
+ expect(resolveChildExitCode(null, 'SIGINT')).toBe(130);
364
+ });
365
+
366
+ it('maps a missing code without a signal to a failure', () => {
367
+ expect(resolveChildExitCode(null)).toBe(1);
368
+ expect(resolveChildExitCode(undefined)).toBe(1);
369
+ expect(resolveChildExitCode(null, 'SIGNOTREAL')).toBe(1);
370
+ });
371
+
372
+ it('keeps real exit codes, including 0', () => {
373
+ expect(resolveChildExitCode(0)).toBe(0);
374
+ expect(resolveChildExitCode(3, null)).toBe(3);
375
+ });
376
+
377
+ it('records a self-killed child as 137 in the node spawn path', async () => {
378
+ if (needsPosixShell()) {
379
+ return;
380
+ }
381
+ const dir = makeTempDir('spawn-174-');
382
+ const logFilePath = path.join(dir, 'run.log');
383
+ const exitCode = await new Promise((resolve) => {
384
+ runWithNodeSpawn({
385
+ shell: '/bin/sh',
386
+ shellArgs: ['-c', 'kill -KILL $$'],
387
+ logFilePath,
388
+ startTimeMs: Date.now(),
389
+ onComplete: (code) => resolve(code),
390
+ onError: () => resolve('error'),
391
+ });
392
+ });
393
+
394
+ expect(exitCode).toBe(137);
395
+ expect(fs.readFileSync(logFilePath, 'utf8')).toContain('Exit Code: 137');
396
+ });
397
+ });