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,701 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `$ --pause <id>` and `$ --unpause <id>` (issue #263): freeze and thaw a
|
|
3
|
+
* detached Docker execution with the cgroup freezer.
|
|
4
|
+
*
|
|
5
|
+
* `docker pause` stops every process of the container from being scheduled,
|
|
6
|
+
* so CPU is released, while the processes, their memory, the container, its
|
|
7
|
+
* workspace and its log all stay exactly where they are. `docker unpause`
|
|
8
|
+
* continues the very same processes: there is no new container, command,
|
|
9
|
+
* snapshot, execution or recovery attempt. To get the memory back, `--stop`
|
|
10
|
+
* the execution instead.
|
|
11
|
+
*
|
|
12
|
+
* Only Docker can freeze a session; screen and tmux are refused. The pause is
|
|
13
|
+
* recorded on the execution as `options.paused`, `pausedAt`, `pauseCount` and
|
|
14
|
+
* the optional `pausedBy` / `pauseReason`, but Docker stays the source of
|
|
15
|
+
* truth: `--status`, `--list` and the controls reconcile a stale record from
|
|
16
|
+
* `docker inspect` (after a daemon restart, a reboot or a removal), so a stale
|
|
17
|
+
* `paused: true` can never hide a task.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { getDockerCommand } = require('./docker-cleanup');
|
|
21
|
+
const {
|
|
22
|
+
escapeForLinksNotation,
|
|
23
|
+
formatAsNestedLinksNotation,
|
|
24
|
+
} = require('./output-blocks');
|
|
25
|
+
const { collectProcessIds, runCommand } = require('./execution-control');
|
|
26
|
+
|
|
27
|
+
const PauseAction = {
|
|
28
|
+
PAUSE: 'pause',
|
|
29
|
+
UNPAUSE: 'unpause',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Bound for every docker call made here, so a hung daemon cannot hang us. */
|
|
33
|
+
const DOCKER_PAUSE_TIMEOUT_MS = 30000;
|
|
34
|
+
|
|
35
|
+
/** `docker inspect` template answering both the status and the freezer flag. */
|
|
36
|
+
const PAUSE_STATE_FORMAT = '{{.State.Status}} {{.State.Paused}}';
|
|
37
|
+
|
|
38
|
+
/** Docker states an execution can be reported in (plus `removed`/`unknown`). */
|
|
39
|
+
const DockerState = {
|
|
40
|
+
RUNNING: 'running',
|
|
41
|
+
PAUSED: 'paused',
|
|
42
|
+
EXITED: 'exited',
|
|
43
|
+
CREATED: 'created',
|
|
44
|
+
REMOVED: 'removed',
|
|
45
|
+
UNKNOWN: 'unknown',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Pause bookkeeping kept in `options`; cleared when the pause ends. */
|
|
49
|
+
const PAUSE_FIELDS = ['pausedAt', 'pausedBy', 'pauseReason'];
|
|
50
|
+
|
|
51
|
+
const NOT_FOUND_PATTERN = /no such (object|container)/i;
|
|
52
|
+
|
|
53
|
+
/** What a pause does to the container's resources, for callers' accounting. */
|
|
54
|
+
const PAUSED_RESOURCES = {
|
|
55
|
+
cpu: 'released',
|
|
56
|
+
memory: 'retained',
|
|
57
|
+
disk: 'retained',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function failureDetail(result) {
|
|
61
|
+
return (
|
|
62
|
+
String(result?.stderr || '').trim() ||
|
|
63
|
+
result?.error ||
|
|
64
|
+
`exit code ${result?.status}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse `docker inspect -f '{{.State.Status}} {{.State.Paused}}'` output.
|
|
70
|
+
* @param {string} stdout - Inspect output
|
|
71
|
+
* @returns {{state: string, paused: boolean}} Docker state
|
|
72
|
+
*/
|
|
73
|
+
function parsePauseState(stdout) {
|
|
74
|
+
const [statusRaw, pausedRaw] = String(stdout || '')
|
|
75
|
+
.trim()
|
|
76
|
+
.split(/\s+/);
|
|
77
|
+
const state = String(statusRaw || '').toLowerCase() || DockerState.UNKNOWN;
|
|
78
|
+
const paused = pausedRaw === 'true' || state === DockerState.PAUSED;
|
|
79
|
+
return { state: paused ? DockerState.PAUSED : state, paused };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Ask Docker for the real state of a container.
|
|
84
|
+
* @param {string} containerName - Container name or id
|
|
85
|
+
* @param {Function} [runner] - Command runner (injectable for tests)
|
|
86
|
+
* @returns {{state: string, paused: boolean, detail?: string}} `removed` when
|
|
87
|
+
* Docker answers "no such container", `unknown` when it cannot be asked
|
|
88
|
+
*/
|
|
89
|
+
function inspectPauseState(containerName, runner = runCommand) {
|
|
90
|
+
const result = runner(
|
|
91
|
+
getDockerCommand(),
|
|
92
|
+
['inspect', '-f', PAUSE_STATE_FORMAT, containerName],
|
|
93
|
+
{ timeout: DOCKER_PAUSE_TIMEOUT_MS }
|
|
94
|
+
);
|
|
95
|
+
if (result.success) {
|
|
96
|
+
return parsePauseState(result.stdout);
|
|
97
|
+
}
|
|
98
|
+
const detail = failureDetail(result);
|
|
99
|
+
if (!result.error && NOT_FOUND_PATTERN.test(detail)) {
|
|
100
|
+
return { state: DockerState.REMOVED, paused: false };
|
|
101
|
+
}
|
|
102
|
+
return { state: DockerState.UNKNOWN, paused: false, detail };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a record is a detached Docker execution that Docker confirms is
|
|
107
|
+
* paused right now. A stale `options.paused` alone never counts.
|
|
108
|
+
* @param {object} record - Execution record
|
|
109
|
+
* @param {Function} [runner] - Command runner
|
|
110
|
+
* @returns {boolean} True only when `docker inspect` reports it paused
|
|
111
|
+
*/
|
|
112
|
+
function isConfirmedPaused(record, runner = runCommand) {
|
|
113
|
+
const opts = (record && record.options) || {};
|
|
114
|
+
if (
|
|
115
|
+
opts.isolated !== 'docker' ||
|
|
116
|
+
opts.isolationMode !== 'detached' ||
|
|
117
|
+
!opts.sessionName ||
|
|
118
|
+
record.containerState === DockerState.REMOVED
|
|
119
|
+
) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return inspectPauseState(opts.sessionName, runner).paused;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Options of a record whose pause just ended.
|
|
127
|
+
* @param {object} options - Current options
|
|
128
|
+
* @param {string} endedBy - unpause, stop, terminate or reconcile
|
|
129
|
+
* @param {string} [at] - When the pause ended
|
|
130
|
+
* @returns {object} New options (the input is not mutated)
|
|
131
|
+
*/
|
|
132
|
+
function clearedPauseOptions(options, endedBy, at = new Date().toISOString()) {
|
|
133
|
+
const next = { ...(options || {}) };
|
|
134
|
+
for (const field of PAUSE_FIELDS) {
|
|
135
|
+
delete next[field];
|
|
136
|
+
}
|
|
137
|
+
next.paused = false;
|
|
138
|
+
next.unpausedAt = at;
|
|
139
|
+
next.pauseEndedBy = endedBy;
|
|
140
|
+
return next;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Options of a record that was just paused.
|
|
145
|
+
* @param {object} options - Current options
|
|
146
|
+
* @param {{reason?: ?string, by?: ?string, at?: string}} details - Who and why
|
|
147
|
+
* @returns {object} New options (the input is not mutated)
|
|
148
|
+
*/
|
|
149
|
+
function pausedOptions(options, details = {}) {
|
|
150
|
+
const next = { ...(options || {}) };
|
|
151
|
+
delete next.unpausedAt;
|
|
152
|
+
delete next.pauseEndedBy;
|
|
153
|
+
delete next.pausedBy;
|
|
154
|
+
delete next.pauseReason;
|
|
155
|
+
next.paused = true;
|
|
156
|
+
next.pausedAt = details.at || new Date().toISOString();
|
|
157
|
+
next.pauseCount = (Number(next.pauseCount) || 0) + 1;
|
|
158
|
+
if (details.by) {
|
|
159
|
+
next.pausedBy = details.by;
|
|
160
|
+
}
|
|
161
|
+
if (details.reason) {
|
|
162
|
+
next.pauseReason = details.reason;
|
|
163
|
+
}
|
|
164
|
+
return next;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Re-read the record and save new options on it, best effort for
|
|
169
|
+
* reconciliation and strict for the pause/unpause transitions themselves.
|
|
170
|
+
* @returns {object} The saved record
|
|
171
|
+
*/
|
|
172
|
+
function saveOptions(store, record, update, strict = true) {
|
|
173
|
+
let current = record;
|
|
174
|
+
try {
|
|
175
|
+
current = store.get(record.uuid) || record;
|
|
176
|
+
} catch {
|
|
177
|
+
// Fall back to the record we already have.
|
|
178
|
+
}
|
|
179
|
+
current.options = update(current.options || {});
|
|
180
|
+
try {
|
|
181
|
+
store.save(current);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (strict) {
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return current;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Forget a pause that Docker no longer confirms (issue #263): a container that
|
|
192
|
+
* was stopped, removed, restarted by the daemon or thawed by hand.
|
|
193
|
+
* @returns {object} The (possibly updated) record
|
|
194
|
+
*/
|
|
195
|
+
function reconcileStalePause(store, record, endedBy = 'reconcile') {
|
|
196
|
+
if (!store || !record.options || record.options.paused !== true) {
|
|
197
|
+
return record;
|
|
198
|
+
}
|
|
199
|
+
return saveOptions(
|
|
200
|
+
store,
|
|
201
|
+
record,
|
|
202
|
+
(options) => clearedPauseOptions(options, endedBy),
|
|
203
|
+
false
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* End the recorded pause of an execution that `--stop` / `--terminate` just
|
|
209
|
+
* ended (issue #263). No-op when it was not paused.
|
|
210
|
+
* @param {object} store - Execution store
|
|
211
|
+
* @param {object} record - Execution record
|
|
212
|
+
* @param {string} action - stop or terminate
|
|
213
|
+
*/
|
|
214
|
+
function clearPauseAfterControl(store, record, action) {
|
|
215
|
+
if (record?.options?.paused === true) {
|
|
216
|
+
reconcileStalePause(store, record, action);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Validate that an execution can be paused/unpaused at all.
|
|
222
|
+
* @returns {{record?: object, error?: string}} Record, or the refusal
|
|
223
|
+
*/
|
|
224
|
+
function resolvePauseTarget(store, identifier, action) {
|
|
225
|
+
if (!store) {
|
|
226
|
+
return { error: 'Execution tracking is disabled.' };
|
|
227
|
+
}
|
|
228
|
+
const record = store.get(identifier);
|
|
229
|
+
if (!record) {
|
|
230
|
+
return {
|
|
231
|
+
error: `No execution found with UUID or session name: ${identifier}`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const opts = record.options || {};
|
|
235
|
+
if (opts.isolated !== 'docker') {
|
|
236
|
+
return {
|
|
237
|
+
error:
|
|
238
|
+
`--${action} only supports Docker isolation: ${identifier} uses ${opts.isolated || 'no isolation'}. ` +
|
|
239
|
+
'screen and tmux sessions cannot be frozen; use --stop or --terminate instead.',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (!opts.sessionName) {
|
|
243
|
+
return {
|
|
244
|
+
error: 'Execution record does not contain an isolation session name.',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (opts.isolationMode !== 'detached') {
|
|
248
|
+
return {
|
|
249
|
+
error: `Only detached Docker executions can be ${action}d.`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { record };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function notPausableError(action, identifier, record, dockerState) {
|
|
256
|
+
const name = record.options.sessionName;
|
|
257
|
+
const what =
|
|
258
|
+
dockerState.state === DockerState.REMOVED
|
|
259
|
+
? 'no longer exists (removed)'
|
|
260
|
+
: `is ${dockerState.state}, not ${action === PauseAction.PAUSE ? 'running' : 'paused'}`;
|
|
261
|
+
return (
|
|
262
|
+
`Cannot ${action} ${identifier}: container "${name}" ${what}. ` +
|
|
263
|
+
`Use \`$ --status ${record.uuid}\` to inspect it, or \`$ --resume ${record.uuid}\` to continue it.`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Report of one pause/unpause, in every output format.
|
|
269
|
+
* @param {object} result - {action, identifier, status, record, method,
|
|
270
|
+
* dockerState, processIds, message}
|
|
271
|
+
* @returns {object} Plain report object
|
|
272
|
+
*/
|
|
273
|
+
function buildPauseReport(result) {
|
|
274
|
+
const opts = result.record.options || {};
|
|
275
|
+
const report = {
|
|
276
|
+
action: result.action,
|
|
277
|
+
identifier: result.identifier,
|
|
278
|
+
uuid: result.record.uuid,
|
|
279
|
+
status: result.status,
|
|
280
|
+
backend: opts.isolated,
|
|
281
|
+
sessionName: opts.sessionName,
|
|
282
|
+
method: result.method,
|
|
283
|
+
dockerState: result.dockerState,
|
|
284
|
+
paused: result.dockerState === DockerState.PAUSED,
|
|
285
|
+
};
|
|
286
|
+
if (report.paused) {
|
|
287
|
+
report.pausedAt = opts.pausedAt || null;
|
|
288
|
+
report.pauseCount = Number(opts.pauseCount) || 0;
|
|
289
|
+
if (opts.pausedBy) {
|
|
290
|
+
report.pausedBy = opts.pausedBy;
|
|
291
|
+
}
|
|
292
|
+
if (opts.pauseReason) {
|
|
293
|
+
report.pauseReason = opts.pauseReason;
|
|
294
|
+
}
|
|
295
|
+
report.resources = { ...PAUSED_RESOURCES };
|
|
296
|
+
} else if (opts.unpausedAt) {
|
|
297
|
+
report.unpausedAt = opts.unpausedAt;
|
|
298
|
+
}
|
|
299
|
+
if (result.processIds) {
|
|
300
|
+
report.processIds = result.processIds;
|
|
301
|
+
}
|
|
302
|
+
report.message = result.message;
|
|
303
|
+
return report;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function formatPauseReportAsLinksNotation(report) {
|
|
307
|
+
const lines = ['executionControl'];
|
|
308
|
+
for (const [key, value] of Object.entries(report)) {
|
|
309
|
+
if (value !== null && typeof value === 'object') {
|
|
310
|
+
lines.push(` ${key}`);
|
|
311
|
+
lines.push(formatAsNestedLinksNotation(value, 2, 2));
|
|
312
|
+
} else {
|
|
313
|
+
lines.push(` ${key} ${escapeForLinksNotation(value)}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return lines.join('\n');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function formatPauseReportAsText(report) {
|
|
320
|
+
const lines = [
|
|
321
|
+
report.message,
|
|
322
|
+
`UUID: ${report.uuid}`,
|
|
323
|
+
`Container: ${report.sessionName}`,
|
|
324
|
+
`Docker State: ${report.dockerState}`,
|
|
325
|
+
`Paused: ${report.paused}`,
|
|
326
|
+
];
|
|
327
|
+
if (report.paused) {
|
|
328
|
+
lines.push(`Paused At: ${report.pausedAt || 'N/A'}`);
|
|
329
|
+
lines.push(`Pause Count: ${report.pauseCount}`);
|
|
330
|
+
lines.push(
|
|
331
|
+
`Resources: CPU ${report.resources.cpu}, memory ${report.resources.memory}, disk ${report.resources.disk}`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
if (report.processIds?.containerPid) {
|
|
335
|
+
lines.push(`Container PID: ${report.processIds.containerPid}`);
|
|
336
|
+
}
|
|
337
|
+
return lines.join('\n');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Render a pause/unpause report.
|
|
342
|
+
* @param {object} report - From `buildPauseReport`
|
|
343
|
+
* @param {?string} outputFormat - links-notation (default), json or text
|
|
344
|
+
* @returns {string} Output
|
|
345
|
+
*/
|
|
346
|
+
function formatPauseReport(report, outputFormat) {
|
|
347
|
+
if (outputFormat === 'json') {
|
|
348
|
+
return JSON.stringify(report, null, 2);
|
|
349
|
+
}
|
|
350
|
+
if (outputFormat === 'text') {
|
|
351
|
+
return formatPauseReportAsText(report);
|
|
352
|
+
}
|
|
353
|
+
return formatPauseReportAsLinksNotation(report);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function success(fields, deps) {
|
|
357
|
+
const record = fields.record;
|
|
358
|
+
// Every docker call of --pause/--unpause is bounded, the PID lookup too.
|
|
359
|
+
const processIds = collectProcessIds(record, (command, args, extra) =>
|
|
360
|
+
deps.runner(command, args, { timeout: DOCKER_PAUSE_TIMEOUT_MS, ...extra })
|
|
361
|
+
);
|
|
362
|
+
return {
|
|
363
|
+
success: true,
|
|
364
|
+
output: formatPauseReport(
|
|
365
|
+
buildPauseReport({ ...fields, processIds }),
|
|
366
|
+
deps.outputFormat
|
|
367
|
+
),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function runDocker(runner, args) {
|
|
372
|
+
return runner(getDockerCommand(), args, {
|
|
373
|
+
timeout: DOCKER_PAUSE_TIMEOUT_MS,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Handle a container Docker no longer has: record the removal and drop a
|
|
379
|
+
* stale pause, then fail with a recovery path.
|
|
380
|
+
*/
|
|
381
|
+
function failRemoved(store, action, identifier, record, dockerState) {
|
|
382
|
+
if (typeof store.markContainerRemoved === 'function') {
|
|
383
|
+
try {
|
|
384
|
+
store.markContainerRemoved(record.uuid, new Date().toISOString());
|
|
385
|
+
} catch {
|
|
386
|
+
// Reported either way; `--status` observes the removal again.
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
reconcileStalePause(store, record);
|
|
390
|
+
return {
|
|
391
|
+
success: false,
|
|
392
|
+
error: notPausableError(action, identifier, record, dockerState),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* `$ --pause <id>`: freeze a running detached Docker execution.
|
|
398
|
+
* @param {?object} store - Execution store
|
|
399
|
+
* @param {string} identifier - UUID or session name
|
|
400
|
+
* @param {object} [deps] - {runner, outputFormat, reason, by}
|
|
401
|
+
* @returns {{success: boolean, output?: string, error?: string}}
|
|
402
|
+
*/
|
|
403
|
+
function pauseExecution(store, identifier, deps = {}) {
|
|
404
|
+
const action = PauseAction.PAUSE;
|
|
405
|
+
const target = resolvePauseTarget(store, identifier, action);
|
|
406
|
+
if (target.error) {
|
|
407
|
+
return { success: false, error: target.error };
|
|
408
|
+
}
|
|
409
|
+
let record = target.record;
|
|
410
|
+
const runner = deps.runner || runCommand;
|
|
411
|
+
const context = { runner, outputFormat: deps.outputFormat };
|
|
412
|
+
const name = record.options.sessionName;
|
|
413
|
+
const dockerState = inspectPauseState(name, runner);
|
|
414
|
+
|
|
415
|
+
if (dockerState.state === DockerState.REMOVED) {
|
|
416
|
+
return failRemoved(store, action, identifier, record, dockerState);
|
|
417
|
+
}
|
|
418
|
+
if (dockerState.state === DockerState.UNKNOWN) {
|
|
419
|
+
return {
|
|
420
|
+
success: false,
|
|
421
|
+
error: `Cannot ${action} ${identifier}: docker inspect ${name} failed: ${dockerState.detail}`,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (dockerState.paused) {
|
|
425
|
+
if (record.options.paused !== true) {
|
|
426
|
+
// Paused outside start-command: adopt it so status and cleanup know.
|
|
427
|
+
record = saveOptions(store, record, (o) =>
|
|
428
|
+
pausedOptions(o, { by: deps.by, reason: deps.reason })
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return success(
|
|
432
|
+
{
|
|
433
|
+
action,
|
|
434
|
+
identifier,
|
|
435
|
+
record,
|
|
436
|
+
status: 'already-paused',
|
|
437
|
+
method: 'NONE',
|
|
438
|
+
dockerState: DockerState.PAUSED,
|
|
439
|
+
message: `Container ${name} is already paused; nothing changed.`,
|
|
440
|
+
},
|
|
441
|
+
context
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (dockerState.state !== DockerState.RUNNING) {
|
|
445
|
+
reconcileStalePause(store, record);
|
|
446
|
+
return {
|
|
447
|
+
success: false,
|
|
448
|
+
error: notPausableError(action, identifier, record, dockerState),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const result = runDocker(runner, ['pause', name]);
|
|
453
|
+
if (!result.success) {
|
|
454
|
+
return {
|
|
455
|
+
success: false,
|
|
456
|
+
error: `Failed to pause docker container "${name}": ${failureDetail(result)}`,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
record = saveOptions(store, record, (o) =>
|
|
460
|
+
pausedOptions(o, { by: deps.by, reason: deps.reason })
|
|
461
|
+
);
|
|
462
|
+
return success(
|
|
463
|
+
{
|
|
464
|
+
action,
|
|
465
|
+
identifier,
|
|
466
|
+
record,
|
|
467
|
+
status: 'paused',
|
|
468
|
+
method: 'DOCKER_PAUSE',
|
|
469
|
+
dockerState: DockerState.PAUSED,
|
|
470
|
+
message:
|
|
471
|
+
`Paused detached docker container: ${name}. CPU is released; memory stays allocated ` +
|
|
472
|
+
`(use \`$ --stop ${record.uuid}\` to free it). \`$ --unpause ${record.uuid}\` continues the same process.`,
|
|
473
|
+
},
|
|
474
|
+
context
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* `$ --unpause <id>`: thaw a paused detached Docker execution.
|
|
480
|
+
* @param {?object} store - Execution store
|
|
481
|
+
* @param {string} identifier - UUID or session name
|
|
482
|
+
* @param {object} [deps] - {runner, outputFormat}
|
|
483
|
+
* @returns {{success: boolean, output?: string, error?: string}}
|
|
484
|
+
*/
|
|
485
|
+
function unpauseExecution(store, identifier, deps = {}) {
|
|
486
|
+
const action = PauseAction.UNPAUSE;
|
|
487
|
+
const target = resolvePauseTarget(store, identifier, action);
|
|
488
|
+
if (target.error) {
|
|
489
|
+
return { success: false, error: target.error };
|
|
490
|
+
}
|
|
491
|
+
let record = target.record;
|
|
492
|
+
const runner = deps.runner || runCommand;
|
|
493
|
+
const context = { runner, outputFormat: deps.outputFormat };
|
|
494
|
+
const name = record.options.sessionName;
|
|
495
|
+
const dockerState = inspectPauseState(name, runner);
|
|
496
|
+
|
|
497
|
+
if (dockerState.state === DockerState.REMOVED) {
|
|
498
|
+
return failRemoved(store, action, identifier, record, dockerState);
|
|
499
|
+
}
|
|
500
|
+
if (dockerState.state === DockerState.UNKNOWN) {
|
|
501
|
+
return {
|
|
502
|
+
success: false,
|
|
503
|
+
error: `Cannot ${action} ${identifier}: docker inspect ${name} failed: ${dockerState.detail}`,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
if (dockerState.state === DockerState.RUNNING) {
|
|
507
|
+
// Thawed by hand or by a daemon restart: report the truth.
|
|
508
|
+
record = reconcileStalePause(store, record);
|
|
509
|
+
return success(
|
|
510
|
+
{
|
|
511
|
+
action,
|
|
512
|
+
identifier,
|
|
513
|
+
record,
|
|
514
|
+
status: 'not-paused',
|
|
515
|
+
method: 'NONE',
|
|
516
|
+
dockerState: DockerState.RUNNING,
|
|
517
|
+
message: `Container ${name} is running and not paused; nothing changed.`,
|
|
518
|
+
},
|
|
519
|
+
context
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
if (!dockerState.paused) {
|
|
523
|
+
reconcileStalePause(store, record);
|
|
524
|
+
return {
|
|
525
|
+
success: false,
|
|
526
|
+
error: notPausableError(action, identifier, record, dockerState),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const result = runDocker(runner, ['unpause', name]);
|
|
531
|
+
if (!result.success) {
|
|
532
|
+
return {
|
|
533
|
+
success: false,
|
|
534
|
+
error: `Failed to unpause docker container "${name}": ${failureDetail(result)}`,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
record = saveOptions(store, record, (o) =>
|
|
538
|
+
clearedPauseOptions(o, PauseAction.UNPAUSE)
|
|
539
|
+
);
|
|
540
|
+
return success(
|
|
541
|
+
{
|
|
542
|
+
action,
|
|
543
|
+
identifier,
|
|
544
|
+
record,
|
|
545
|
+
status: 'unpaused',
|
|
546
|
+
method: 'DOCKER_UNPAUSE',
|
|
547
|
+
dockerState: DockerState.RUNNING,
|
|
548
|
+
message: `Unpaused detached docker container: ${name}. The same process continues.`,
|
|
549
|
+
},
|
|
550
|
+
context
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* The pause fields `--status` and `--list` report for a detached Docker
|
|
556
|
+
* execution, from what the status probe saw.
|
|
557
|
+
* @param {object} record - Execution record (after status reconciliation)
|
|
558
|
+
* @param {object} probe - Status probe; `dockerState` is the inspected state
|
|
559
|
+
* and `sessionNotFound` says Docker answered "no such container"
|
|
560
|
+
* @returns {?{dockerState: string, paused: boolean, pausedAt?: string}} Fields,
|
|
561
|
+
* or null for records that are not detached Docker executions
|
|
562
|
+
*/
|
|
563
|
+
function observedPauseFields(record, probe) {
|
|
564
|
+
const opts = (record && record.options) || {};
|
|
565
|
+
if (opts.isolated !== 'docker' || opts.isolationMode !== 'detached') {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
const inspected = probe && probe.dockerState;
|
|
569
|
+
let dockerState;
|
|
570
|
+
if (record.containerState === DockerState.REMOVED || probe?.sessionNotFound) {
|
|
571
|
+
dockerState = DockerState.REMOVED;
|
|
572
|
+
} else if (inspected && inspected.status) {
|
|
573
|
+
dockerState = inspected.paused ? DockerState.PAUSED : inspected.status;
|
|
574
|
+
} else if (inspected) {
|
|
575
|
+
dockerState = inspected.running ? DockerState.RUNNING : DockerState.EXITED;
|
|
576
|
+
} else {
|
|
577
|
+
dockerState = DockerState.UNKNOWN;
|
|
578
|
+
}
|
|
579
|
+
// Unknown liveness (docker unreachable) keeps the recorded pause.
|
|
580
|
+
const paused =
|
|
581
|
+
dockerState === DockerState.UNKNOWN
|
|
582
|
+
? opts.paused === true
|
|
583
|
+
: dockerState === DockerState.PAUSED;
|
|
584
|
+
const fields = { dockerState, paused };
|
|
585
|
+
if (paused) {
|
|
586
|
+
fields.pausedAt = opts.pausedAt || null;
|
|
587
|
+
}
|
|
588
|
+
return fields;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Attach `dockerState`, `paused` and `pausedAt` to a status record, and make
|
|
593
|
+
* its recorded pause agree with Docker (issue #263). The input is not mutated.
|
|
594
|
+
* @param {object} record - Enriched execution record
|
|
595
|
+
* @param {object} probe - Status probe (see `observedPauseFields`)
|
|
596
|
+
* @returns {object} Record-like object whose `toObject()` carries the fields
|
|
597
|
+
*/
|
|
598
|
+
function attachPauseState(record, probe) {
|
|
599
|
+
const fields = observedPauseFields(record, probe);
|
|
600
|
+
if (!fields) {
|
|
601
|
+
return record;
|
|
602
|
+
}
|
|
603
|
+
const wrapped = Object.assign(
|
|
604
|
+
Object.create(Object.getPrototypeOf(record)),
|
|
605
|
+
record
|
|
606
|
+
);
|
|
607
|
+
const opts = record.options || {};
|
|
608
|
+
if (fields.dockerState !== DockerState.UNKNOWN) {
|
|
609
|
+
if (fields.paused && opts.paused !== true) {
|
|
610
|
+
wrapped.options = pausedOptions(opts);
|
|
611
|
+
fields.pausedAt = wrapped.options.pausedAt;
|
|
612
|
+
wrapped.pauseReconciled = true;
|
|
613
|
+
} else if (!fields.paused && opts.paused === true) {
|
|
614
|
+
wrapped.options = clearedPauseOptions(opts, 'reconcile');
|
|
615
|
+
wrapped.pauseReconciled = true;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
Object.assign(wrapped, fields);
|
|
619
|
+
// `this`, not `wrapped`: later enrichment copies the record (exit reason,
|
|
620
|
+
// attempt history) and those copies must still serialize their own fields.
|
|
621
|
+
const baseToObject = record.toObject;
|
|
622
|
+
wrapped.toObject = function () {
|
|
623
|
+
const ordered = {};
|
|
624
|
+
const base =
|
|
625
|
+
typeof baseToObject === 'function'
|
|
626
|
+
? baseToObject.call(this)
|
|
627
|
+
: { ...this };
|
|
628
|
+
for (const [key, value] of Object.entries(base)) {
|
|
629
|
+
ordered[key] = value;
|
|
630
|
+
if (key === 'status') {
|
|
631
|
+
Object.assign(ordered, fields);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return Object.assign(ordered, fields);
|
|
635
|
+
};
|
|
636
|
+
return wrapped;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Persist the pause reconciliation `--status` / `--list` observed, best
|
|
641
|
+
* effort: a read-only query must never fail because the store is busy.
|
|
642
|
+
* @param {?object} store - Execution store
|
|
643
|
+
* @param {object} reported - Record returned by `attachPauseState`
|
|
644
|
+
*/
|
|
645
|
+
function persistPauseReconciliation(store, reported) {
|
|
646
|
+
if (!store || !reported || reported.pauseReconciled !== true) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const target = reported.options || {};
|
|
650
|
+
saveOptions(
|
|
651
|
+
store,
|
|
652
|
+
reported,
|
|
653
|
+
(options) =>
|
|
654
|
+
target.paused
|
|
655
|
+
? pausedOptions(options, { at: target.pausedAt })
|
|
656
|
+
: clearedPauseOptions(options, 'reconcile', target.unpausedAt),
|
|
657
|
+
false
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Human-readable pause lines for `--status --output-format text`.
|
|
663
|
+
* @param {object} obj - Serialized record
|
|
664
|
+
* @returns {string[]} Lines (empty for non-Docker records)
|
|
665
|
+
*/
|
|
666
|
+
function pauseTextLines(obj) {
|
|
667
|
+
if (obj.dockerState === undefined) {
|
|
668
|
+
return [];
|
|
669
|
+
}
|
|
670
|
+
const lines = [
|
|
671
|
+
`Docker State: ${obj.dockerState}`,
|
|
672
|
+
`Paused: ${obj.paused}`,
|
|
673
|
+
];
|
|
674
|
+
if (obj.paused) {
|
|
675
|
+
lines.push(`Paused At: ${obj.pausedAt || 'N/A'}`);
|
|
676
|
+
}
|
|
677
|
+
return lines;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
module.exports = {
|
|
681
|
+
DOCKER_PAUSE_TIMEOUT_MS,
|
|
682
|
+
DockerState,
|
|
683
|
+
PAUSED_RESOURCES,
|
|
684
|
+
PAUSE_STATE_FORMAT,
|
|
685
|
+
PauseAction,
|
|
686
|
+
attachPauseState,
|
|
687
|
+
buildPauseReport,
|
|
688
|
+
clearPauseAfterControl,
|
|
689
|
+
clearedPauseOptions,
|
|
690
|
+
formatPauseReport,
|
|
691
|
+
inspectPauseState,
|
|
692
|
+
isConfirmedPaused,
|
|
693
|
+
observedPauseFields,
|
|
694
|
+
parsePauseState,
|
|
695
|
+
pauseExecution,
|
|
696
|
+
pauseTextLines,
|
|
697
|
+
pausedOptions,
|
|
698
|
+
persistPauseReconciliation,
|
|
699
|
+
reconcileStalePause,
|
|
700
|
+
unpauseExecution,
|
|
701
|
+
};
|
|
@@ -239,6 +239,12 @@ function recoverKilledExecution(params = {}) {
|
|
|
239
239
|
log('\n[Recovery] Not resuming: the session was stopped on request.\n');
|
|
240
240
|
return { recovered: false, reason: 'stop-requested' };
|
|
241
241
|
}
|
|
242
|
+
if (opts.paused === true) {
|
|
243
|
+
// A paused container only exits when it is stopped or killed on purpose;
|
|
244
|
+
// a pause is never a crash to recover from (issue #263).
|
|
245
|
+
log('\n[Recovery] Not resuming: the session was paused when it exited.\n');
|
|
246
|
+
return { recovered: false, reason: 'paused' };
|
|
247
|
+
}
|
|
242
248
|
const used = Number(opts.recoveryAttempts) || 0;
|
|
243
249
|
if (used >= maxAttempts) {
|
|
244
250
|
log(
|