start-command 0.35.1 → 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.
- package/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/src/lib/args-parser.js +1 -0
- package/src/lib/cgroup-memory.js +283 -0
- package/src/lib/detached-finalize.js +17 -2
- package/src/lib/docker-cleanup.js +27 -8
- package/src/lib/docker-recovery-options.js +31 -3
- package/src/lib/docker-runtime-args.js +10 -4
- package/src/lib/execution-control.js +36 -0
- package/src/lib/execution-recovery.js +90 -10
- package/src/lib/execution-store.js +9 -0
- package/src/lib/exit-reason.js +67 -6
- package/src/lib/recovery-delay.js +151 -0
- package/src/lib/status-formatter.js +16 -0
- package/src/lib/usage.js +2 -0
- package/test/docker-runtime-options.js +2 -0
- package/test/regression-174.js +2 -0
- package/test/regression-180.js +196 -0
- package/test/regression-181.js +338 -0
- package/test/regression-182.js +439 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #180:
|
|
3
|
+
*
|
|
4
|
+
* `$ --status` reported `exitReason memory-exhaustion (cgroup-oom-killer)`
|
|
5
|
+
* and `memoryExhausted true` for an ordinary exit 0/1 whenever Docker's
|
|
6
|
+
* `State.OOMKilled` flag was set. Since moby/moby#43564 that flag is
|
|
7
|
+
* container-wide and sticky: it turns on as soon as *any* process in the
|
|
8
|
+
* container is OOM-killed (a `rustc` child under `cargo test`) and stays on
|
|
9
|
+
* until the container starts again. A command that survived that and later
|
|
10
|
+
* exited 1 on its own was reported as having run out of memory.
|
|
11
|
+
*
|
|
12
|
+
* The flag only explains the command's exit when that exit is 137
|
|
13
|
+
* (`128 + SIGKILL`) or unknown: the rule `isKilledExit()` applies since #178.
|
|
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 {
|
|
22
|
+
CGROUP_OOM_EXIT_REASON,
|
|
23
|
+
isOomKillOfCommand,
|
|
24
|
+
resolveExitReason,
|
|
25
|
+
resolveMemoryExhaustion,
|
|
26
|
+
} = require('../src/lib/exit-reason');
|
|
27
|
+
const {
|
|
28
|
+
attachExitReason,
|
|
29
|
+
attachMemoryExhaustion,
|
|
30
|
+
} = require('../src/lib/status-formatter');
|
|
31
|
+
const { finalizeDetachedExecution } = require('../src/lib/detached-finalize');
|
|
32
|
+
const { buildAttachedDockerKeptMessage } = require('../src/lib/docker-cleanup');
|
|
33
|
+
const {
|
|
34
|
+
ExecutionStore,
|
|
35
|
+
ExecutionRecord,
|
|
36
|
+
ExecutionStatus,
|
|
37
|
+
} = require('../src/lib/execution-store');
|
|
38
|
+
|
|
39
|
+
const OOM_REASON = 'Docker reported State.OOMKilled=true';
|
|
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
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function finishedRecord(fields) {
|
|
56
|
+
const record = new ExecutionRecord({
|
|
57
|
+
status: ExecutionStatus.EXECUTED,
|
|
58
|
+
command: 'cargo test',
|
|
59
|
+
logPath: null,
|
|
60
|
+
});
|
|
61
|
+
return Object.assign(record, fields);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('issue #180: the sticky OOMKilled flag is not an exit reason', () => {
|
|
65
|
+
// The reproduction table from the issue, plus the watcher's unknown exit.
|
|
66
|
+
const CASES = [
|
|
67
|
+
{ exitCode: 0, oomKilled: true, reason: null, memory: false },
|
|
68
|
+
{ exitCode: 1, oomKilled: true, reason: null, memory: false },
|
|
69
|
+
{ exitCode: 1, oomKilled: false, reason: null, memory: false },
|
|
70
|
+
{
|
|
71
|
+
exitCode: 137,
|
|
72
|
+
oomKilled: true,
|
|
73
|
+
reason: CGROUP_OOM_EXIT_REASON,
|
|
74
|
+
memory: true,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
exitCode: 137,
|
|
78
|
+
oomKilled: false,
|
|
79
|
+
reason: 'signal (SIGKILL)',
|
|
80
|
+
memory: false,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
exitCode: -1,
|
|
84
|
+
oomKilled: true,
|
|
85
|
+
reason: CGROUP_OOM_EXIT_REASON,
|
|
86
|
+
memory: true,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
exitCode: 139,
|
|
90
|
+
oomKilled: true,
|
|
91
|
+
reason: 'signal (SIGSEGV)',
|
|
92
|
+
memory: false,
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
for (const { exitCode, oomKilled, reason, memory } of CASES) {
|
|
97
|
+
it(`exit ${exitCode} with oomKilled=${oomKilled}`, () => {
|
|
98
|
+
expect(resolveExitReason({ exitCode, oomKilled })).toBe(reason);
|
|
99
|
+
expect(resolveMemoryExhaustion({ exitCode, oomKilled })).toEqual(
|
|
100
|
+
memory
|
|
101
|
+
? { memoryExhausted: true, memoryExhaustedReason: OOM_REASON }
|
|
102
|
+
: null
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it('blames the flag on the command only for SIGKILL or an unknown exit', () => {
|
|
108
|
+
expect(isOomKillOfCommand({ exitCode: 137, oomKilled: true })).toBe(true);
|
|
109
|
+
expect(isOomKillOfCommand({ exitCode: null, oomKilled: true })).toBe(true);
|
|
110
|
+
expect(isOomKillOfCommand({ exitCode: -1, oomKilled: true })).toBe(true);
|
|
111
|
+
expect(isOomKillOfCommand({ exitCode: 0, oomKilled: true })).toBe(false);
|
|
112
|
+
expect(isOomKillOfCommand({ exitCode: 1, oomKilled: true })).toBe(false);
|
|
113
|
+
expect(isOomKillOfCommand({ exitCode: 137, oomKilled: false })).toBe(false);
|
|
114
|
+
expect(isOomKillOfCommand(null)).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('still trusts a memory marker the command printed itself', () => {
|
|
118
|
+
const logTail =
|
|
119
|
+
'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory';
|
|
120
|
+
expect(resolveExitReason({ exitCode: 1, oomKilled: true, logTail })).toBe(
|
|
121
|
+
'memory-exhaustion (v8-heap-limit)'
|
|
122
|
+
);
|
|
123
|
+
expect(
|
|
124
|
+
resolveMemoryExhaustion({ exitCode: 1, oomKilled: true, logTail })
|
|
125
|
+
.memoryExhausted
|
|
126
|
+
).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('reports the incident record without a memory verdict', () => {
|
|
130
|
+
const record = finishedRecord({ exitCode: 1, oomKilled: true });
|
|
131
|
+
const enriched = attachMemoryExhaustion(
|
|
132
|
+
attachExitReason(record, 'Failed to authenticate: OAuth session expired'),
|
|
133
|
+
'Failed to authenticate: OAuth session expired'
|
|
134
|
+
);
|
|
135
|
+
expect(enriched.oomKilled).toBe(true);
|
|
136
|
+
expect(enriched.exitReason).toBeUndefined();
|
|
137
|
+
expect(enriched.memoryExhausted).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('drops a stale cgroup reason stored before the fix', () => {
|
|
141
|
+
const record = finishedRecord({
|
|
142
|
+
exitCode: 1,
|
|
143
|
+
oomKilled: true,
|
|
144
|
+
exitReason: CGROUP_OOM_EXIT_REASON,
|
|
145
|
+
});
|
|
146
|
+
const enriched = attachExitReason(record, null);
|
|
147
|
+
expect(enriched.exitReason).toBeUndefined();
|
|
148
|
+
expect(record.exitReason).toBe(CGROUP_OOM_EXIT_REASON);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('the finalizer does not record the flag as the reason for exit 1', () => {
|
|
152
|
+
const appFolder = makeTempDir('finalize-180-');
|
|
153
|
+
const store = new ExecutionStore({ appFolder, useLinks: false });
|
|
154
|
+
const record = new ExecutionRecord({
|
|
155
|
+
status: ExecutionStatus.EXECUTING,
|
|
156
|
+
command: 'cargo test',
|
|
157
|
+
logPath: null,
|
|
158
|
+
options: { isolated: 'docker', isolationMode: 'detached' },
|
|
159
|
+
});
|
|
160
|
+
store.save(record);
|
|
161
|
+
|
|
162
|
+
finalizeDetachedExecution({
|
|
163
|
+
store,
|
|
164
|
+
executionId: record.uuid,
|
|
165
|
+
exitCode: '1',
|
|
166
|
+
oomKilled: 'true',
|
|
167
|
+
startedAt: '2026-10-01T19:00:00Z',
|
|
168
|
+
finishedAt: '2026-10-01T19:41:00Z',
|
|
169
|
+
running: 'false',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const stored = store.get(record.uuid);
|
|
173
|
+
expect(stored.exitCode).toBe(1);
|
|
174
|
+
expect(stored.oomKilled).toBe(true);
|
|
175
|
+
expect(stored.exitReason).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('the attached kept message names a child OOM kill, not the command', () => {
|
|
179
|
+
const child = buildAttachedDockerKeptMessage({
|
|
180
|
+
containerName: 'c',
|
|
181
|
+
exitCode: 1,
|
|
182
|
+
oomKilled: true,
|
|
183
|
+
logPath: null,
|
|
184
|
+
});
|
|
185
|
+
expect(child).toContain('a process in it was OOM-killed');
|
|
186
|
+
expect(child).not.toContain('Memory exhaustion detected');
|
|
187
|
+
|
|
188
|
+
const main = buildAttachedDockerKeptMessage({
|
|
189
|
+
containerName: 'c',
|
|
190
|
+
exitCode: 137,
|
|
191
|
+
oomKilled: true,
|
|
192
|
+
logPath: null,
|
|
193
|
+
});
|
|
194
|
+
expect(main).toContain('Docker reports it was OOM-killed.');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -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
|
+
});
|