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,338 @@
1
+ /**
2
+ * Regression tests for issue #181:
3
+ *
4
+ * `--on-kill-resume <N>` restarted a killed detached docker execution right
5
+ * after its watcher saw it end. One host-wide OOM event that kills several
6
+ * executions made every watcher resume its container in the same second;
7
+ * all of them rebuilt their working sets at once and triggered the next OOM
8
+ * event.
9
+ *
10
+ * `--on-kill-resume-delay <min[-max]>` waits a uniformly random number of
11
+ * seconds before each recovery. The chosen delay is printed in the
12
+ * `[Recovery k/N]` line and stored in `recoveryHistory` (`delayMs`), and a
13
+ * `--stop` during the wait cancels the pending recovery.
14
+ */
15
+
16
+ const { describe, it, expect, afterEach } = require('bun:test');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+
21
+ const { parseArgs } = require('../src/lib/args-parser');
22
+ const {
23
+ buildDockerRuntimeMetadata,
24
+ buildDockerRuntimeStatusLines,
25
+ } = require('../src/lib/docker-runtime-args');
26
+ const {
27
+ formatRecoverySeparator,
28
+ recoverKilledExecution,
29
+ } = require('../src/lib/execution-recovery');
30
+ const {
31
+ ControlAction,
32
+ controlExecution,
33
+ } = require('../src/lib/execution-control');
34
+ const {
35
+ parseOnKillResumeDelayValue,
36
+ pickRecoveryDelayMs,
37
+ sleepSync,
38
+ waitForRecoveryDelay,
39
+ } = require('../src/lib/recovery-delay');
40
+ const {
41
+ ExecutionStore,
42
+ ExecutionRecord,
43
+ ExecutionStatus,
44
+ } = require('../src/lib/execution-store');
45
+
46
+ const tempDirs = [];
47
+
48
+ function makeTempDir(prefix) {
49
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
50
+ tempDirs.push(dir);
51
+ return dir;
52
+ }
53
+
54
+ afterEach(() => {
55
+ while (tempDirs.length > 0) {
56
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ function parse(extra) {
61
+ return parseArgs(['--isolated', 'docker', '--detached', ...extra, '--', 'a'])
62
+ .wrapperOptions;
63
+ }
64
+
65
+ function ok(stdout = '') {
66
+ return { success: true, stdout, stderr: '', status: 0, error: null };
67
+ }
68
+
69
+ function fakeRunner(failures = {}) {
70
+ const calls = [];
71
+ const runner = (command, args) => {
72
+ calls.push([command, ...args]);
73
+ if (failures[args[0]]) {
74
+ return {
75
+ success: false,
76
+ stdout: '',
77
+ stderr: failures[args[0]],
78
+ status: 1,
79
+ error: null,
80
+ };
81
+ }
82
+ if (args[0] === 'inspect') {
83
+ return ok('{}\n');
84
+ }
85
+ return ok('cid\n');
86
+ };
87
+ runner.calls = calls;
88
+ return runner;
89
+ }
90
+
91
+ function makeStore(options = {}) {
92
+ const dir = makeTempDir('recovery-181-');
93
+ const store = new ExecutionStore({
94
+ appFolder: path.join(dir, 'app'),
95
+ useLinks: false,
96
+ });
97
+ const logPath = path.join(dir, 'run.log');
98
+ fs.writeFileSync(logPath, 'main output\n');
99
+ const record = new ExecutionRecord({
100
+ status: ExecutionStatus.EXECUTING,
101
+ command: 'cargo test',
102
+ logPath,
103
+ options: {
104
+ isolated: 'docker',
105
+ isolationMode: 'detached',
106
+ sessionName: 'box',
107
+ onKillResume: 3,
108
+ onKillResumeDelay: '30-90',
109
+ ...options,
110
+ },
111
+ });
112
+ store.save(record);
113
+ return { store, record, logPath };
114
+ }
115
+
116
+ function recover(store, record, extra = {}) {
117
+ const runner = extra.runner || fakeRunner();
118
+ const sleeps = [];
119
+ const outcome = recoverKilledExecution({
120
+ store,
121
+ executionId: record.uuid,
122
+ exitCode: '137',
123
+ oomKilled: 'true',
124
+ startedAt: '2026-10-01T10:00:00Z',
125
+ finishedAt: '2026-10-01T10:05:00Z',
126
+ containerError: '',
127
+ runner,
128
+ startWatcher: () => {},
129
+ now: () => new Date('2026-10-01T10:06:00.000Z'),
130
+ random: () => 0.5,
131
+ sleep: (ms) => sleeps.push(ms),
132
+ ...extra,
133
+ });
134
+ return { outcome, runner, sleeps };
135
+ }
136
+
137
+ describe('issue #181: --on-kill-resume-delay parsing', () => {
138
+ it('accepts a range, a fixed delay and 0, with or without "="', () => {
139
+ expect(
140
+ parse(['--on-kill-resume', '3', '--on-kill-resume-delay', '30-90'])
141
+ ).toMatchObject({ onKillResume: 3, onKillResumeDelay: '30-90' });
142
+ expect(
143
+ parse(['--on-kill-resume=3', '--on-kill-resume-delay=45'])
144
+ .onKillResumeDelay
145
+ ).toBe('45');
146
+ expect(
147
+ parse(['--on-kill-resume', '1', '--on-kill-resume-delay', '0'])
148
+ .onKillResumeDelay
149
+ ).toBe('0');
150
+ expect(parseOnKillResumeDelayValue('1.5-2.5')).toBe('1.5-2.5');
151
+ });
152
+
153
+ it('defaults to no delay', () => {
154
+ expect(parse(['--on-kill-resume', '3']).onKillResumeDelay).toBeNull();
155
+ });
156
+
157
+ it('rejects malformed and reversed ranges', () => {
158
+ for (const value of ['abc', '90-30', '-5', '30-', '1-2-3']) {
159
+ expect(() => parseOnKillResumeDelayValue(value)).toThrow(
160
+ 'Invalid --on-kill-resume-delay value'
161
+ );
162
+ }
163
+ expect(() =>
164
+ parse(['--on-kill-resume', '1', '--on-kill-resume-delay'])
165
+ ).toThrow('requires a seconds argument');
166
+ });
167
+
168
+ it('requires --on-kill-resume or --recovery-command', () => {
169
+ expect(() => parse(['--on-kill-resume-delay', '30-90'])).toThrow(
170
+ '--on-kill-resume-delay requires --on-kill-resume or --recovery-command'
171
+ );
172
+ expect(
173
+ parse(['--recovery-command', 'b', '--on-kill-resume-delay', '30-90'])
174
+ ).toMatchObject({ onKillResume: 1, onKillResumeDelay: '30-90' });
175
+ });
176
+
177
+ it('shows the delay in the [Isolation] line and stores it', () => {
178
+ const options = { onKillResume: 3, onKillResumeDelay: '30-90' };
179
+ expect(buildDockerRuntimeStatusLines(options)).toContain(
180
+ '[Isolation] On kill: resume up to 3 time(s) after a random 30-90s delay with the original command'
181
+ );
182
+ expect(buildDockerRuntimeMetadata(options).onKillResumeDelay).toBe('30-90');
183
+ expect(
184
+ buildDockerRuntimeMetadata({ onKillResume: 3, onKillResumeDelay: '0' })
185
+ .onKillResumeDelay
186
+ ).toBeNull();
187
+ expect(
188
+ buildDockerRuntimeStatusLines({ onKillResume: 3, onKillResumeDelay: '0' })
189
+ ).toContain(
190
+ '[Isolation] On kill: resume up to 3 time(s) with the original command'
191
+ );
192
+ });
193
+ });
194
+
195
+ describe('issue #181: picking and waiting out the delay', () => {
196
+ it('picks uniformly within the range', () => {
197
+ expect(pickRecoveryDelayMs('30-90', () => 0)).toBe(30000);
198
+ expect(pickRecoveryDelayMs('30-90', () => 0.5)).toBe(60000);
199
+ expect(pickRecoveryDelayMs('30-90', () => 0.999999)).toBe(90000);
200
+ expect(pickRecoveryDelayMs('45', () => 0.7)).toBe(45000);
201
+ expect(pickRecoveryDelayMs('0')).toBe(0);
202
+ expect(pickRecoveryDelayMs(null)).toBe(0);
203
+ for (let i = 0; i < 100; i++) {
204
+ const ms = pickRecoveryDelayMs('30-90');
205
+ expect(ms).toBeGreaterThanOrEqual(30000);
206
+ expect(ms).toBeLessThanOrEqual(90000);
207
+ }
208
+ });
209
+
210
+ it('waits in short steps and stops as soon as it is cancelled', () => {
211
+ const sleeps = [];
212
+ expect(
213
+ waitForRecoveryDelay({ delayMs: 2500, sleep: (ms) => sleeps.push(ms) })
214
+ ).toBe(false);
215
+ expect(sleeps).toEqual([1000, 1000, 500]);
216
+
217
+ const cancelled = [];
218
+ expect(
219
+ waitForRecoveryDelay({
220
+ delayMs: 60000,
221
+ sleep: (ms) => cancelled.push(ms),
222
+ shouldCancel: () => cancelled.length >= 2,
223
+ })
224
+ ).toBe(true);
225
+ expect(cancelled).toEqual([1000, 1000]);
226
+ });
227
+
228
+ it('really blocks the thread for the real sleep', () => {
229
+ const start = Date.now();
230
+ sleepSync(50);
231
+ expect(Date.now() - start).toBeGreaterThanOrEqual(45);
232
+ });
233
+ });
234
+
235
+ describe('issue #181: recovering after the delay', () => {
236
+ it('prints the delay in the [Recovery k/N] line', () => {
237
+ expect(
238
+ formatRecoverySeparator({
239
+ attempt: 1,
240
+ maxAttempts: 3,
241
+ exitCode: 137,
242
+ oomKilled: true,
243
+ containerName: 'box',
244
+ command: null,
245
+ delayMs: 42500,
246
+ })
247
+ ).toBe(
248
+ '\n[Recovery 1/3] Main process was killed (exit 137, SIGKILL, oomKilled=true); resuming container box after a 42.5s delay, running the original command again\n'
249
+ );
250
+ });
251
+
252
+ it('waits before docker start and records delayMs in the history', () => {
253
+ const { store, record, logPath } = makeStore();
254
+ const { outcome, runner, sleeps } = recover(store, record);
255
+ expect(outcome).toEqual({
256
+ recovered: true,
257
+ reason: 'resumed',
258
+ attempt: 1,
259
+ delayMs: 60000,
260
+ });
261
+ expect(sleeps.reduce((a, b) => a + b, 0)).toBe(60000);
262
+ expect(runner.calls.some((call) => call[1] === 'start')).toBe(true);
263
+ expect(fs.readFileSync(logPath, 'utf8')).toContain(
264
+ '[Recovery 1/3] Main process was killed (exit 137, SIGKILL, oomKilled=true); resuming container box after a 60s delay'
265
+ );
266
+ const stored = store.get(record.uuid).options;
267
+ expect(stored.recoveryHistory).toEqual([
268
+ '1: exit 137, oomKilled=true, delayMs=60000, resumed at 2026-10-01T10:06:00.000Z',
269
+ ]);
270
+ expect(stored.lastRecoveryDelayMs).toBe(60000);
271
+ });
272
+
273
+ it('keeps the old behaviour and history format without a delay', () => {
274
+ const { store, record } = makeStore({ onKillResumeDelay: null });
275
+ const { outcome, sleeps } = recover(store, record);
276
+ expect(outcome).toEqual({ recovered: true, reason: 'resumed', attempt: 1 });
277
+ expect(sleeps).toEqual([]);
278
+ const stored = store.get(record.uuid).options;
279
+ expect(stored.recoveryHistory).toEqual([
280
+ '1: exit 137, oomKilled=true, resumed at 2026-10-01T10:06:00.000Z',
281
+ ]);
282
+ expect(stored.lastRecoveryDelayMs).toBeUndefined();
283
+ });
284
+
285
+ it('a --stop during the wait cancels the pending recovery', () => {
286
+ const { store, record, logPath } = makeStore();
287
+ let stopped = false;
288
+ const { outcome, runner } = recover(store, record, {
289
+ sleep: () => {
290
+ if (!stopped) {
291
+ stopped = true;
292
+ // `docker stop` on the already exited container succeeds.
293
+ controlExecution(store, record.uuid, ControlAction.STOP, () => ok());
294
+ }
295
+ },
296
+ });
297
+ expect(outcome).toMatchObject({
298
+ recovered: false,
299
+ reason: 'stop-requested',
300
+ attempt: 1,
301
+ });
302
+ expect(runner.calls.some((call) => call[1] === 'start')).toBe(false);
303
+ expect(fs.readFileSync(logPath, 'utf8')).toContain(
304
+ '[Recovery 1/3] Not resuming: the session was stopped on request during the delay.'
305
+ );
306
+ expect(store.get(record.uuid).options.recoveryAttempts).toBeUndefined();
307
+ });
308
+
309
+ it('a --terminate of the exited container still cancels the recovery', () => {
310
+ const { store, record } = makeStore();
311
+ const result = controlExecution(
312
+ store,
313
+ record.uuid,
314
+ ControlAction.TERMINATE,
315
+ fakeRunner({
316
+ kill: 'Error response from daemon: Cannot kill container: box: Container cid is not running',
317
+ })
318
+ );
319
+ expect(result.success).toBe(true);
320
+ expect(result.output).toContain('status recovery-cancelled');
321
+ expect(store.get(record.uuid).options.stopRequestedAt).toBeTruthy();
322
+
323
+ const { outcome } = recover(store, record);
324
+ expect(outcome.reason).toBe('stop-requested');
325
+ });
326
+
327
+ it('a failed --terminate for another reason still restores the marker', () => {
328
+ const { store, record } = makeStore();
329
+ const result = controlExecution(
330
+ store,
331
+ record.uuid,
332
+ ControlAction.TERMINATE,
333
+ fakeRunner({ kill: 'permission denied' })
334
+ );
335
+ expect(result.success).toBe(false);
336
+ expect(store.get(record.uuid).options.stopRequestedAt).toBeUndefined();
337
+ });
338
+ });