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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * THE WATCHDOG DRILL: three real scenarios, and the tiers on a real machine.
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 three scenarios ---');
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 ---');
@@ -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 })),