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.
@@ -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);