start-command 0.43.0 → 0.44.0
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/.changeset/README.md +1 -1
- package/CHANGELOG.md +13 -0
- package/README.md +2 -0
- package/package.json +5 -5
- package/src/lib/args-parser-queries.js +64 -3
- package/src/lib/docker-inspect-batch.js +3 -0
- package/src/lib/execution-attach.js +10 -1
- package/src/lib/execution-cleanup.js +8 -0
- package/src/lib/execution-control.js +14 -3
- package/src/lib/execution-pause.js +701 -0
- package/src/lib/execution-recovery.js +6 -0
- package/src/lib/execution-resume-all.js +14 -0
- package/src/lib/execution-resume.js +16 -1
- package/src/lib/execution-store.js +5 -1
- package/src/lib/log-sanitizer.js +4 -3
- package/src/lib/query-commands.js +33 -1
- package/src/lib/session-probe.js +27 -1
- package/src/lib/status-formatter.js +13 -3
- package/src/lib/status-probe.js +19 -5
- package/src/lib/usage.js +8 -1
- package/test/args-parser-attach-resume.js +1 -1
- package/test/args-parser-control.js +3 -3
- package/test/args-parser.js +1 -1
- package/test/fixtures/pause-golden.cjs +142 -0
- package/test/regression-243.js +4 -1
- package/test/regression-263.js +867 -0
- package/test/shared-golden.js +28 -3
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #263: `$ --pause <id>` / `$ --unpause <id>` freeze and thaw a
|
|
3
|
+
* detached Docker execution with `docker pause` (cgroup freezer). CPU is
|
|
4
|
+
* released, memory and the process stay, and the same process continues on
|
|
5
|
+
* unpause. Paused executions are reported, never resumed, attached to,
|
|
6
|
+
* cleaned up or recovered, and a stale recorded pause is reconciled against
|
|
7
|
+
* `docker inspect`.
|
|
8
|
+
*/
|
|
9
|
+
// Own snapshot lock even without the bunfig.toml preload (issue #234).
|
|
10
|
+
require('./setup/private-snapshot-lock');
|
|
11
|
+
const { describe, it, expect, beforeEach, afterEach } = require('bun:test');
|
|
12
|
+
const { spawnSync } = require('child_process');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
18
|
+
const {
|
|
19
|
+
ExecutionRecord,
|
|
20
|
+
ExecutionStatus,
|
|
21
|
+
ExecutionStore,
|
|
22
|
+
} = require('../src/lib/execution-store');
|
|
23
|
+
const pause = require('../src/lib/execution-pause');
|
|
24
|
+
const {
|
|
25
|
+
SessionState,
|
|
26
|
+
mapDockerStatusToState,
|
|
27
|
+
probeSession,
|
|
28
|
+
} = require('../src/lib/session-probe');
|
|
29
|
+
const { attachExecution } = require('../src/lib/execution-attach');
|
|
30
|
+
const { resumeExecution } = require('../src/lib/execution-resume');
|
|
31
|
+
const { resumeAllExecutions } = require('../src/lib/execution-resume-all');
|
|
32
|
+
const { cleanupExecution } = require('../src/lib/execution-cleanup');
|
|
33
|
+
const { controlExecution } = require('../src/lib/execution-control');
|
|
34
|
+
const { recoverKilledExecution } = require('../src/lib/execution-recovery');
|
|
35
|
+
const { queryStatus, listExecutions } = require('../src/lib/status-formatter');
|
|
36
|
+
const { canRunLinuxDockerImages } = require('../src/lib/isolation');
|
|
37
|
+
|
|
38
|
+
const UUID = '26300000-0000-4000-8000-000000000263';
|
|
39
|
+
const NAME = 'paused-263';
|
|
40
|
+
|
|
41
|
+
function makeStore() {
|
|
42
|
+
const appFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'start-263-'));
|
|
43
|
+
const store = new ExecutionStore({ appFolder, useLinks: false });
|
|
44
|
+
store.appFolderForTest = appFolder;
|
|
45
|
+
return store;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeRecord(options = {}, overrides = {}) {
|
|
49
|
+
return new ExecutionRecord({
|
|
50
|
+
uuid: UUID,
|
|
51
|
+
command: 'sleep 300',
|
|
52
|
+
pid: 999999,
|
|
53
|
+
logPath: path.join(os.tmpdir(), `start-263-${process.pid}.log`),
|
|
54
|
+
status: ExecutionStatus.EXECUTING,
|
|
55
|
+
options: {
|
|
56
|
+
isolated: 'docker',
|
|
57
|
+
isolationMode: 'detached',
|
|
58
|
+
sessionName: NAME,
|
|
59
|
+
image: 'alpine:3.23',
|
|
60
|
+
...options,
|
|
61
|
+
},
|
|
62
|
+
...overrides,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* In-memory docker: `state` is running, paused, exited or removed. Answers
|
|
68
|
+
* the pause probe, `pause`/`unpause`/`stop`/`kill`/`rm`, and records calls.
|
|
69
|
+
*/
|
|
70
|
+
function fakeDocker(initial = 'running') {
|
|
71
|
+
const docker = { state: initial, calls: [] };
|
|
72
|
+
docker.runner = (command, args, extra = {}) => {
|
|
73
|
+
docker.calls.push({ command, args, extra });
|
|
74
|
+
const ok = (stdout = '') => ({
|
|
75
|
+
success: true,
|
|
76
|
+
stdout,
|
|
77
|
+
stderr: '',
|
|
78
|
+
status: 0,
|
|
79
|
+
error: null,
|
|
80
|
+
});
|
|
81
|
+
const fail = (stderr) => ({
|
|
82
|
+
success: false,
|
|
83
|
+
stdout: '',
|
|
84
|
+
stderr,
|
|
85
|
+
status: 1,
|
|
86
|
+
error: null,
|
|
87
|
+
});
|
|
88
|
+
if (command !== 'docker') {
|
|
89
|
+
return fail('not docker');
|
|
90
|
+
}
|
|
91
|
+
if (docker.state === 'removed' && args[0] !== 'rm') {
|
|
92
|
+
return fail(`Error response from daemon: No such container: ${NAME}`);
|
|
93
|
+
}
|
|
94
|
+
switch (args[0]) {
|
|
95
|
+
case 'inspect': {
|
|
96
|
+
const format = args[args.indexOf('-f') + 1];
|
|
97
|
+
if (format === pause.PAUSE_STATE_FORMAT) {
|
|
98
|
+
return ok(`${docker.state} ${docker.state === 'paused'}\n`);
|
|
99
|
+
}
|
|
100
|
+
if (format === '{{.State.Status}}') {
|
|
101
|
+
return ok(`${docker.state}\n`);
|
|
102
|
+
}
|
|
103
|
+
return ok('');
|
|
104
|
+
}
|
|
105
|
+
case 'pause':
|
|
106
|
+
if (docker.state !== 'running') {
|
|
107
|
+
return fail(`container ${NAME} is not running`);
|
|
108
|
+
}
|
|
109
|
+
docker.state = 'paused';
|
|
110
|
+
return ok(`${NAME}\n`);
|
|
111
|
+
case 'unpause':
|
|
112
|
+
if (docker.state !== 'paused') {
|
|
113
|
+
return fail(`container ${NAME} is not paused`);
|
|
114
|
+
}
|
|
115
|
+
docker.state = 'running';
|
|
116
|
+
return ok(`${NAME}\n`);
|
|
117
|
+
case 'stop':
|
|
118
|
+
case 'kill':
|
|
119
|
+
docker.state = 'exited';
|
|
120
|
+
return ok(`${NAME}\n`);
|
|
121
|
+
case 'rm':
|
|
122
|
+
if (docker.state === 'paused') {
|
|
123
|
+
return fail('cannot remove a paused container');
|
|
124
|
+
}
|
|
125
|
+
docker.state = 'removed';
|
|
126
|
+
return ok(`${NAME}\n`);
|
|
127
|
+
default:
|
|
128
|
+
return ok('');
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
docker.ran = (verb) => docker.calls.filter((c) => c.args[0] === verb);
|
|
132
|
+
return docker;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const pausedProbe = () => ({
|
|
136
|
+
backend: 'docker',
|
|
137
|
+
sessionName: NAME,
|
|
138
|
+
state: SessionState.PAUSED,
|
|
139
|
+
alive: true,
|
|
140
|
+
containerStatus: 'paused',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
let store;
|
|
144
|
+
beforeEach(() => {
|
|
145
|
+
store = makeStore();
|
|
146
|
+
});
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
fs.rmSync(store.appFolderForTest, { recursive: true, force: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('issue #263: parsing --pause / --unpause', () => {
|
|
152
|
+
it('parses the identifiers, reason and actor in both forms', () => {
|
|
153
|
+
const a = parseArgs([
|
|
154
|
+
'--pause',
|
|
155
|
+
NAME,
|
|
156
|
+
'--pause-reason',
|
|
157
|
+
'rate limit',
|
|
158
|
+
'--paused-by=hive-mind',
|
|
159
|
+
]).wrapperOptions;
|
|
160
|
+
expect(a.pause).toBe(NAME);
|
|
161
|
+
expect(a.pauseReason).toBe('rate limit');
|
|
162
|
+
expect(a.pausedBy).toBe('hive-mind');
|
|
163
|
+
expect(parseArgs([`--unpause=${UUID}`]).wrapperOptions.unpause).toBe(UUID);
|
|
164
|
+
expect(
|
|
165
|
+
parseArgs(['--unpause', NAME, '--output-format', 'json']).wrapperOptions
|
|
166
|
+
.outputFormat
|
|
167
|
+
).toBe('json');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('rejects combinations and stray detail options', () => {
|
|
171
|
+
expect(() => parseArgs(['--pause', NAME, '--stop', NAME])).toThrow(
|
|
172
|
+
/Cannot combine .*--pause, --unpause.* in the same invocation/
|
|
173
|
+
);
|
|
174
|
+
expect(() => parseArgs(['--pause', NAME, '--unpause', NAME])).toThrow(
|
|
175
|
+
/Cannot combine/
|
|
176
|
+
);
|
|
177
|
+
expect(() => parseArgs(['--unpause', NAME, '--pause-reason', 'x'])).toThrow(
|
|
178
|
+
'--pause-reason and --paused-by options are only valid with --pause'
|
|
179
|
+
);
|
|
180
|
+
expect(() => parseArgs(['--pause', NAME, '--pause-reason'])).toThrow(
|
|
181
|
+
'Option --pause-reason requires a value'
|
|
182
|
+
);
|
|
183
|
+
expect(() => parseArgs(['--pause'])).toThrow();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('issue #263: session probe', () => {
|
|
188
|
+
it('reads a paused container as PAUSED and alive, not STOPPED', () => {
|
|
189
|
+
expect(mapDockerStatusToState('paused')).toBe(SessionState.PAUSED);
|
|
190
|
+
const docker = fakeDocker('paused');
|
|
191
|
+
const probe = probeSession(makeRecord(), docker.runner);
|
|
192
|
+
expect(probe.state).toBe(SessionState.PAUSED);
|
|
193
|
+
expect(probe.alive).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('issue #263: --pause', () => {
|
|
198
|
+
it('pauses with an argv array and a timeout, keeping the same record', () => {
|
|
199
|
+
store.save(makeRecord());
|
|
200
|
+
const docker = fakeDocker('running');
|
|
201
|
+
const result = pause.pauseExecution(store, NAME, {
|
|
202
|
+
runner: docker.runner,
|
|
203
|
+
outputFormat: 'json',
|
|
204
|
+
reason: 'rate limit',
|
|
205
|
+
by: 'hive-mind',
|
|
206
|
+
});
|
|
207
|
+
expect(result.success).toBe(true);
|
|
208
|
+
const report = JSON.parse(result.output);
|
|
209
|
+
expect(report).toMatchObject({
|
|
210
|
+
action: 'pause',
|
|
211
|
+
uuid: UUID,
|
|
212
|
+
status: 'paused',
|
|
213
|
+
method: 'DOCKER_PAUSE',
|
|
214
|
+
dockerState: 'paused',
|
|
215
|
+
paused: true,
|
|
216
|
+
pauseCount: 1,
|
|
217
|
+
pausedBy: 'hive-mind',
|
|
218
|
+
pauseReason: 'rate limit',
|
|
219
|
+
resources: { cpu: 'released', memory: 'retained', disk: 'retained' },
|
|
220
|
+
});
|
|
221
|
+
expect(report.message).toContain('memory stays allocated');
|
|
222
|
+
expect(report.message).toContain(`--unpause ${UUID}`);
|
|
223
|
+
const [call] = docker.ran('pause');
|
|
224
|
+
expect(call.args).toEqual(['pause', NAME]);
|
|
225
|
+
expect(call.extra.timeout).toBe(pause.DOCKER_PAUSE_TIMEOUT_MS);
|
|
226
|
+
// Every docker call is bounded, the process-ID lookup included.
|
|
227
|
+
for (const c of docker.calls) {
|
|
228
|
+
expect(c.extra.timeout).toBe(pause.DOCKER_PAUSE_TIMEOUT_MS);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const all = store.getAll();
|
|
232
|
+
expect(all).toHaveLength(1);
|
|
233
|
+
const saved = all[0];
|
|
234
|
+
expect(saved.uuid).toBe(UUID);
|
|
235
|
+
expect(saved.status).toBe(ExecutionStatus.EXECUTING);
|
|
236
|
+
expect(saved.logPath).toBe(makeRecord().logPath);
|
|
237
|
+
expect(saved.options).toMatchObject({
|
|
238
|
+
paused: true,
|
|
239
|
+
pauseCount: 1,
|
|
240
|
+
pausedBy: 'hive-mind',
|
|
241
|
+
pauseReason: 'rate limit',
|
|
242
|
+
image: 'alpine:3.23',
|
|
243
|
+
});
|
|
244
|
+
expect(Date.parse(saved.options.pausedAt)).not.toBeNaN();
|
|
245
|
+
expect(saved.options.recoveryAttempts).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('is idempotent on an already paused container', () => {
|
|
249
|
+
store.save(makeRecord());
|
|
250
|
+
const docker = fakeDocker('running');
|
|
251
|
+
pause.pauseExecution(store, NAME, { runner: docker.runner });
|
|
252
|
+
const first = store.get(UUID).options.pausedAt;
|
|
253
|
+
const again = pause.pauseExecution(store, NAME, {
|
|
254
|
+
runner: docker.runner,
|
|
255
|
+
outputFormat: 'json',
|
|
256
|
+
});
|
|
257
|
+
expect(again.success).toBe(true);
|
|
258
|
+
expect(JSON.parse(again.output)).toMatchObject({
|
|
259
|
+
status: 'already-paused',
|
|
260
|
+
method: 'NONE',
|
|
261
|
+
paused: true,
|
|
262
|
+
});
|
|
263
|
+
expect(docker.ran('pause')).toHaveLength(1);
|
|
264
|
+
expect(store.get(UUID).options.pauseCount).toBe(1);
|
|
265
|
+
expect(store.get(UUID).options.pausedAt).toBe(first);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('adopts a pause made outside start-command', () => {
|
|
269
|
+
store.save(makeRecord());
|
|
270
|
+
const docker = fakeDocker('paused');
|
|
271
|
+
const result = pause.pauseExecution(store, NAME, { runner: docker.runner });
|
|
272
|
+
expect(result.success).toBe(true);
|
|
273
|
+
expect(result.output).toContain('already-paused');
|
|
274
|
+
expect(store.get(UUID).options.paused).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('fails on an exited container and clears a stale pause', () => {
|
|
278
|
+
store.save(makeRecord({ paused: true, pausedAt: 'x', pauseCount: 1 }));
|
|
279
|
+
const docker = fakeDocker('exited');
|
|
280
|
+
const result = pause.pauseExecution(store, NAME, { runner: docker.runner });
|
|
281
|
+
expect(result.success).toBe(false);
|
|
282
|
+
expect(result.error).toContain('is exited, not running');
|
|
283
|
+
expect(result.error).toContain(`--status ${UUID}`);
|
|
284
|
+
expect(result.error).toContain(`--resume ${UUID}`);
|
|
285
|
+
expect(docker.ran('pause')).toHaveLength(0);
|
|
286
|
+
expect(store.get(UUID).options).toMatchObject({
|
|
287
|
+
paused: false,
|
|
288
|
+
pauseEndedBy: 'reconcile',
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('fails on a removed container and records the removal', () => {
|
|
293
|
+
store.save(
|
|
294
|
+
makeRecord(
|
|
295
|
+
{ paused: true, pausedAt: 'x', pauseCount: 1 },
|
|
296
|
+
{ status: ExecutionStatus.EXECUTED, exitCode: 137 }
|
|
297
|
+
)
|
|
298
|
+
);
|
|
299
|
+
const docker = fakeDocker('removed');
|
|
300
|
+
const result = pause.pauseExecution(store, NAME, { runner: docker.runner });
|
|
301
|
+
expect(result.success).toBe(false);
|
|
302
|
+
expect(result.error).toContain('no longer exists (removed)');
|
|
303
|
+
expect(result.error).toContain(`--resume ${UUID}`);
|
|
304
|
+
const saved = store.get(UUID);
|
|
305
|
+
expect(saved.containerState).toBe('removed');
|
|
306
|
+
expect(saved.options.paused).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('refuses screen, tmux and attached docker executions', () => {
|
|
310
|
+
for (const isolated of ['screen', 'tmux']) {
|
|
311
|
+
const s = makeStore();
|
|
312
|
+
s.save(makeRecord({ isolated }));
|
|
313
|
+
const docker = fakeDocker();
|
|
314
|
+
const result = pause.pauseExecution(s, NAME, { runner: docker.runner });
|
|
315
|
+
expect(result.success).toBe(false);
|
|
316
|
+
expect(result.error).toContain(
|
|
317
|
+
`--pause only supports Docker isolation: ${NAME} uses ${isolated}`
|
|
318
|
+
);
|
|
319
|
+
expect(docker.calls).toHaveLength(0);
|
|
320
|
+
fs.rmSync(s.appFolderForTest, { recursive: true, force: true });
|
|
321
|
+
}
|
|
322
|
+
store.save(makeRecord({ isolationMode: 'attached' }));
|
|
323
|
+
expect(
|
|
324
|
+
pause.unpauseExecution(store, NAME, { runner: fakeDocker().runner }).error
|
|
325
|
+
).toBe('Only detached Docker executions can be unpaused.');
|
|
326
|
+
expect(pause.pauseExecution(store, 'nope', {}).error).toContain(
|
|
327
|
+
'No execution found'
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('reports a docker failure without changing the record', () => {
|
|
332
|
+
store.save(makeRecord());
|
|
333
|
+
const docker = fakeDocker('running');
|
|
334
|
+
const runner = (command, args, extra) =>
|
|
335
|
+
args[0] === 'pause'
|
|
336
|
+
? { success: false, stdout: '', stderr: 'boom', status: 1 }
|
|
337
|
+
: docker.runner(command, args, extra);
|
|
338
|
+
const result = pause.pauseExecution(store, NAME, { runner });
|
|
339
|
+
expect(result).toEqual({
|
|
340
|
+
success: false,
|
|
341
|
+
error: `Failed to pause docker container "${NAME}": boom`,
|
|
342
|
+
});
|
|
343
|
+
expect(store.get(UUID).options.paused).toBeUndefined();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe('issue #263: --unpause', () => {
|
|
348
|
+
it('unpauses a paused container and clears the pause state', () => {
|
|
349
|
+
store.save(makeRecord());
|
|
350
|
+
const docker = fakeDocker('running');
|
|
351
|
+
pause.pauseExecution(store, NAME, { runner: docker.runner, by: 'me' });
|
|
352
|
+
const result = pause.unpauseExecution(store, UUID, {
|
|
353
|
+
runner: docker.runner,
|
|
354
|
+
outputFormat: 'json',
|
|
355
|
+
});
|
|
356
|
+
expect(result.success).toBe(true);
|
|
357
|
+
expect(JSON.parse(result.output)).toMatchObject({
|
|
358
|
+
action: 'unpause',
|
|
359
|
+
status: 'unpaused',
|
|
360
|
+
method: 'DOCKER_UNPAUSE',
|
|
361
|
+
dockerState: 'running',
|
|
362
|
+
paused: false,
|
|
363
|
+
});
|
|
364
|
+
expect(docker.ran('unpause')[0].args).toEqual(['unpause', NAME]);
|
|
365
|
+
const opts = store.get(UUID).options;
|
|
366
|
+
expect(opts).toMatchObject({
|
|
367
|
+
paused: false,
|
|
368
|
+
pauseCount: 1,
|
|
369
|
+
pauseEndedBy: 'unpause',
|
|
370
|
+
});
|
|
371
|
+
expect(opts.pausedAt).toBeUndefined();
|
|
372
|
+
expect(opts.pausedBy).toBeUndefined();
|
|
373
|
+
expect(store.getAll()).toHaveLength(1);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('is idempotent on a running container and reconciles the record', () => {
|
|
377
|
+
store.save(makeRecord({ paused: true, pausedAt: 'x', pauseCount: 2 }));
|
|
378
|
+
const docker = fakeDocker('running');
|
|
379
|
+
const result = pause.unpauseExecution(store, NAME, {
|
|
380
|
+
runner: docker.runner,
|
|
381
|
+
});
|
|
382
|
+
expect(result.success).toBe(true);
|
|
383
|
+
expect(result.output).toContain('status not-paused');
|
|
384
|
+
expect(docker.ran('unpause')).toHaveLength(0);
|
|
385
|
+
expect(store.get(UUID).options).toMatchObject({
|
|
386
|
+
paused: false,
|
|
387
|
+
pauseCount: 2,
|
|
388
|
+
pauseEndedBy: 'reconcile',
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('fails on exited and removed containers with a recovery path', () => {
|
|
393
|
+
store.save(makeRecord({ paused: true }));
|
|
394
|
+
const exited = pause.unpauseExecution(store, NAME, {
|
|
395
|
+
runner: fakeDocker('exited').runner,
|
|
396
|
+
});
|
|
397
|
+
expect(exited.success).toBe(false);
|
|
398
|
+
expect(exited.error).toContain('is exited, not paused');
|
|
399
|
+
expect(exited.error).toContain(`--resume ${UUID}`);
|
|
400
|
+
const removed = pause.unpauseExecution(store, NAME, {
|
|
401
|
+
runner: fakeDocker('removed').runner,
|
|
402
|
+
});
|
|
403
|
+
expect(removed.success).toBe(false);
|
|
404
|
+
expect(removed.error).toContain('no longer exists (removed)');
|
|
405
|
+
expect(removed.error).toContain(`--status ${UUID}`);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('renders text and links-notation reports', () => {
|
|
409
|
+
store.save(makeRecord());
|
|
410
|
+
const docker = fakeDocker('running');
|
|
411
|
+
const text = pause.pauseExecution(store, NAME, {
|
|
412
|
+
runner: docker.runner,
|
|
413
|
+
outputFormat: 'text',
|
|
414
|
+
}).output;
|
|
415
|
+
expect(text).toContain('Docker State: paused');
|
|
416
|
+
expect(text).toContain('CPU released, memory retained, disk retained');
|
|
417
|
+
const lino = pause.unpauseExecution(store, NAME, {
|
|
418
|
+
runner: docker.runner,
|
|
419
|
+
}).output;
|
|
420
|
+
expect(lino.split('\n')[0]).toBe('executionControl');
|
|
421
|
+
expect(lino).toContain(' status unpaused');
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe('issue #263: other commands on a paused execution', () => {
|
|
426
|
+
it('--attach is refused without starting an interactive session', () => {
|
|
427
|
+
store.save(makeRecord({ paused: true }));
|
|
428
|
+
let interactive = 0;
|
|
429
|
+
const result = attachExecution(store, NAME, {
|
|
430
|
+
probe: pausedProbe,
|
|
431
|
+
interactiveRunner: () => {
|
|
432
|
+
interactive += 1;
|
|
433
|
+
return { status: 0 };
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
expect(result.success).toBe(false);
|
|
437
|
+
expect(result.error).toContain('is paused');
|
|
438
|
+
expect(result.error).toContain(`--unpause ${UUID}`);
|
|
439
|
+
expect(interactive).toBe(0);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('--resume is refused and takes no snapshot', async () => {
|
|
443
|
+
store.save(makeRecord({ paused: true }));
|
|
444
|
+
const docker = fakeDocker('paused');
|
|
445
|
+
const result = await resumeExecution(store, NAME, {
|
|
446
|
+
probe: pausedProbe,
|
|
447
|
+
runner: docker.runner,
|
|
448
|
+
runIsolated: () => {
|
|
449
|
+
throw new Error('must not launch');
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
expect(result.success).toBe(false);
|
|
453
|
+
expect(result.error).toContain(`--unpause ${UUID}`);
|
|
454
|
+
expect(docker.ran('commit')).toHaveLength(0);
|
|
455
|
+
expect(docker.ran('start')).toHaveLength(0);
|
|
456
|
+
expect(store.getAll()).toHaveLength(1);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('--resume-all skips a paused execution with a reason', () => {
|
|
460
|
+
store.save(makeRecord({ paused: true }));
|
|
461
|
+
const result = resumeAllExecutions(store, {
|
|
462
|
+
probe: pausedProbe,
|
|
463
|
+
runner: fakeDocker('paused').runner,
|
|
464
|
+
outputFormat: 'json',
|
|
465
|
+
startWatcher: () => {
|
|
466
|
+
throw new Error('must not watch');
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
expect(result.success).toBe(true);
|
|
470
|
+
const summary = JSON.parse(result.output);
|
|
471
|
+
expect(summary.executions[0]).toMatchObject({
|
|
472
|
+
uuid: UUID,
|
|
473
|
+
state: 'paused',
|
|
474
|
+
action: 'paused',
|
|
475
|
+
});
|
|
476
|
+
expect(summary.executions[0].message).toContain('Skipped paused container');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('--cleanup <id> retains a paused container', () => {
|
|
480
|
+
store.save(makeRecord({ paused: true }));
|
|
481
|
+
const docker = fakeDocker('paused');
|
|
482
|
+
const result = cleanupExecution(store, NAME, { runner: docker.runner });
|
|
483
|
+
expect(result.success).toBe(false);
|
|
484
|
+
expect(result.output).toBe(`Retained container ${NAME} (paused)`);
|
|
485
|
+
expect(docker.ran('rm')).toHaveLength(0);
|
|
486
|
+
expect(docker.ran('rmi')).toHaveLength(0);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
for (const action of ['stop', 'terminate']) {
|
|
490
|
+
it(`--${action} counts as intentional and clears the pause`, () => {
|
|
491
|
+
store.save(makeRecord({ onKillResume: 3 }));
|
|
492
|
+
const docker = fakeDocker('running');
|
|
493
|
+
pause.pauseExecution(store, NAME, { runner: docker.runner });
|
|
494
|
+
const result = controlExecution(store, NAME, action, docker.runner);
|
|
495
|
+
expect(result.success).toBe(true);
|
|
496
|
+
const opts = store.get(UUID).options;
|
|
497
|
+
expect(opts.paused).toBe(false);
|
|
498
|
+
expect(opts.pauseEndedBy).toBe(action);
|
|
499
|
+
expect(Date.parse(opts.stopRequestedAt)).not.toBeNaN();
|
|
500
|
+
|
|
501
|
+
const recovery = recoverKilledExecution({
|
|
502
|
+
store,
|
|
503
|
+
executionId: UUID,
|
|
504
|
+
exitCode: 137,
|
|
505
|
+
oomKilled: false,
|
|
506
|
+
runner: docker.runner,
|
|
507
|
+
});
|
|
508
|
+
expect(recovery).toEqual({
|
|
509
|
+
recovered: false,
|
|
510
|
+
reason: 'stop-requested',
|
|
511
|
+
});
|
|
512
|
+
expect(store.getAll()).toHaveLength(1);
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
it('stop intent is recorded for a paused execution without --on-kill-resume', () => {
|
|
517
|
+
store.save(makeRecord());
|
|
518
|
+
const docker = fakeDocker('running');
|
|
519
|
+
pause.pauseExecution(store, NAME, { runner: docker.runner });
|
|
520
|
+
controlExecution(store, NAME, 'stop', docker.runner);
|
|
521
|
+
expect(store.get(UUID).options.stopRequestedAt).toBeDefined();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('a paused execution killed from outside is never auto-recovered', () => {
|
|
525
|
+
store.save(makeRecord({ onKillResume: 3, paused: true }));
|
|
526
|
+
const recovery = recoverKilledExecution({
|
|
527
|
+
store,
|
|
528
|
+
executionId: UUID,
|
|
529
|
+
exitCode: 137,
|
|
530
|
+
oomKilled: false,
|
|
531
|
+
runner: fakeDocker('exited').runner,
|
|
532
|
+
});
|
|
533
|
+
expect(recovery).toEqual({ recovered: false, reason: 'paused' });
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Fake `docker` binary for code that spawns docker itself (status probes,
|
|
539
|
+
* cleanupStale): `state` is the JSON of the one container, or "removed".
|
|
540
|
+
*/
|
|
541
|
+
function withDockerBinary(state, fn) {
|
|
542
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-docker-263-'));
|
|
543
|
+
const stateFile = path.join(dir, 'state.json');
|
|
544
|
+
fs.writeFileSync(stateFile, JSON.stringify(state));
|
|
545
|
+
const script = path.join(dir, 'docker.js');
|
|
546
|
+
fs.writeFileSync(
|
|
547
|
+
script,
|
|
548
|
+
`const fs = require('fs');
|
|
549
|
+
let args = process.argv.slice(2);
|
|
550
|
+
const c = JSON.parse(fs.readFileSync(${JSON.stringify(stateFile)}, 'utf8'));
|
|
551
|
+
if (args[0] !== 'inspect') process.exit(1);
|
|
552
|
+
args = args.slice(1);
|
|
553
|
+
if (args[0] === '--type') args = args.slice(2);
|
|
554
|
+
// On Windows START_DOCKER_BIN runs through cmd.exe, which splits the format
|
|
555
|
+
// at its spaces: its words are the ones holding a {{.Field}} placeholder.
|
|
556
|
+
let next = args.indexOf('-f') + 1;
|
|
557
|
+
const words = [];
|
|
558
|
+
while (next < args.length && args[next].includes('}}')) words.push(args[next++]);
|
|
559
|
+
const format = words.join(' ');
|
|
560
|
+
const targets = args.slice(next);
|
|
561
|
+
let missing = false;
|
|
562
|
+
for (const t of targets) {
|
|
563
|
+
if (c === 'removed' || (t !== c['.Name'].slice(1) && !c['.Id'].startsWith(t))) {
|
|
564
|
+
missing = true;
|
|
565
|
+
process.stderr.write('Error response from daemon: No such container: ' + t + '\\n');
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
process.stdout.write(format.replace(/\\{\\{\\s*(\\.[A-Za-z.]+)\\s*\\}\\}/g, (_, f) => (f in c ? c[f] : '')) + '\\n');
|
|
569
|
+
}
|
|
570
|
+
process.exit(missing ? 1 : 0);
|
|
571
|
+
`
|
|
572
|
+
);
|
|
573
|
+
const windows = process.platform === 'win32';
|
|
574
|
+
const bin = path.join(dir, windows ? 'docker.cmd' : 'docker');
|
|
575
|
+
fs.writeFileSync(
|
|
576
|
+
bin,
|
|
577
|
+
windows
|
|
578
|
+
? `@echo off\r\n"${process.execPath}" "${script}" %*\r\nexit /b %errorlevel%\r\n`
|
|
579
|
+
: `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} "$@"\n`
|
|
580
|
+
);
|
|
581
|
+
fs.chmodSync(bin, 0o755);
|
|
582
|
+
const original = process.env.START_DOCKER_BIN;
|
|
583
|
+
process.env.START_DOCKER_BIN = bin;
|
|
584
|
+
try {
|
|
585
|
+
return fn();
|
|
586
|
+
} finally {
|
|
587
|
+
if (original === undefined) {
|
|
588
|
+
delete process.env.START_DOCKER_BIN;
|
|
589
|
+
} else {
|
|
590
|
+
process.env.START_DOCKER_BIN = original;
|
|
591
|
+
}
|
|
592
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const container = (status) => ({
|
|
597
|
+
'.Name': `/${NAME}`,
|
|
598
|
+
'.Id': 'abc263def',
|
|
599
|
+
'.State.Pid': status === 'exited' ? '0' : '4242',
|
|
600
|
+
'.State.Running': String(status === 'running' || status === 'paused'),
|
|
601
|
+
'.State.ExitCode': status === 'exited' ? '137' : '0',
|
|
602
|
+
'.State.OOMKilled': 'false',
|
|
603
|
+
'.State.StartedAt': '2026-11-13T08:00:00Z',
|
|
604
|
+
'.State.FinishedAt':
|
|
605
|
+
status === 'exited' ? '2026-11-13T08:05:00Z' : '0001-01-01T00:00:00Z',
|
|
606
|
+
'.State.Status': status,
|
|
607
|
+
'.State.Paused': String(status === 'paused'),
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
describe('issue #263: --status, --list and reconciliation', () => {
|
|
611
|
+
it('--status reports a paused execution as executing, with pause fields', () => {
|
|
612
|
+
store.save(makeRecord({ paused: true, pausedAt: '2026-11-13T08:01:00Z' }));
|
|
613
|
+
const obj = withDockerBinary(container('paused'), () =>
|
|
614
|
+
JSON.parse(queryStatus(store, NAME, 'json').output)
|
|
615
|
+
);
|
|
616
|
+
expect(obj.status).toBe('executing');
|
|
617
|
+
expect(obj.dockerState).toBe('paused');
|
|
618
|
+
expect(obj.paused).toBe(true);
|
|
619
|
+
expect(obj.pausedAt).toBe('2026-11-13T08:01:00Z');
|
|
620
|
+
expect(obj.exitCode).toBeNull();
|
|
621
|
+
const keys = Object.keys(obj);
|
|
622
|
+
expect(keys.indexOf('dockerState')).toBe(keys.indexOf('status') + 1);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('--status text shows the docker state and pause', () => {
|
|
626
|
+
store.save(makeRecord({ paused: true, pausedAt: '2026-11-13T08:01:00Z' }));
|
|
627
|
+
const text = withDockerBinary(
|
|
628
|
+
container('paused'),
|
|
629
|
+
() => queryStatus(store, NAME, 'text').output
|
|
630
|
+
);
|
|
631
|
+
expect(text).toContain('Docker State: paused');
|
|
632
|
+
expect(text).toContain('Paused: true');
|
|
633
|
+
expect(text).toContain('Paused At: 2026-11-13T08:01:00Z');
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('--list and --list --running include paused executions', () => {
|
|
637
|
+
store.save(makeRecord({ paused: true, pausedAt: '2026-11-13T08:01:00Z' }));
|
|
638
|
+
for (const running of [false, true]) {
|
|
639
|
+
const list = withDockerBinary(container('paused'), () =>
|
|
640
|
+
JSON.parse(listExecutions(store, 'json', { running }).output)
|
|
641
|
+
);
|
|
642
|
+
const rows = Array.isArray(list) ? list : list.executions;
|
|
643
|
+
expect(rows).toHaveLength(1);
|
|
644
|
+
expect(rows[0]).toMatchObject({
|
|
645
|
+
uuid: UUID,
|
|
646
|
+
status: 'executing',
|
|
647
|
+
dockerState: 'paused',
|
|
648
|
+
paused: true,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('a stale paused:true is reconciled when docker says it exited', () => {
|
|
654
|
+
store.save(makeRecord({ paused: true, pausedAt: '2026-11-13T08:01:00Z' }));
|
|
655
|
+
const obj = withDockerBinary(container('exited'), () =>
|
|
656
|
+
JSON.parse(queryStatus(store, NAME, 'json').output)
|
|
657
|
+
);
|
|
658
|
+
expect(obj.dockerState).toBe('exited');
|
|
659
|
+
expect(obj.paused).toBe(false);
|
|
660
|
+
expect(obj.status).not.toBe('executing');
|
|
661
|
+
expect(store.get(UUID).options).toMatchObject({
|
|
662
|
+
paused: false,
|
|
663
|
+
pauseEndedBy: 'reconcile',
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('a stale paused:true is reconciled when docker says it is running', () => {
|
|
668
|
+
store.save(makeRecord({ paused: true, pausedAt: '2026-11-13T08:01:00Z' }));
|
|
669
|
+
const obj = withDockerBinary(container('running'), () =>
|
|
670
|
+
JSON.parse(queryStatus(store, NAME, 'json').output)
|
|
671
|
+
);
|
|
672
|
+
expect(obj).toMatchObject({
|
|
673
|
+
status: 'executing',
|
|
674
|
+
dockerState: 'running',
|
|
675
|
+
paused: false,
|
|
676
|
+
});
|
|
677
|
+
expect(store.get(UUID).options.paused).toBe(false);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('a removed container (reboot) shows as removed, not paused', () => {
|
|
681
|
+
store.save(makeRecord({ paused: true, pausedAt: '2026-11-13T08:01:00Z' }));
|
|
682
|
+
const obj = withDockerBinary('removed', () =>
|
|
683
|
+
JSON.parse(queryStatus(store, NAME, 'json').output)
|
|
684
|
+
);
|
|
685
|
+
expect(obj.dockerState).toBe('removed');
|
|
686
|
+
expect(obj.paused).toBe(false);
|
|
687
|
+
expect(store.get(UUID).options.paused).toBe(false);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('a pause made outside start-command is adopted by --status', () => {
|
|
691
|
+
store.save(makeRecord());
|
|
692
|
+
const obj = withDockerBinary(container('paused'), () =>
|
|
693
|
+
JSON.parse(queryStatus(store, NAME, 'json').output)
|
|
694
|
+
);
|
|
695
|
+
expect(obj.paused).toBe(true);
|
|
696
|
+
expect(store.get(UUID).options.paused).toBe(true);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('keeps the recorded pause when docker cannot be asked', () => {
|
|
700
|
+
const record = makeRecord({ paused: true, pausedAt: 'p' });
|
|
701
|
+
const fields = pause.observedPauseFields(record, {});
|
|
702
|
+
expect(fields).toEqual({
|
|
703
|
+
dockerState: 'unknown',
|
|
704
|
+
paused: true,
|
|
705
|
+
pausedAt: 'p',
|
|
706
|
+
});
|
|
707
|
+
expect(
|
|
708
|
+
pause.observedPauseFields(makeRecord({ isolated: 'screen' }), {})
|
|
709
|
+
).toBeNull();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('cleanupStale never cleans a docker-confirmed paused execution', () => {
|
|
713
|
+
store.save(makeRecord({ paused: true }, { platform: process.platform }));
|
|
714
|
+
const kept = withDockerBinary(container('paused'), () =>
|
|
715
|
+
store.cleanupStale()
|
|
716
|
+
);
|
|
717
|
+
expect(kept.cleaned).toBe(0);
|
|
718
|
+
expect(store.get(UUID).status).toBe(ExecutionStatus.EXECUTING);
|
|
719
|
+
// A stale recorded pause does not hide the dead record forever.
|
|
720
|
+
const cleaned = withDockerBinary('removed', () => store.cleanupStale());
|
|
721
|
+
expect(cleaned.cleaned).toBe(1);
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
describe('issue #263: detached watcher', () => {
|
|
726
|
+
it('keeps waiting while the container is paused and stops once it exits', () => {
|
|
727
|
+
const {
|
|
728
|
+
buildDockerWaitForExitSnippet,
|
|
729
|
+
} = require('../src/lib/docker-post-mortem');
|
|
730
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'watcher-263-'));
|
|
731
|
+
const stateFile = path.join(dir, 'state');
|
|
732
|
+
// Docker keeps `.State.Running=true` for a paused container.
|
|
733
|
+
fs.writeFileSync(stateFile, 'true');
|
|
734
|
+
fs.writeFileSync(
|
|
735
|
+
path.join(dir, 'docker'),
|
|
736
|
+
`#!/bin/sh\nif [ "$1" = inspect ]; then cat ${JSON.stringify(stateFile)}; echo; exit 0; fi\nsleep 0.1; exit 1\n`
|
|
737
|
+
);
|
|
738
|
+
fs.chmodSync(path.join(dir, 'docker'), 0o755);
|
|
739
|
+
const env = { ...process.env, PATH: `${dir}:${process.env.PATH}` };
|
|
740
|
+
const snippet = buildDockerWaitForExitSnippet(NAME);
|
|
741
|
+
try {
|
|
742
|
+
const paused = spawnSync('sh', ['-c', snippet], {
|
|
743
|
+
env,
|
|
744
|
+
timeout: 1500,
|
|
745
|
+
});
|
|
746
|
+
// Still in its wait loop when the timeout killed it: not completed.
|
|
747
|
+
expect(paused.signal).toBe('SIGTERM');
|
|
748
|
+
fs.writeFileSync(stateFile, 'false');
|
|
749
|
+
const exited = spawnSync('sh', ['-c', snippet], {
|
|
750
|
+
env,
|
|
751
|
+
timeout: 10000,
|
|
752
|
+
});
|
|
753
|
+
expect(exited.status).toBe(0);
|
|
754
|
+
} finally {
|
|
755
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
const dockerReady = (() => {
|
|
761
|
+
try {
|
|
762
|
+
return canRunLinuxDockerImages();
|
|
763
|
+
} catch {
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
})();
|
|
767
|
+
|
|
768
|
+
describe('issue #263: real docker', () => {
|
|
769
|
+
it.skipIf(!dockerReady)(
|
|
770
|
+
'pause/unpause keeps the same process and the CLI reports it',
|
|
771
|
+
() => {
|
|
772
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'start-263-e2e-'));
|
|
773
|
+
const name = `t263-${process.pid}`;
|
|
774
|
+
const env = {
|
|
775
|
+
...process.env,
|
|
776
|
+
HOME: root,
|
|
777
|
+
START_APP_FOLDER: path.join(root, 'app'),
|
|
778
|
+
START_LOG_DIR: path.join(root, 'logs'),
|
|
779
|
+
START_DISABLE_AUTO_ISSUE: '1',
|
|
780
|
+
START_DISABLE_LOG_UPLOAD: '1',
|
|
781
|
+
START_EXECUTION_ID: '',
|
|
782
|
+
};
|
|
783
|
+
delete env.START_DOCKER_BIN;
|
|
784
|
+
const cli = (...args) =>
|
|
785
|
+
spawnSync(
|
|
786
|
+
process.execPath,
|
|
787
|
+
[path.join(__dirname, '../src/bin/cli.js'), ...args],
|
|
788
|
+
{ encoding: 'utf8', timeout: 60000, env }
|
|
789
|
+
);
|
|
790
|
+
const docker = (...args) =>
|
|
791
|
+
(
|
|
792
|
+
spawnSync('docker', args, { encoding: 'utf8', timeout: 60000 })
|
|
793
|
+
.stdout || ''
|
|
794
|
+
).trim();
|
|
795
|
+
try {
|
|
796
|
+
const launched = cli(
|
|
797
|
+
'--isolated',
|
|
798
|
+
'docker',
|
|
799
|
+
'--detached',
|
|
800
|
+
'--image',
|
|
801
|
+
'alpine:3.23',
|
|
802
|
+
'--session',
|
|
803
|
+
name,
|
|
804
|
+
'--',
|
|
805
|
+
'sleep 300'
|
|
806
|
+
);
|
|
807
|
+
expect(launched.status).toBe(0);
|
|
808
|
+
const pidBefore = docker('inspect', '-f', '{{.State.Pid}}', name);
|
|
809
|
+
expect(Number(pidBefore)).toBeGreaterThan(0);
|
|
810
|
+
|
|
811
|
+
const paused = cli('--pause', name, '--output-format', 'json');
|
|
812
|
+
expect(paused.status).toBe(0);
|
|
813
|
+
expect(JSON.parse(paused.stdout)).toMatchObject({
|
|
814
|
+
status: 'paused',
|
|
815
|
+
paused: true,
|
|
816
|
+
});
|
|
817
|
+
expect(docker('inspect', '-f', '{{.State.Status}}', name)).toBe(
|
|
818
|
+
'paused'
|
|
819
|
+
);
|
|
820
|
+
expect(cli('--pause', name).stdout).toContain('already-paused');
|
|
821
|
+
|
|
822
|
+
const status = JSON.parse(
|
|
823
|
+
cli('--status', name, '--output-format', 'json').stdout
|
|
824
|
+
);
|
|
825
|
+
expect(status).toMatchObject({
|
|
826
|
+
status: 'executing',
|
|
827
|
+
dockerState: 'paused',
|
|
828
|
+
paused: true,
|
|
829
|
+
});
|
|
830
|
+
const running = cli('--list', '--running', '--output-format', 'json');
|
|
831
|
+
expect(running.stdout).toContain(name);
|
|
832
|
+
|
|
833
|
+
const attach = cli('--attach', name);
|
|
834
|
+
expect(attach.status).not.toBe(0);
|
|
835
|
+
expect(attach.stderr + attach.stdout).toContain('--unpause');
|
|
836
|
+
const resume = cli('--resume', name);
|
|
837
|
+
expect(resume.status).not.toBe(0);
|
|
838
|
+
expect(resume.stderr + resume.stdout).toContain('--unpause');
|
|
839
|
+
expect(cli('--cleanup', name).status).not.toBe(0);
|
|
840
|
+
expect(docker('inspect', '-f', '{{.State.Status}}', name)).toBe(
|
|
841
|
+
'paused'
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
const unpaused = cli('--unpause', name, '--output-format', 'json');
|
|
845
|
+
expect(unpaused.status).toBe(0);
|
|
846
|
+
expect(JSON.parse(unpaused.stdout).status).toBe('unpaused');
|
|
847
|
+
expect(docker('inspect', '-f', '{{.State.Pid}}', name)).toBe(pidBefore);
|
|
848
|
+
expect(cli('--unpause', name).stdout).toContain('not-paused');
|
|
849
|
+
|
|
850
|
+
expect(cli('--pause', name).status).toBe(0);
|
|
851
|
+
expect(cli('--terminate', name).status).toBe(0);
|
|
852
|
+
const ended = JSON.parse(
|
|
853
|
+
cli('--status', name, '--output-format', 'json').stdout
|
|
854
|
+
);
|
|
855
|
+
expect(ended.paused).toBe(false);
|
|
856
|
+
expect(ended.options.stopRequestedAt).toBeDefined();
|
|
857
|
+
const again = cli('--unpause', name);
|
|
858
|
+
expect(again.status).not.toBe(0);
|
|
859
|
+
expect(again.stderr).toContain('--resume');
|
|
860
|
+
} finally {
|
|
861
|
+
spawnSync('docker', ['rm', '-f', name], { timeout: 60000 });
|
|
862
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
863
|
+
}
|
|
864
|
+
},
|
|
865
|
+
180000
|
|
866
|
+
);
|
|
867
|
+
});
|