dsh-autorestart 0.8.0 → 0.8.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 +97 -0
- package/README.md +55 -6
- package/client.js +102 -9
- package/docs/README.zh.md +4 -2
- package/index.js +514 -39
- package/lib/backup.js +279 -9
- package/lib/checkpoint.js +134 -0
- package/lib/diagnostics.js +18 -0
- package/lib/restart-phase.js +263 -0
- package/lib/watchdog-disarm.js +506 -0
- package/lib/watchdog.js +86 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +6 -1
- package/scripts/drill-rescue.sh +427 -0
- package/scripts/drill-watchdog.mjs +474 -0
- package/scripts/emergency-disable.sh +314 -0
- package/scripts/rescue-strings.zh.json +168 -0
- package/scripts/restore-profile.sh +593 -0
- package/scripts/watchdog.mjs +310 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart watchdog: the one-off process that watches the relaunch.
|
|
3
|
+
*
|
|
4
|
+
* The tiers (a Windows scheduled task, a systemd user timer, a detached
|
|
5
|
+
* process of our own) differ only in HOW this file is made to run. What it
|
|
6
|
+
* does is always the same, and it lives in lib/watchdog.js:
|
|
7
|
+
*
|
|
8
|
+
* wait for the ready marker -> ask again -> is an instance alive? -> take
|
|
9
|
+
* the lock -> restore the snapshot -> start the host again -> report.
|
|
10
|
+
*
|
|
11
|
+
* This file only provides the parts that touch the machine: the health probe,
|
|
12
|
+
* the restore command, the start-again command, and the lock.
|
|
13
|
+
*
|
|
14
|
+
* It is ASCII on purpose (like every other script of this plugin): the
|
|
15
|
+
* console reads it with the code page of the machine.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
acquireLock,
|
|
25
|
+
LOCK_NAME,
|
|
26
|
+
readPending,
|
|
27
|
+
releaseLock,
|
|
28
|
+
restoreFromSnapshot,
|
|
29
|
+
runWatchdogFlow,
|
|
30
|
+
} from '../lib/watchdog.js';
|
|
31
|
+
import { removeScheduleItem } from '../lib/watchdog-schedule.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The value of a command line flag.
|
|
35
|
+
*
|
|
36
|
+
* @param {string[]} args
|
|
37
|
+
* @param {string} name
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function valueOf(args, name) {
|
|
41
|
+
const index = args.indexOf(name);
|
|
42
|
+
if (index === -1) return '';
|
|
43
|
+
const next = args[index + 1];
|
|
44
|
+
return typeof next === 'string' && !next.startsWith('--') ? next : '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Ask a health route whether it is alive.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} url
|
|
51
|
+
* @param {number} timeoutMs
|
|
52
|
+
* @returns {Promise<boolean>}
|
|
53
|
+
*/
|
|
54
|
+
function healthOk(url, timeoutMs = 5000) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
let settled = false;
|
|
57
|
+
const done = (value) => {
|
|
58
|
+
if (settled) return;
|
|
59
|
+
settled = true;
|
|
60
|
+
resolve(value);
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
const request = http.get(url, { timeout: timeoutMs }, (response) => {
|
|
64
|
+
let body = '';
|
|
65
|
+
response.on('data', (chunk) => { body += chunk; });
|
|
66
|
+
response.on('end', () => {
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(body);
|
|
69
|
+
done(parsed && parsed.ok === true);
|
|
70
|
+
} catch {
|
|
71
|
+
done(false);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
request.on('timeout', () => { request.destroy(); done(false); });
|
|
76
|
+
request.on('error', () => done(false));
|
|
77
|
+
} catch {
|
|
78
|
+
done(false);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every health URL that could answer for this host, most trustworthy first.
|
|
85
|
+
*
|
|
86
|
+
* The NEW instance of the desktop app listens on a RANDOM port, so the URL the
|
|
87
|
+
* old instance knew (`pending.healthUrl`) is usually worthless after a
|
|
88
|
+
* restart. The new instance publishes its own port in the instance record
|
|
89
|
+
* (`pending.portFile`), and that record is asked REGARDLESS of its boot id: any
|
|
90
|
+
* host that answers this route is a host that must not be restored.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} pending
|
|
93
|
+
* @returns {string[]}
|
|
94
|
+
*/
|
|
95
|
+
function healthCandidates(pending) {
|
|
96
|
+
const healthPath = pending.healthPath || '/__dsh_autorestart/health';
|
|
97
|
+
const urls = [];
|
|
98
|
+
const add = (url) => {
|
|
99
|
+
if (typeof url === 'string' && url !== '' && !urls.includes(url)) urls.push(url);
|
|
100
|
+
};
|
|
101
|
+
// (1) The port the running instance published about itself.
|
|
102
|
+
if (pending.portFile) {
|
|
103
|
+
try {
|
|
104
|
+
const record = JSON.parse(fs.readFileSync(pending.portFile, 'utf8'));
|
|
105
|
+
const port = Number(record && record.port);
|
|
106
|
+
if (Number.isFinite(port) && port > 0) add('http://127.0.0.1:' + port + healthPath);
|
|
107
|
+
} catch {
|
|
108
|
+
// No record yet: the host may not have written it.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// (2) A port the plugin put into the pending marker itself.
|
|
112
|
+
const own = Number(pending.port);
|
|
113
|
+
if (Number.isFinite(own) && own > 0) add('http://127.0.0.1:' + own + healthPath);
|
|
114
|
+
// (3) The URL of the instance that is being replaced (last resort: it is the
|
|
115
|
+
// OLD port, but a host that never went down still answers on it).
|
|
116
|
+
add(pending.healthUrl);
|
|
117
|
+
return urls;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* ASK THE HOST ITSELF: is it alive?
|
|
122
|
+
*
|
|
123
|
+
* This is the last question before a restore (see step 3b of
|
|
124
|
+
* lib/watchdog.js). It answers:
|
|
125
|
+
*
|
|
126
|
+
* 'healthy' -- a health route answered `{"ok":true}`, or an instance of the
|
|
127
|
+
* NEW boot is clearly running: DO NOT RESTORE
|
|
128
|
+
* 'unhealthy' -- every known URL was asked and none answered, and no instance
|
|
129
|
+
* of the new boot is running: the restore may go ahead
|
|
130
|
+
* 'unknown' -- there was no URL to ask at all (no instance record, no
|
|
131
|
+
* health URL): the caller waits one more round
|
|
132
|
+
*
|
|
133
|
+
* The instance record is checked as well as the ports, because a port in that
|
|
134
|
+
* record can be the WRONG one (it may carry the configured port instead of the
|
|
135
|
+
* one the server really listens on). The pid of the new boot is a harder fact
|
|
136
|
+
* than a failed HTTP request, and "never restore a live host" is the rule that
|
|
137
|
+
* matters here.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} pending
|
|
140
|
+
* @returns {Promise<{ state: 'healthy'|'unhealthy'|'unknown', detail: string|null }>}
|
|
141
|
+
*/
|
|
142
|
+
async function askHealth(pending) {
|
|
143
|
+
const urls = healthCandidates(pending);
|
|
144
|
+
for (const url of urls) {
|
|
145
|
+
if (await healthOk(url)) return { state: 'healthy', detail: 'the health route answered: ' + url };
|
|
146
|
+
}
|
|
147
|
+
const record = readInstanceRecord(pending.portFile);
|
|
148
|
+
if (record && record.pid && record.bootId !== pending.expectedBootId && isAlive(record.pid)) {
|
|
149
|
+
return { state: 'healthy', detail: 'the instance of the new boot is running (pid ' + record.pid + ')' };
|
|
150
|
+
}
|
|
151
|
+
if (urls.length === 0) return { state: 'unknown', detail: null };
|
|
152
|
+
return { state: 'unhealthy', detail: 'no health route answered (' + urls.length + ' asked), no live instance' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The instance record the plugin writes at boot (port, pid, boot id).
|
|
157
|
+
*
|
|
158
|
+
* @param {string} file
|
|
159
|
+
* @returns {object|null}
|
|
160
|
+
*/
|
|
161
|
+
function readInstanceRecord(file) {
|
|
162
|
+
if (!file) return null;
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
165
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Is that process still there?
|
|
173
|
+
*
|
|
174
|
+
* @param {number} pid
|
|
175
|
+
* @returns {boolean}
|
|
176
|
+
*/
|
|
177
|
+
function isAlive(pid) {
|
|
178
|
+
try {
|
|
179
|
+
process.kill(Number(pid), 0);
|
|
180
|
+
return true;
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Is an instance of the host alive?
|
|
188
|
+
*
|
|
189
|
+
* The NEW instance publishes its own port (the desktop app listens on a
|
|
190
|
+
* random port, so the port of the old one is worthless). When there is no
|
|
191
|
+
* record at all, the fallback URL of the host is asked.
|
|
192
|
+
*
|
|
193
|
+
* @param {object} pending
|
|
194
|
+
* @returns {Promise<boolean>}
|
|
195
|
+
*/
|
|
196
|
+
async function probeAlive(pending) {
|
|
197
|
+
const record = (() => {
|
|
198
|
+
if (!pending.portFile) return null;
|
|
199
|
+
try {
|
|
200
|
+
return JSON.parse(fs.readFileSync(pending.portFile, 'utf8'));
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
})();
|
|
205
|
+
if (record && record.bootId && record.port && record.bootId !== pending.expectedBootId) {
|
|
206
|
+
const url = 'http://127.0.0.1:' + record.port + (pending.healthPath || '/__dsh_autorestart/health');
|
|
207
|
+
if (await healthOk(url)) return true;
|
|
208
|
+
}
|
|
209
|
+
if (pending.healthUrl) {
|
|
210
|
+
if (await healthOk(pending.healthUrl)) return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Run a command and report whether it worked.
|
|
217
|
+
*
|
|
218
|
+
* @param {{ file?: string, args?: string[] }|null} command
|
|
219
|
+
* @returns {{ ok: boolean, error: string|null }}
|
|
220
|
+
*/
|
|
221
|
+
function runCommand(command) {
|
|
222
|
+
if (!command || typeof command.file !== 'string' || command.file === '') {
|
|
223
|
+
return { ok: false, error: 'there is no command to run' };
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
execFileSync(command.file, Array.isArray(command.args) ? command.args : [], {
|
|
227
|
+
timeout: 10 * 60 * 1000,
|
|
228
|
+
windowsHide: true,
|
|
229
|
+
stdio: 'ignore',
|
|
230
|
+
});
|
|
231
|
+
return { ok: true, error: null };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return { ok: false, error: error && error.message ? error.message : String(error) };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Start the host again (it must survive this process).
|
|
239
|
+
*
|
|
240
|
+
* @param {{ file?: string, args?: string[] }|null} command
|
|
241
|
+
* @returns {{ ok: boolean, error: string|null }}
|
|
242
|
+
*/
|
|
243
|
+
function startAgain(command) {
|
|
244
|
+
if (!command || typeof command.file !== 'string' || command.file === '') {
|
|
245
|
+
return { ok: false, error: 'there is no command to start the host' };
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const child = spawn(command.file, Array.isArray(command.args) ? command.args : [], {
|
|
249
|
+
detached: true,
|
|
250
|
+
stdio: 'ignore',
|
|
251
|
+
windowsHide: true,
|
|
252
|
+
});
|
|
253
|
+
child.unref();
|
|
254
|
+
return { ok: true, error: null };
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return { ok: false, error: error && error.message ? error.message : String(error) };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const args = process.argv.slice(2);
|
|
261
|
+
const stateDir = valueOf(args, '--state-dir') || process.env.DSH_WATCHDOG_STATE_DIR || '';
|
|
262
|
+
if (stateDir === '') {
|
|
263
|
+
console.error('dsh-autorestart watchdog: no state directory (--state-dir)');
|
|
264
|
+
process.exit(2);
|
|
265
|
+
}
|
|
266
|
+
const pending = readPending(stateDir);
|
|
267
|
+
if (!pending) {
|
|
268
|
+
// Nothing to watch: the plugin decided on its own, or another watchdog is
|
|
269
|
+
// already on it. The scheduling item still goes: a task that fires once and
|
|
270
|
+
// then stays in the task scheduler is garbage.
|
|
271
|
+
try {
|
|
272
|
+
removeScheduleItem({ tier: 'scheduled-task', taskName: 'dsh-autorestart-watchdog' });
|
|
273
|
+
removeScheduleItem({ tier: 'systemd-run', unitId: 'dsh-autorestart-watchdog' });
|
|
274
|
+
} catch {
|
|
275
|
+
// It was never there.
|
|
276
|
+
}
|
|
277
|
+
console.log('dsh-autorestart watchdog: no pending marker - nothing to watch');
|
|
278
|
+
process.exit(0);
|
|
279
|
+
}
|
|
280
|
+
const lockFile = path.join(stateDir, LOCK_NAME);
|
|
281
|
+
|
|
282
|
+
const report = await runWatchdogFlow({
|
|
283
|
+
pending,
|
|
284
|
+
stateDir,
|
|
285
|
+
deps: {
|
|
286
|
+
probeAlive: () => probeAlive(pending),
|
|
287
|
+
// THE LAST QUESTION BEFORE ANY RESTORE: ask the health route itself. A
|
|
288
|
+
// host that answers must never be rolled back, even when it forgot to
|
|
289
|
+
// write the ready marker.
|
|
290
|
+
askHealth: () => askHealth(pending),
|
|
291
|
+
runRestore: () => (pending.restoreCommand
|
|
292
|
+
? runCommand(pending.restoreCommand)
|
|
293
|
+
: restoreFromSnapshot({ snapshotDir: pending.snapshotDir, profileDir: pending.profileDir })),
|
|
294
|
+
runRelaunch: () => startAgain(pending.relaunchCommand),
|
|
295
|
+
acquireLock: () => acquireLock(lockFile),
|
|
296
|
+
releaseLock: () => releaseLock(lockFile),
|
|
297
|
+
removeSchedule: () => {
|
|
298
|
+
removeScheduleItem({
|
|
299
|
+
tier: pending.tier,
|
|
300
|
+
taskName: pending.taskName,
|
|
301
|
+
unitId: pending.unitId,
|
|
302
|
+
pidPath: pending.pidPath,
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
console.log('dsh-autorestart watchdog: ' + report.result + ' (tier ' + report.tier + ')');
|
|
309
|
+
if (report.error) console.log(' ' + report.error);
|
|
310
|
+
process.exit(report.result === 'failed' ? 1 : 0);
|