dsh-autorestart 0.8.1 → 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 +50 -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 +1 -1
- package/scripts/drill-watchdog.mjs +135 -2
- package/scripts/watchdog.mjs +107 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE WATCHDOG DRILL:
|
|
2
|
+
* THE WATCHDOG DRILL: four real scenarios, and the tiers on a real machine.
|
|
3
3
|
*
|
|
4
4
|
* Nothing here touches the profile of a running DSH: the drill works in its
|
|
5
5
|
* own directory under the temp directory, with a FAKE host (a small script
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* 1. the host comes up -> the watchdog stands down, NO restore
|
|
9
9
|
* 2. the host never comes up -> restore, start again, RECOVERED
|
|
10
10
|
* 3. the host stays down -> restore ONCE, start again, FAILED
|
|
11
|
+
* 4. the host is UP but wrote NO ready marker (the 0.8.2 incident) -> the
|
|
12
|
+
* watchdog ASKS THE HEALTH ROUTE, sees the host answer, does NOT restore,
|
|
13
|
+
* and still deletes the pending marker AND the scheduled task.
|
|
11
14
|
*
|
|
12
15
|
* And then the tiers, for real:
|
|
13
16
|
*
|
|
@@ -193,6 +196,133 @@ async function scenario({ name, behaviour, fixIt, expect }) {
|
|
|
193
196
|
return report;
|
|
194
197
|
}
|
|
195
198
|
|
|
199
|
+
/**
|
|
200
|
+
* A fake host that is UP and answers the health route -- and NEVER writes the
|
|
201
|
+
* ready marker. That is exactly the 0.8.2 incident: a healthy host with no
|
|
202
|
+
* marker, because the marker write failed and the failure was swallowed.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} dir
|
|
205
|
+
*/
|
|
206
|
+
function fakeHealthOnlyHost(dir) {
|
|
207
|
+
const script = path.join(dir, 'fake-health-host.mjs');
|
|
208
|
+
const portFile = path.join(dir, 'health-port.txt');
|
|
209
|
+
fs.writeFileSync(script, [
|
|
210
|
+
"import fs from 'node:fs';",
|
|
211
|
+
"import http from 'node:http';",
|
|
212
|
+
"import path from 'node:path';",
|
|
213
|
+
"const dir = process.argv[2];",
|
|
214
|
+
"const server = http.createServer((req, res) => {",
|
|
215
|
+
" res.statusCode = 200;",
|
|
216
|
+
" res.setHeader('content-type', 'application/json');",
|
|
217
|
+
" res.end(JSON.stringify({ ok: true }));",
|
|
218
|
+
"});",
|
|
219
|
+
"server.listen(0, '127.0.0.1', () => {",
|
|
220
|
+
" fs.writeFileSync(path.join(dir, 'health-port.txt'), String(server.address().port));",
|
|
221
|
+
"});",
|
|
222
|
+
"setTimeout(() => process.exit(0), 90000);",
|
|
223
|
+
'',
|
|
224
|
+
].join('\n'), 'utf8');
|
|
225
|
+
return { script, portFile };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Does the Windows task still exist?
|
|
230
|
+
*
|
|
231
|
+
* @param {string} taskName
|
|
232
|
+
* @returns {boolean}
|
|
233
|
+
*/
|
|
234
|
+
function taskStillExists(taskName) {
|
|
235
|
+
try {
|
|
236
|
+
execFileSync('schtasks', ['/Query', '/TN', taskName], { stdio: 'ignore', timeout: 15000 });
|
|
237
|
+
return true;
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* SCENARIO 4 -- THE HOST IS UP BUT WROTE NO READY MARKER (the 0.8.2 incident).
|
|
245
|
+
*
|
|
246
|
+
* The watchdog must ask the health route itself before it restores anything.
|
|
247
|
+
* The host answers, so the snapshot is NOT restored -- and the pending marker
|
|
248
|
+
* and the real scheduled task are still cleaned up (an armed task that fires
|
|
249
|
+
* later would restore a healthy profile).
|
|
250
|
+
*/
|
|
251
|
+
async function scenarioHostUpWithoutMarker() {
|
|
252
|
+
const name = 'scenario-4-no-marker';
|
|
253
|
+
const dir = path.join(drillRoot, name);
|
|
254
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
255
|
+
const restoreLog = path.join(dir, 'restore.log');
|
|
256
|
+
const restore = fakeRestore(dir, false);
|
|
257
|
+
|
|
258
|
+
// (1) The host: up, answering health, NO ready marker.
|
|
259
|
+
const host = fakeHealthOnlyHost(dir);
|
|
260
|
+
const child = spawn(process.execPath, [host.script, dir], { detached: true, stdio: 'ignore' });
|
|
261
|
+
child.unref();
|
|
262
|
+
const up = await waitForFile(host.portFile, 15000);
|
|
263
|
+
check(name + ': the fake host came up and answers health', up);
|
|
264
|
+
const port = up ? Number(fs.readFileSync(host.portFile, 'utf8').trim()) : 0;
|
|
265
|
+
check(name + ': the health port is known', Number.isFinite(port) && port > 0, String(port));
|
|
266
|
+
|
|
267
|
+
// (2) The instance record the real host writes at boot -- WITHOUT a boot id,
|
|
268
|
+
// so "is an instance alive" cannot be answered from it alone: only the
|
|
269
|
+
// direct health question can. That is the guard this scenario tests.
|
|
270
|
+
const portFile = path.join(dir, 'access.json');
|
|
271
|
+
fs.writeFileSync(portFile, JSON.stringify({ bootId: null, port: port > 0 ? port : null, at: Date.now() }), 'utf8');
|
|
272
|
+
|
|
273
|
+
// (3) A REAL scheduling item: the watchdog has to delete it, not only the
|
|
274
|
+
// pending marker. (On a machine without the task scheduler the drill
|
|
275
|
+
// falls back to the detached-process tier and checks the markers only.)
|
|
276
|
+
const taskName = 'dsh-autorestart-drill-' + crypto.randomUUID().slice(0, 8);
|
|
277
|
+
let registered = { ok: false, error: 'the task scheduler is not used here' };
|
|
278
|
+
if (process.platform === 'win32') {
|
|
279
|
+
registered = registerScheduledTask({
|
|
280
|
+
taskName,
|
|
281
|
+
startIso: localIso(new Date(Date.now() + 15 * 60 * 1000)),
|
|
282
|
+
endIso: localIso(new Date(Date.now() + 45 * 60 * 1000)),
|
|
283
|
+
command: 'echo fired > "' + path.join(dir, 'task-fired.txt') + '"',
|
|
284
|
+
xmlPath: path.join(dir, 'task.xml'),
|
|
285
|
+
});
|
|
286
|
+
check(name + ': the real scheduled task was registered', registered.ok === true, registered.error || '');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
writePending(dir, buildPending({
|
|
290
|
+
tier: registered.ok === true ? 'scheduled-task' : 'detached-process',
|
|
291
|
+
tierReason: 'the drill arms the watchdog itself',
|
|
292
|
+
taskName: registered.ok === true ? taskName : '',
|
|
293
|
+
restoreCommand: { file: process.execPath, args: [restore, dir] },
|
|
294
|
+
healthPath: '/__dsh_autorestart/health',
|
|
295
|
+
portFile,
|
|
296
|
+
expectedBootId: 'the-boot-before-the-restart',
|
|
297
|
+
timeoutMs: 3000,
|
|
298
|
+
recheckMs: 800,
|
|
299
|
+
readyTimeoutMs: 4000,
|
|
300
|
+
now: Date.now(),
|
|
301
|
+
}));
|
|
302
|
+
|
|
303
|
+
const watchdog = spawn(process.execPath, [path.join(here, 'watchdog.mjs'), '--state-dir', dir], { stdio: 'ignore' });
|
|
304
|
+
await new Promise((resolve) => watchdog.on('exit', resolve));
|
|
305
|
+
|
|
306
|
+
const report = readReport(dir);
|
|
307
|
+
const restores = fs.existsSync(restoreLog)
|
|
308
|
+
? fs.readFileSync(restoreLog, 'utf8').trim().split('\n').filter(Boolean).length
|
|
309
|
+
: 0;
|
|
310
|
+
check(name + ': the report says not-needed', report !== null && report.result === 'not-needed', report ? String(report.result) : 'no report');
|
|
311
|
+
check(name + ': the health route was asked and answered', report !== null && report.healthCheck === 'healthy', report ? String(report.healthCheck) : 'no report');
|
|
312
|
+
check(name + ': nothing was restored', restores === 0, restores + ' restore(s)');
|
|
313
|
+
check(name + ': the pending marker is gone', !fs.existsSync(path.join(dir, 'watchdog-pending.json')));
|
|
314
|
+
check(name + ': no task was fired', !fs.existsSync(path.join(dir, 'task-fired.txt')));
|
|
315
|
+
if (registered.ok === true) {
|
|
316
|
+
check(name + ': the scheduled task is gone', !taskStillExists(taskName), taskName);
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
child.kill();
|
|
320
|
+
} catch {
|
|
321
|
+
// It is gone anyway (it exits by itself).
|
|
322
|
+
}
|
|
323
|
+
return report;
|
|
324
|
+
}
|
|
325
|
+
|
|
196
326
|
/**
|
|
197
327
|
* The Windows scheduled task, for real: register it, read it back, let it
|
|
198
328
|
* fire, delete it.
|
|
@@ -311,10 +441,13 @@ console.log('dsh-autorestart watchdog drill');
|
|
|
311
441
|
console.log(' workspace: ' + drillRoot);
|
|
312
442
|
console.log('');
|
|
313
443
|
|
|
314
|
-
console.log('--- the
|
|
444
|
+
console.log('--- the four scenarios ---');
|
|
315
445
|
await scenario({ name: 'scenario-1-normal', behaviour: 'up', fixIt: false, expect: 'not-needed' });
|
|
316
446
|
await scenario({ name: 'scenario-2-recovered', behaviour: 'down', fixIt: true, expect: 'recovered' });
|
|
317
447
|
await scenario({ name: 'scenario-3-failed', behaviour: 'down', fixIt: false, expect: 'failed' });
|
|
448
|
+
// THE 0.8.2 INCIDENT: a healthy host that never wrote the ready marker. The
|
|
449
|
+
// watchdog has to ask the health route instead of trusting the marker.
|
|
450
|
+
await scenarioHostUpWithoutMarker();
|
|
318
451
|
|
|
319
452
|
console.log('');
|
|
320
453
|
console.log('--- the tiers, for real ---');
|
package/scripts/watchdog.mjs
CHANGED
|
@@ -80,6 +80,109 @@ function healthOk(url, timeoutMs = 5000) {
|
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
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
|
+
|
|
83
186
|
/**
|
|
84
187
|
* Is an instance of the host alive?
|
|
85
188
|
*
|
|
@@ -181,6 +284,10 @@ const report = await runWatchdogFlow({
|
|
|
181
284
|
stateDir,
|
|
182
285
|
deps: {
|
|
183
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),
|
|
184
291
|
runRestore: () => (pending.restoreCommand
|
|
185
292
|
? runCommand(pending.restoreCommand)
|
|
186
293
|
: restoreFromSnapshot({ snapshotDir: pending.snapshotDir, profileDir: pending.profileDir })),
|