dsh-autorestart 0.8.4 → 0.8.6

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/README.md +465 -40
  3. package/assets/icon-9c245e5b.ico +0 -0
  4. package/assets/icon-rescue-1902bb15.ico +0 -0
  5. package/assets/icon-rescue-1902bb15.png +0 -0
  6. package/client.js +3002 -170
  7. package/cordis.patch.yml +12 -0
  8. package/docs/README.zh.md +231 -23
  9. package/docs/platform-matrix.md +171 -55
  10. package/docs/real-machine-verification-0.8.6.md +202 -0
  11. package/index.js +13272 -8513
  12. package/lib/autostart-advice.js +31 -2
  13. package/lib/backup.js +2768 -21
  14. package/lib/checkpoint.js +214 -7
  15. package/lib/cookie-clear.js +260 -0
  16. package/lib/lifecycle.js +23 -40
  17. package/lib/plugin-compare.js +499 -0
  18. package/lib/plugin-failure.js +382 -0
  19. package/lib/plugin-recover.js +1557 -0
  20. package/lib/plugin-watch.js +213 -0
  21. package/lib/rescue-paths.js +982 -56
  22. package/lib/restart-phase.js +5 -0
  23. package/lib/restart-verdict.js +379 -0
  24. package/lib/restart.js +244 -7
  25. package/lib/scheduler-capability.js +19 -3
  26. package/lib/sleep.js +190 -29
  27. package/lib/watchdog-disarm.js +46 -2
  28. package/lib/watchdog-schedule.js +834 -17
  29. package/lib/watchdog-tier.js +8 -1
  30. package/lib/watchdog.js +1222 -47
  31. package/locale/en.json +175 -68
  32. package/locale/zh.json +175 -68
  33. package/package.json +3 -2
  34. package/screenshots.json +6 -6
  35. package/scripts/README.md +6 -3
  36. package/scripts/build-icon.mjs +998 -0
  37. package/scripts/clear-dsh-auth-cookies.mjs +257 -0
  38. package/scripts/emergency-disable.sh +69 -2
  39. package/scripts/emergency-toggle.ps1 +85 -69
  40. package/scripts/rescue-strings.zh.json +36 -18
  41. package/scripts/restore-profile.ps1 +182 -1
  42. package/scripts/watchdog.mjs +432 -79
@@ -9,8 +9,11 @@
9
9
  *
10
10
  * - the Windows task is read back after it was registered (and deleted
11
11
  * completely afterwards);
12
- * - the systemd unit is reset (`reset-failed`) when it is over, so it does
13
- * not lie around as a failed unit;
12
+ * - the systemd unit is STOPPED and then reset (`reset-failed`) when it is
13
+ * over — `reset-failed` alone only clears the failed STATE and leaves a
14
+ * timer that is still waiting alive, which then makes the next
15
+ * `systemd-run --unit=…` fail with "Unit … already exists" (see
16
+ * `cleanupSystemdUnit` and `armSystemdWatchdog`);
14
17
  * - the detached process writes its pid into the state directory, and the
15
18
  * process is READ BACK to prove that it is really running.
16
19
  */
@@ -18,8 +21,344 @@
18
21
  import fs from 'node:fs';
19
22
  import path from 'node:path';
20
23
 
24
+ import { isNodeExecutable, resolveNodeInterpreter } from './node-runtime.js';
25
+ import { PROBE_TASK_PREFIX } from './scheduler-capability.js';
21
26
  import { defaultExec, TIERS } from './watchdog-tier.js';
22
27
 
28
+ /**
29
+ * THE LAUNCHER OF THE WATCHDOG — why this section exists.
30
+ *
31
+ * A one-off Windows task used to be registered as
32
+ *
33
+ * <Command>cmd</Command>
34
+ * <Arguments>/c "<host exe>" "<watchdog script>" --state-dir "<dir>"</Arguments>
35
+ *
36
+ * and that command line was broken twice over (both findings are from the
37
+ * incident of 2026-10-30, see support/dsh-crash-20261030):
38
+ *
39
+ * 1. THE INTERPRETER WAS THE HOST. The command started `DeepSeek Harness.exe`
40
+ * with a script argument, and the environment of a scheduled task does
41
+ * NOT carry `ELECTRON_RUN_AS_NODE=1`, so it started a second copy of the
42
+ * CLIENT instead of running the script as node;
43
+ * 2. THE EXTRA `cmd /c` ATE THE QUOTES. With six quotes on the line, cmd
44
+ * strips the outer pair and the first token ends at
45
+ * `...\Programs\DeepSeek` — the task exits with 9009 and the script never
46
+ * runs a line.
47
+ *
48
+ * The watchdog therefore never ran, while the report kept saying `armed`.
49
+ *
50
+ * The rule that follows is now mechanical, not a habit:
51
+ *
52
+ * - the task runs a REAL node interpreter, RESOLVED (lib/node-runtime.js) —
53
+ * never `process.execPath`, which is the app executable in the desktop app;
54
+ * - the XML carries the interpreter path in `<Command>` and the script plus
55
+ * its arguments in `<Arguments>`: there is no shell in between, so there
56
+ * is nothing that could strip a quote;
57
+ * - when no node interpreter exists the registration is REFUSED with a
58
+ * reason. The app executable is never used as a stand-in for node.
59
+ *
60
+ * A command line that needs a shell (a redirection, a pipe) still goes through
61
+ * `cmd /c`, and it says so (`form: 'shell'`): the drill scripts of this plugin
62
+ * register such tasks on purpose.
63
+ */
64
+
65
+ /** The tokens cmd.exe understands and node does not. */
66
+ const SHELL_TOKENS = Object.freeze(['>', '>>', '<', '|', '||', '&&', '&', '2>', '1>', '2>&1']);
67
+
68
+ /**
69
+ * Split a command line into its arguments, respecting double quotes.
70
+ *
71
+ * It is deliberately small: the strings this module receives are built by this
72
+ * plugin (an interpreter path, a script path, a state directory), not by a
73
+ * user typing a shell line.
74
+ *
75
+ * @param {string} text
76
+ * @returns {string[]}
77
+ */
78
+ export function splitCommandLine(text) {
79
+ const source = String(text ?? '');
80
+ const tokens = [];
81
+ let current = '';
82
+ let quoted = false;
83
+ let started = false;
84
+ for (const character of source) {
85
+ if (character === '"') {
86
+ quoted = !quoted;
87
+ started = true;
88
+ continue;
89
+ }
90
+ if (!quoted && (character === ' ' || character === '\t')) {
91
+ if (started) {
92
+ tokens.push(current);
93
+ current = '';
94
+ started = false;
95
+ }
96
+ continue;
97
+ }
98
+ current += character;
99
+ started = true;
100
+ }
101
+ if (started) tokens.push(current);
102
+ return tokens;
103
+ }
104
+
105
+ /**
106
+ * Does this token only make sense to a shell?
107
+ *
108
+ * @param {string} token
109
+ * @returns {boolean}
110
+ */
111
+ function isShellToken(token) {
112
+ return SHELL_TOKENS.includes(String(token ?? ''));
113
+ }
114
+
115
+ /**
116
+ * Is this token the path of a script node can run?
117
+ *
118
+ * @param {string} token
119
+ * @returns {boolean}
120
+ */
121
+ export function isNodeScriptPath(token) {
122
+ return /\.(cjs|mjs|js)$/i.test(String(token ?? '').trim());
123
+ }
124
+
125
+ /**
126
+ * The interpreter a watchdog task must run: ALWAYS a real node.
127
+ *
128
+ * This is the pure function the incident demanded: it takes the executable of
129
+ * the running process (which is the desktop client in the desktop app) and
130
+ * answers with a node interpreter, or with a REASON and a refusal. It never
131
+ * answers with the application executable.
132
+ *
133
+ * @param {{
134
+ * execPath?: string,
135
+ * platform?: string,
136
+ * env?: object,
137
+ * existsSyncFn?: Function,
138
+ * }} [options]
139
+ * @returns {{ ok: boolean, path: string, source: string, reason: string, tried: string[] }}
140
+ */
141
+ export function resolveWatchdogInterpreter(options = {}) {
142
+ const answer = resolveNodeInterpreter(options);
143
+ const found = typeof answer.path === 'string' ? answer.path : '';
144
+ if (answer.ok !== true || found === '') {
145
+ return {
146
+ ok: false,
147
+ path: '',
148
+ source: '',
149
+ reason: answer.reason || 'no node interpreter was found',
150
+ tried: Array.isArray(answer.tried) ? answer.tried : [],
151
+ };
152
+ }
153
+ if (!isNodeExecutable(found)) {
154
+ // Belt and braces: the resolver is not allowed to answer with the client
155
+ // executable, and this is the place that would notice if it ever did.
156
+ return {
157
+ ok: false,
158
+ path: '',
159
+ source: '',
160
+ reason: 'the resolved interpreter is not node: ' + found
161
+ + ' — the application executable is never used as a stand-in for node',
162
+ tried: Array.isArray(answer.tried) ? answer.tried : [],
163
+ };
164
+ }
165
+ return { ok: true, path: found, source: answer.source || '', reason: '', tried: answer.tried || [] };
166
+ }
167
+
168
+ /**
169
+ * The plan of "how does this task start something".
170
+ *
171
+ * THREE forms are possible, and the caller gets told which one it got:
172
+ *
173
+ * exec — `<Command>` is the program, `<Arguments>` are its arguments. No
174
+ * shell is involved, so no quote can be stripped. The program is a
175
+ * resolved node interpreter unless it already IS node.
176
+ * shell — the command line needs cmd.exe (a redirection, a pipe): the old
177
+ * `cmd /c` form. It is used only when the command really asks for it.
178
+ *
179
+ * Returns `{ ok:false, reason }` when a node interpreter is needed and none
180
+ * exists — the caller must then REFUSE the registration.
181
+ *
182
+ * @param {string|string[]|{file?: string, args?: string[]}} command
183
+ * @param {{
184
+ * execPath?: string,
185
+ * platform?: string,
186
+ * env?: object,
187
+ * existsSyncFn?: Function,
188
+ * }} [options]
189
+ * @returns {{
190
+ * ok: boolean,
191
+ * form: 'exec'|'shell',
192
+ * file: string,
193
+ * args: string[],
194
+ * commandLine: string,
195
+ * reason: string,
196
+ * tried: string[],
197
+ * }}
198
+ */
199
+ export function planTaskCommand(command, options = {}) {
200
+ const refused = (reason, tried, form = 'exec', file = '', args = []) => ({
201
+ ok: false,
202
+ form,
203
+ file,
204
+ args,
205
+ commandLine: '',
206
+ reason,
207
+ tried: Array.isArray(tried) ? tried : [],
208
+ });
209
+
210
+ // (a) The argv form: `{ file, args }` (and an array of tokens).
211
+ let tokens = [];
212
+ if (Array.isArray(command)) {
213
+ tokens = command.map((entry) => String(entry));
214
+ } else if (command && typeof command === 'object') {
215
+ const file = typeof command.file === 'string' ? command.file : '';
216
+ if (file === '') return refused('the command carries no program to run', []);
217
+ const args = Array.isArray(command.args) ? command.args.map((entry) => String(entry)) : [];
218
+ tokens = [file, ...args];
219
+ } else if (typeof command === 'string') {
220
+ tokens = splitCommandLine(command);
221
+ }
222
+ if (tokens.length === 0) return refused('there is no command to run', []);
223
+
224
+ // (b) A command line that only a shell can run keeps the shell: the drill
225
+ // scripts of this plugin register `echo ... > file` tasks on purpose.
226
+ if (typeof command === 'string' && tokens.some(isShellToken)) {
227
+ return {
228
+ ok: true,
229
+ form: 'shell',
230
+ file: 'cmd',
231
+ args: ['/c', String(command)],
232
+ commandLine: 'cmd /c ' + String(command),
233
+ reason: 'the command line needs a shell (a redirection or a pipe)',
234
+ tried: [],
235
+ };
236
+ }
237
+
238
+ // (c) Already node? Then it runs as it is — but a BARE `node` is not an
239
+ // absolute path, and a scheduled task must not depend on the PATH it
240
+ // happens to see: it is resolved like every other interpreter.
241
+ const first = tokens[0];
242
+ if (isNodeExecutable(first)) {
243
+ const args = tokens.slice(1);
244
+ let file = first;
245
+ if (!path.isAbsolute(first)) {
246
+ const resolved = resolveWatchdogInterpreter(options);
247
+ if (resolved.ok === true) file = resolved.path;
248
+ }
249
+ return {
250
+ ok: true,
251
+ form: 'exec',
252
+ file,
253
+ args,
254
+ commandLine: quoteArgv([file, ...args]),
255
+ reason: '',
256
+ tried: [],
257
+ };
258
+ }
259
+
260
+ // (d) Everything else has to become "node <script> <args>".
261
+ //
262
+ // The first token is the program of the caller: it may be the client
263
+ // executable (`DeepSeek Harness.exe`), or the script itself, or nothing
264
+ // at all. Whatever it was, the payload is a node script plus arguments,
265
+ // and only a resolved node interpreter may run it.
266
+ const payload = isNodeScriptPath(first) ? tokens : tokens.slice(1);
267
+ const scriptIndex = payload.findIndex(isNodeScriptPath);
268
+ if (scriptIndex === -1) {
269
+ // A plain program invocation (`notepad.exe`, `echo something`): there is no
270
+ // node script in it, so nothing has to be re-interpreted.
271
+ return {
272
+ ok: true,
273
+ form: 'shell',
274
+ file: 'cmd',
275
+ args: ['/c', typeof command === 'string' ? String(command) : quoteArgv(tokens)],
276
+ commandLine: 'cmd /c ' + (typeof command === 'string' ? String(command) : quoteArgv(tokens)),
277
+ reason: 'the command line carries no node script: it is run as it is',
278
+ tried: [],
279
+ };
280
+ }
281
+ const interpreter = resolveWatchdogInterpreter(options);
282
+ if (interpreter.ok !== true) {
283
+ return refused(interpreter.reason, interpreter.tried);
284
+ }
285
+ return {
286
+ ok: true,
287
+ form: 'exec',
288
+ file: interpreter.path,
289
+ args: payload,
290
+ commandLine: quoteArgv([interpreter.path, ...payload]),
291
+ reason: '',
292
+ tried: interpreter.tried,
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Quote one argument the way Windows splits a command line again
298
+ * (`CommandLineToArgvW` rules).
299
+ *
300
+ * @param {string} value
301
+ * @returns {string}
302
+ */
303
+ export function quoteArg(value) {
304
+ const text = String(value ?? '');
305
+ if (text === '') return '""';
306
+ // Backslashes only matter in front of a quote, and at the very end.
307
+ const escaped = text.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1');
308
+ return '"' + escaped + '"';
309
+ }
310
+
311
+ /**
312
+ * A whole command line out of tokens.
313
+ *
314
+ * @param {string[]} tokens
315
+ * @returns {string}
316
+ */
317
+ export function quoteArgv(tokens) {
318
+ return (Array.isArray(tokens) ? tokens : []).map(quoteArg).join(' ');
319
+ }
320
+
321
+ /**
322
+ * The `{ file, args }` of "run that node script with a resolved node".
323
+ *
324
+ * One call, so nobody has to remember the two things that broke the watchdog:
325
+ * `process.execPath` is not node, and the script must not be handed to a shell.
326
+ *
327
+ * @param {{
328
+ * scriptPath: string,
329
+ * args?: string[],
330
+ * execPath?: string,
331
+ * platform?: string,
332
+ * env?: object,
333
+ * existsSyncFn?: Function,
334
+ * }} options
335
+ * @returns {{ ok: boolean, file: string, args: string[], reason: string, tried: string[] }}
336
+ */
337
+ export function buildWatchdogTaskCommand({
338
+ scriptPath = '',
339
+ args = [],
340
+ execPath = process.execPath,
341
+ platform = process.platform,
342
+ env = process.env,
343
+ existsSyncFn = undefined,
344
+ } = {}) {
345
+ const script = typeof scriptPath === 'string' ? scriptPath : '';
346
+ if (script === '') {
347
+ return { ok: false, file: '', args: [], reason: 'there is no script to run', tried: [] };
348
+ }
349
+ const interpreter = resolveWatchdogInterpreter({ execPath, platform, env, existsSyncFn });
350
+ if (interpreter.ok !== true) {
351
+ return { ok: false, file: '', args: [], reason: interpreter.reason, tried: interpreter.tried };
352
+ }
353
+ return {
354
+ ok: true,
355
+ file: interpreter.path,
356
+ args: [script, ...(Array.isArray(args) ? args.map((entry) => String(entry)) : [])],
357
+ reason: '',
358
+ tried: interpreter.tried,
359
+ };
360
+ }
361
+
23
362
  /**
24
363
  * The XML of a one-off Windows task.
25
364
  *
@@ -30,10 +369,21 @@ import { defaultExec, TIERS } from './watchdog-tier.js';
30
369
  * - `MultipleInstancesPolicy` must be `IgnoreNew`: a second instance of the
31
370
  * watchdog would fight the first one over the same lock.
32
371
  *
33
- * @param {{ startIso: string, endIso: string, command: string, description?: string }} options
372
+ * `<Command>` is the PROGRAM (an absolute interpreter path for the watchdog)
373
+ * and `<Arguments>` are its arguments. Neither is wrapped in another shell:
374
+ * cmd.exe is what ate the quotes that killed the watchdog on 2026-10-30.
375
+ *
376
+ * @param {{
377
+ * startIso: string,
378
+ * endIso: string,
379
+ * command: string|string[]|{file?: string, args?: string[]}|{form?: string, file: string, args?: string[]},
380
+ * description?: string,
381
+ * planOptions?: object,
382
+ * }} options
34
383
  * @returns {string}
35
384
  */
36
- export function buildScheduledTaskXml({ startIso, endIso, command, description = 'dsh-autorestart watchdog' }) {
385
+ export function buildScheduledTaskXml({ startIso, endIso, command, description = 'dsh-autorestart watchdog', planOptions = {} }) {
386
+ const exec = execOfCommand(command, planOptions);
37
387
  const escape = (value) => String(value ?? '')
38
388
  .replace(/&/g, '&amp;')
39
389
  .replace(/</g, '&lt;')
@@ -67,8 +417,8 @@ export function buildScheduledTaskXml({ startIso, endIso, command, description =
67
417
  ' </Settings>',
68
418
  ' <Actions Context="Author">',
69
419
  ' <Exec>',
70
- ' <Command>cmd</Command>',
71
- ' <Arguments>/c ' + escape(command) + '</Arguments>',
420
+ ' <Command>' + escape(exec.file) + '</Command>',
421
+ ' <Arguments>' + escape(exec.arguments) + '</Arguments>',
72
422
  ' </Exec>',
73
423
  ' </Actions>',
74
424
  '</Task>',
@@ -76,18 +426,50 @@ export function buildScheduledTaskXml({ startIso, endIso, command, description =
76
426
  ].join('\r\n');
77
427
  }
78
428
 
429
+ /**
430
+ * The `<Command>`/`<Arguments>` of a task, planned and quoted.
431
+ *
432
+ * @param {*} command
433
+ * @param {object} planOptions
434
+ * @returns {{ file: string, arguments: string }}
435
+ */
436
+ function execOfCommand(command, planOptions = {}) {
437
+ const isPlan = command !== null
438
+ && typeof command === 'object'
439
+ && !Array.isArray(command)
440
+ && typeof command.file === 'string'
441
+ && command.file !== '';
442
+ const plan = isPlan
443
+ ? {
444
+ ok: true,
445
+ file: command.file,
446
+ args: Array.isArray(command.args) ? command.args.map((entry) => String(entry)) : [],
447
+ }
448
+ : planTaskCommand(command, planOptions);
449
+ if (plan.ok !== true) {
450
+ throw new Error('the task cannot be built: ' + (plan.reason || 'there is no command to run'));
451
+ }
452
+ return { file: plan.file, arguments: quoteArgv(plan.args) };
453
+ }
454
+
79
455
  /**
80
456
  * Register the one-off Windows task and read it back.
81
457
  *
458
+ * The command is PLANNED first (see `planTaskCommand`): when the task needs a
459
+ * node interpreter and none exists, the registration is refused with the
460
+ * reason and nothing is written to the machine. The application executable is
461
+ * never used as a stand-in.
462
+ *
82
463
  * @param {{
83
464
  * taskName: string,
84
465
  * startIso: string,
85
466
  * endIso: string,
86
- * command: string,
467
+ * command: string|string[]|{file?: string, args?: string[]},
87
468
  * xmlPath: string,
88
469
  * exec?: Function,
89
470
  * writeFile?: Function,
90
471
  * removeFile?: Function,
472
+ * planOptions?: object,
91
473
  * }} options
92
474
  * @returns {{ ok: boolean, taskName: string, error: string|null, steps: Array<object> }}
93
475
  */
@@ -100,13 +482,25 @@ export function registerScheduledTask({
100
482
  exec = defaultExec,
101
483
  writeFile = (target, content) => fs.writeFileSync(target, content, 'utf8'),
102
484
  removeFile = (target) => fs.rmSync(target, { force: true }),
485
+ planOptions = {},
103
486
  }) {
104
487
  const steps = [];
105
488
  const step = (kind, ok, detail) => {
106
489
  steps.push({ kind, ok, detail: detail === undefined ? null : detail });
107
490
  return ok;
108
491
  };
109
- const xml = buildScheduledTaskXml({ startIso, endIso, command });
492
+ // (0) WHAT RUNS THE TASK. Refused before anything is written when no real
493
+ // node interpreter can be found: a task that would start the client
494
+ // executable (or nothing at all) is not a watchdog.
495
+ let xml = '';
496
+ try {
497
+ xml = buildScheduledTaskXml({ startIso, endIso, command, planOptions });
498
+ } catch (error) {
499
+ const reason = error && error.message ? error.message : String(error);
500
+ step('plan', false, reason);
501
+ return { ok: false, taskName, error: reason, steps };
502
+ }
503
+ step('plan', true, 'the task runs a program directly (no shell in between)');
110
504
  try {
111
505
  writeFile(xmlPath, xml);
112
506
  } catch (error) {
@@ -193,14 +587,273 @@ export function registerSystemdRun({ unitId, delaySeconds, command, exec = defau
193
587
  }
194
588
 
195
589
  /**
196
- * Clean the systemd unit up (a failed unit would stay in the list).
590
+ * WHAT AN ARMING ATTEMPT ENDED UP AS — one word, so the report can be read at
591
+ * a glance instead of "armed: false" with nothing else.
592
+ */
593
+ export const ARM_OUTCOMES = Object.freeze({
594
+ /** Registered on the first attempt and nothing was left over. */
595
+ ARMED: 'armed',
596
+ /** A timer of an earlier run was really STOPPED, then the unit was registered. */
597
+ CLEANED_THEN_ARMED: 'cleaned-then-armed',
598
+ /** The first `systemd-run` was refused; one forced cleanup and one retry saved it. */
599
+ ARMED_AFTER_RETRY: 'armed-after-retry',
600
+ /** The cleanup itself failed, so the name stayed taken and the arming failed. */
601
+ CLEANUP_FAILED: 'cleanup-failed',
602
+ /** The cleanup worked (or had nothing to do) and the registration still failed. */
603
+ FAILED: 'failed',
604
+ });
605
+
606
+ /**
607
+ * The two unit names one `systemd-run --unit=<base>` leaves behind.
197
608
  *
198
- * @param {{ unitId: string, exec?: Function }} options
199
- * @returns {{ ok: boolean, error: string|null }}
609
+ * The caller may pass the bare base (`dsh-autorestart-watchdog`) or the timer
610
+ * name with its suffix; both mean the same pair of units. Without the suffix
611
+ * `systemctl` cannot address anything at all — which is why the old cleanup
612
+ * never cleaned anything.
613
+ *
614
+ * @param {string} unitId
615
+ * @returns {{ base: string, timer: string, service: string }}
616
+ */
617
+ export function systemdUnitNames(unitId) {
618
+ const base = String(unitId ?? '').trim().replace(/\.(timer|service)$/i, '');
619
+ return {
620
+ base,
621
+ timer: base === '' ? '' : base + '.timer',
622
+ service: base === '' ? '' : base + '.service',
623
+ };
624
+ }
625
+
626
+ /**
627
+ * systemd's answer for "this unit is not here at all".
628
+ *
629
+ * It is the NORMAL answer on a clean machine, not a failure: `systemctl --user
630
+ * stop x.timer` exits 5 with "Unit x.timer not loaded". Treating that as a
631
+ * failure would make every healthy arming look broken.
632
+ */
633
+ const UNIT_ABSENT = /not loaded|not found|no such file|does not exist|could not be found/i;
634
+
635
+ /**
636
+ * Clean the systemd units of an earlier run up — for real.
637
+ *
638
+ * WHY THIS IS NOT `reset-failed` ANY MORE (hosted instance on 5601, real logs):
639
+ * `reset-failed` only clears the FAILED STATE. A transient timer that is still
640
+ * waiting stays loaded, and the next `systemd-run --unit=<same name>` is
641
+ * refused with
642
+ *
643
+ * Failed to start transient timer unit: Unit dsh-autorestart-watchdog.timer
644
+ * already exists.
645
+ *
646
+ * That refusal made the arming fail, which made `v2Armed=false`, which made the
647
+ * arbiter fall back to the old v1 watcher — and because nothing ever stopped
648
+ * the leftover, EVERY later arming failed the same way: one bad leftover
649
+ * degraded the watchdog for good. So the units are STOPPED first (stop is what
650
+ * removes a transient timer), and only then reset.
651
+ *
652
+ * Every single step is reported (action, unit, outcome); a step that really
653
+ * failed is never swallowed, because "cleaned" is a claim the report has to be
654
+ * able to back up.
655
+ *
656
+ * `includeService: false` — 只停 timer,**不停 service**。为什么需要这个开关:
657
+ * 守夜人 CLI 自己就跑在 `systemd-run --on-active` 起的 one-shot service 里,
658
+ * `systemctl --user stop <that>.service` 等于让 systemd 给自己发 SIGTERM,清理
659
+ * 动作会在报告落盘之前把进程杀掉(2026-11-01 救援报告丢失就是这么来的)。自
660
+ * 清理要显式跳过 service——one-shot 的 service 在本进程退出后本来就消失了,
661
+ * 没人需要去停它。宿主进程(index.js / disarm)照旧用默认值 true:它不在那个
662
+ * service 里跑,停掉残留的 service 是对的。
663
+ *
664
+ * @param {{ unitId: string, exec?: Function, includeService?: boolean }} options
665
+ * @returns {{
666
+ * ok: boolean,
667
+ * error: string|null,
668
+ * units: string[],
669
+ * stopped: string[],
670
+ * residual: string[],
671
+ * serviceStopSkipped: boolean,
672
+ * steps: Array<{ action: string, unit: string, outcome: string, executed: boolean, error: string|null, reason?: string }>,
673
+ * }}
200
674
  */
201
- export function cleanupSystemdUnit({ unitId, exec = defaultExec }) {
202
- const answer = exec('systemctl', ['--user', 'reset-failed', unitId]);
203
- return { ok: answer.ok === true, error: answer.ok ? null : (answer.error || null) };
675
+ export function cleanupSystemdUnit({ unitId, exec = defaultExec, includeService = true }) {
676
+ const units = systemdUnitNames(unitId);
677
+ const steps = [];
678
+ const run = (action, unit) => {
679
+ if (unit === '') {
680
+ steps.push({ action, unit, outcome: 'skipped', executed: false, error: 'there is no unit name to clean' });
681
+ return 'skipped';
682
+ }
683
+ const answer = exec('systemctl', ['--user', action, unit]);
684
+ const ok = answer !== null && answer !== undefined && answer.ok === true;
685
+ const text = ok ? '' : String((answer && answer.error) || (action + ' ' + unit + ' was refused'));
686
+ // "not loaded" means there was nothing to clean — but it must NOT be
687
+ // counted as proof that something WAS cleaned (see `stopped`).
688
+ const absent = !ok && UNIT_ABSENT.test(text);
689
+ const outcome = ok ? 'done' : (absent ? 'absent' : 'failed');
690
+ steps.push({ action, unit, outcome, executed: ok, error: ok || absent ? null : text });
691
+ return outcome;
692
+ };
693
+ run('stop', units.timer);
694
+ if (includeService === true) {
695
+ run('stop', units.service);
696
+ } else {
697
+ // 跳过是有意的,而且写进 steps:报告里「没停 service」和「停 service 失败」
698
+ // 必须是两个不同的答案,否则读报告的人会以为清理失败了。
699
+ steps.push({
700
+ action: 'stop',
701
+ unit: units.service,
702
+ outcome: 'skipped',
703
+ executed: false,
704
+ error: null,
705
+ reason: 'includeService=false: this process runs inside that service, so stopping it would kill itself',
706
+ });
707
+ }
708
+ run('reset-failed', units.timer);
709
+ // reset-failed 只清失败状态,不会停掉单元:所以它对「自己所在的 service」安全,
710
+ // 而且能让下一次武装重新用上同一个单元名。
711
+ run('reset-failed', units.service);
712
+ const failed = steps.filter((step) => step.outcome === 'failed');
713
+ return {
714
+ ok: failed.length === 0,
715
+ error: failed.length === 0
716
+ ? null
717
+ : failed.map((step) => step.action + ' ' + step.unit + ': ' + step.error).join('; '),
718
+ units: [units.timer, units.service].filter((unit) => unit !== ''),
719
+ // A `stop` that systemd accepted = that unit existed here and is now gone.
720
+ stopped: steps.filter((step) => step.action === 'stop' && step.outcome === 'done').map((step) => step.unit),
721
+ residual: failed.map((step) => step.unit),
722
+ serviceStopSkipped: includeService !== true,
723
+ steps,
724
+ };
725
+ }
726
+
727
+ /**
728
+ * Arm the systemd timer — and give a leftover ONE more chance to disappear.
729
+ *
730
+ * The shape is deliberate:
731
+ *
732
+ * 1. clean up first (which STOPS the leftover, see above);
733
+ * 2. register;
734
+ * 3. if the registration was refused, clean up FORCED once more and register
735
+ * exactly once more — never a loop.
736
+ *
737
+ * The retry exists because the leftover can be a timer that was still being
738
+ * torn down a moment ago: one more attempt is a real chance, an endless retry
739
+ * is a frozen host. `outcome` says which of the two happened, so the report can
740
+ * tell "it was a leftover" from "systemd is broken here".
741
+ *
742
+ * @param {{ unitId: string, delaySeconds: number, command: string[], exec?: Function }} options
743
+ * @returns {object}
744
+ */
745
+ export function armSystemdWatchdog({ unitId, delaySeconds, command, exec = defaultExec }) {
746
+ const units = systemdUnitNames(unitId);
747
+ const cleanups = [];
748
+ const attempts = [];
749
+
750
+ const firstClean = cleanupSystemdUnit({ unitId, exec });
751
+ cleanups.push({ when: 'before-register', ...firstClean });
752
+ let registered = registerSystemdRun({ unitId, delaySeconds, command, exec });
753
+ attempts.push({ attempt: 1, ok: registered.ok === true, error: registered.error });
754
+ let retried = false;
755
+ if (registered.ok !== true) {
756
+ retried = true;
757
+ const forced = cleanupSystemdUnit({ unitId, exec });
758
+ cleanups.push({ when: 'before-retry', ...forced });
759
+ registered = registerSystemdRun({ unitId, delaySeconds, command, exec });
760
+ attempts.push({ attempt: 2, ok: registered.ok === true, error: registered.error });
761
+ }
762
+
763
+ const armed = registered.ok === true;
764
+ const cleanupFailed = cleanups.some((entry) => entry.ok !== true);
765
+ const stopped = cleanups.flatMap((entry) => entry.stopped);
766
+ const residual = [...new Set(cleanups.flatMap((entry) => entry.residual))];
767
+ const failedSteps = cleanups
768
+ .flatMap((entry) => entry.steps.map((step) => ({ when: entry.when, ...step })))
769
+ .filter((step) => step.outcome === 'failed');
770
+ const firstError = attempts[0].error || 'the first registration failed';
771
+
772
+ let outcome = ARM_OUTCOMES.FAILED;
773
+ if (armed) {
774
+ if (retried) outcome = ARM_OUTCOMES.ARMED_AFTER_RETRY;
775
+ else if (stopped.length > 0) outcome = ARM_OUTCOMES.CLEANED_THEN_ARMED;
776
+ else outcome = ARM_OUTCOMES.ARMED;
777
+ } else {
778
+ outcome = cleanupFailed ? ARM_OUTCOMES.CLEANUP_FAILED : ARM_OUTCOMES.FAILED;
779
+ }
780
+
781
+ const detail = armed
782
+ ? (retried
783
+ ? 'armed on the second attempt: the first systemd-run was refused (' + firstError + ')'
784
+ + ', one forced cleanup of ' + units.timer + ' / ' + units.service + ' was made and the retry worked'
785
+ : (stopped.length > 0
786
+ ? 'armed after stopping the leftover of an earlier run (' + stopped.join(', ') + ')'
787
+ : 'armed: no unit of an earlier run was left over'))
788
+ : ('not armed: ' + (registered.error || 'systemd-run refused')
789
+ + (cleanupFailed
790
+ ? ' — the cleanup itself failed (' + failedSteps.map((step) => step.action + ' ' + step.unit).join(', ') + ')'
791
+ + ', residual unit(s): ' + (residual.length > 0 ? residual.join(', ') : units.timer)
792
+ : ' — the cleanup found nothing to stop, so the name is taken by something this cleanup cannot see')
793
+ + (retried ? ' (one forced cleanup and one retry were already spent)' : ''));
794
+
795
+ return {
796
+ ok: armed,
797
+ outcome,
798
+ detail,
799
+ unitId: units.base,
800
+ timerUnit: units.timer,
801
+ serviceUnit: units.service,
802
+ error: armed ? null : (registered.error || 'the systemd timer could not be registered'),
803
+ retried,
804
+ attempts,
805
+ // `systemctl` and `systemd-run` are the same exec in production; the steps
806
+ // are kept so a reader can see WHICH command failed, not just that one did.
807
+ cleanup: {
808
+ ok: cleanupFailed !== true,
809
+ error: cleanups.map((entry) => entry.error).filter(Boolean).join('; ') || null,
810
+ runs: cleanups.length,
811
+ stopped,
812
+ residual,
813
+ failedSteps,
814
+ attempts: cleanups.map((entry) => ({ when: entry.when, ok: entry.ok, error: entry.error, steps: entry.steps })),
815
+ },
816
+ };
817
+ }
818
+
819
+ /**
820
+ * The report fields of an arming result — ONE shaper for every tier.
821
+ *
822
+ * WHY: an arming that failed must never read as a bare `armed: false`. Whoever
823
+ * opens `last-watchdog-report.json` has to see what was attempted (cleanup,
824
+ * retry), which step refused, and the name of the unit that stayed behind.
825
+ *
826
+ * @param {object|null} arm the result of the tier registration
827
+ * @returns {object} plain fields, ready to spread into a report
828
+ */
829
+ export function describeArmResult(arm) {
830
+ const record = arm !== null && typeof arm === 'object' ? arm : {};
831
+ const cleanup = record.cleanup !== null && typeof record.cleanup === 'object' ? record.cleanup : null;
832
+ const failedSteps = cleanup !== null && Array.isArray(cleanup.failedSteps) ? cleanup.failedSteps : [];
833
+ const armed = record.ok === true;
834
+ return {
835
+ armOutcome: typeof record.outcome === 'string' && record.outcome !== ''
836
+ ? record.outcome
837
+ : (armed ? ARM_OUTCOMES.ARMED : ARM_OUTCOMES.FAILED),
838
+ armDetail: typeof record.detail === 'string' ? record.detail : '',
839
+ armError: typeof record.error === 'string' && record.error !== '' ? record.error : null,
840
+ armRetried: record.retried === true,
841
+ armUnits: typeof record.timerUnit === 'string' && record.timerUnit !== ''
842
+ ? [record.timerUnit, record.serviceUnit].filter((unit) => typeof unit === 'string' && unit !== '')
843
+ : [],
844
+ armResidualUnits: failedSteps.map((step) => step.unit).filter((unit) => typeof unit === 'string' && unit !== ''),
845
+ armFailedSteps: failedSteps.map((step) => step.action + ' ' + step.unit + ': ' + step.error),
846
+ armCleanup: cleanup === null
847
+ ? null
848
+ : {
849
+ ok: cleanup.ok === true,
850
+ error: typeof cleanup.error === 'string' ? cleanup.error : null,
851
+ runs: Number.isFinite(cleanup.runs) ? cleanup.runs : 0,
852
+ stopped: Array.isArray(cleanup.stopped) ? cleanup.stopped : [],
853
+ residual: Array.isArray(cleanup.residual) ? cleanup.residual : [],
854
+ },
855
+ armAttempts: Array.isArray(record.attempts) ? record.attempts : [],
856
+ };
204
857
  }
205
858
 
206
859
  /**
@@ -289,12 +942,176 @@ export function removeDetachedProcess({
289
942
  /**
290
943
  * Remove the scheduling item of a tier.
291
944
  *
292
- * @param {{ tier: string, taskName?: string, unitId?: string, pidPath?: string, exec?: Function }} options
945
+ * `includeService` is passed through to the systemd-run tier: `false` means
946
+ * "stop the timer, leave the service alone". The watchdog CLI passes `false`
947
+ * because it RUNS inside that service (see cleanupSystemdUnit).
948
+ *
949
+ * @param {{
950
+ * tier: string,
951
+ * taskName?: string,
952
+ * unitId?: string,
953
+ * pidPath?: string,
954
+ * exec?: Function,
955
+ * includeService?: boolean,
956
+ * }} options
293
957
  * @returns {{ ok: boolean, error: string|null }}
294
958
  */
295
- export function removeScheduleItem({ tier, taskName = '', unitId = '', pidPath = '', exec = defaultExec }) {
959
+ /**
960
+ * REMOVE THE LEFTOVER CAPABILITY PROBES (2026-11-01).
961
+ *
962
+ * The capability probe (lib/scheduler-capability.js) registers a one-off task to
963
+ * prove the scheduler works, and deletes it again. That cleanup is the LAST step
964
+ * of the probe, so it is exactly what a crash, a kill or a refusal leaves behind:
965
+ * measured on the real machine, two probe tasks (`…-capability-probe-…`) were
966
+ * still registered, both `Ready`, neither with a next run time.
967
+ *
968
+ * Nothing ever cleaned them up: the boot disarm only removed the pending marker
969
+ * and the WATCHDOG task it named. A task per boot therefore accumulated.
970
+ *
971
+ * THE MATCH IS BY OUR OWN PREFIX, NEVER BY WILDCARD. The listing is read, every
972
+ * name is compared against `PROBE_TASK_PREFIX + '-'`, and only those names are
973
+ * ever passed to a delete. A broader pattern (or `*`) would put other people's
974
+ * scheduled tasks in reach, which is not this plugin's business.
975
+ *
976
+ * A failure is deliberately NOT thrown: cleanup runs during boot, and a refused
977
+ * delete must not stop the host from coming up. It is reported instead, so the
978
+ * log and the disarm record can say what stayed behind.
979
+ *
980
+ * @param {{
981
+ * prefix?: string,
982
+ * exec?: (file: string, args: string[]) => { ok: boolean, stdout: string, error: string|null },
983
+ * limit?: number,
984
+ * }} [options]
985
+ * @returns {{
986
+ * ok: boolean,
987
+ * prefix: string,
988
+ * matched: string[],
989
+ * removed: string[],
990
+ * failed: Array<{ taskName: string, error: string }>,
991
+ * error: string|null,
992
+ * }}
993
+ */
994
+ export function removeLeftoverProbeTasks({
995
+ prefix = PROBE_TASK_PREFIX,
996
+ exec = defaultExec,
997
+ limit = 50,
998
+ platform = process.platform,
999
+ } = {}) {
1000
+ const want = typeof prefix === 'string' ? prefix.trim() : '';
1001
+ const out = {
1002
+ ok: false,
1003
+ prefix: want,
1004
+ matched: [],
1005
+ removed: [],
1006
+ failed: [],
1007
+ skippedFolders: [],
1008
+ skipped: false,
1009
+ reason: null,
1010
+ error: null,
1011
+ };
1012
+ if (want === '') {
1013
+ out.error = 'removeLeftoverProbeTasks needs the prefix of this plugin\'s own probe task';
1014
+ return out;
1015
+ }
1016
+ // NOTHING TO CLEAN UP OFF WINDOWS (2026-11-02). `schtasks` does not exist on
1017
+ // Linux / WSL / macOS, so every boot wrote a FAILURE ("the scheduled tasks of
1018
+ // this machine cannot be listed") into the disarm record for a task that can
1019
+ // never exist there. That is a fake fault, and it buries the real ones. The
1020
+ // cleanup is SKIPPED, and the record says why.
1021
+ if (platform !== 'win32') {
1022
+ out.ok = true;
1023
+ out.skipped = true;
1024
+ out.reason = 'not-applicable: this platform has no scheduled tasks (schtasks is a Windows tool)';
1025
+ return out;
1026
+ }
1027
+ // The marker that makes a name OUR name: the prefix plus its separator. A task
1028
+ // called `dsh-autorestart-something-else` is not a probe of this plugin.
1029
+ const ourPrefix = want + '-';
1030
+ const listing = exec('schtasks', ['/Query', '/FO', 'CSV', '/NH']);
1031
+ if (!listing || listing.ok !== true) {
1032
+ out.error = (listing && listing.error) || 'the scheduled tasks of this machine cannot be listed';
1033
+ return out;
1034
+ }
1035
+ const names = parseTaskNamesCsv(listing.stdout);
1036
+ for (const raw of names) {
1037
+ // THE REAL MACHINE PUTS A BACKSLASH IN FRONT OF THE NAME (2026-11-01).
1038
+ //
1039
+ // `schtasks /Query /FO CSV /NH` answered, verbatim:
1040
+ // "\dsh-autorestart-capability-probe-14264-mvxfr2fo","N/A","Ready"
1041
+ // "\dsh-autorestart-capability-probe-18520-mvxo3eug","N/A","Ready"
1042
+ // i.e. the root-folder task is reported as `\<name>`. Comparing that against
1043
+ // a prefix WITHOUT the backslash matched nothing, so `matched` stayed empty
1044
+ // and not one task was ever deleted. Measured: both tasks were still there
1045
+ // afterwards. (The old unit tests built names with no backslash, which is
1046
+ // exactly why the suite was green while the machine was not.)
1047
+ //
1048
+ // Strip exactly ONE leading backslash — never a loop, never a regex: a name
1049
+ // that still contains a backslash after that is a task inside a FOLDER.
1050
+ const name = raw.startsWith('\\') ? raw.slice(1) : raw;
1051
+ if (name.includes('\\')) {
1052
+ // `\SomeFolder\dsh-autorestart-capability-probe-x` is NOT a root-level
1053
+ // probe of ours. Deleting somebody's task because its leaf name looks like
1054
+ // ours is the failure this check exists to prevent: refused and reported.
1055
+ out.skippedFolders.push(name);
1056
+ continue;
1057
+ }
1058
+ if (name.startsWith(ourPrefix) !== true) continue;
1059
+ if (out.matched.length >= Math.max(1, Math.trunc(Number(limit) || 50))) break;
1060
+ out.matched.push(name);
1061
+ }
1062
+ for (const name of out.matched) {
1063
+ const answer = exec('schtasks', ['/Delete', '/TN', name, '/F']);
1064
+ if (answer && answer.ok === true) {
1065
+ out.removed.push(name);
1066
+ } else {
1067
+ out.failed.push({ taskName: name, error: (answer && answer.error) || 'the task was refused' });
1068
+ }
1069
+ }
1070
+ out.ok = out.error === null && out.failed.length === 0;
1071
+ return out;
1072
+ }
1073
+
1074
+ /**
1075
+ * The first column of `schtasks /Query /FO CSV /NH` — the task names.
1076
+ *
1077
+ * CSV, not the human listing: the table of `schtasks /Query` is TRANSLATED
1078
+ * (the column headers read 任务名 / 下次运行时间 on a Chinese Windows), while a
1079
+ * name is a name on every machine. Empty lines and the folder rows (`Folder: …`)
1080
+ * of a non-`/NH` answer are ignored.
1081
+ *
1082
+ * @param {string} stdout
1083
+ * @returns {string[]}
1084
+ */
1085
+ export function parseTaskNamesCsv(stdout) {
1086
+ const text = typeof stdout === 'string' ? stdout : '';
1087
+ const names = [];
1088
+ for (const line of text.split(/\r?\n/)) {
1089
+ const trimmed = line.trim();
1090
+ if (trimmed === '') continue;
1091
+ if (trimmed.toLowerCase().startsWith('folder')) continue;
1092
+ let name = '';
1093
+ if (trimmed.startsWith('"')) {
1094
+ const closing = trimmed.indexOf('"', 1);
1095
+ name = closing === -1 ? trimmed.slice(1) : trimmed.slice(1, closing);
1096
+ } else {
1097
+ name = trimmed.split(',')[0];
1098
+ }
1099
+ name = name.trim();
1100
+ if (name !== '' && name.startsWith('"') === false) names.push(name);
1101
+ }
1102
+ return names;
1103
+ }
1104
+
1105
+ export function removeScheduleItem({
1106
+ tier,
1107
+ taskName = '',
1108
+ unitId = '',
1109
+ pidPath = '',
1110
+ exec = defaultExec,
1111
+ includeService = true,
1112
+ }) {
296
1113
  if (tier === TIERS.SCHEDULED_TASK) return removeScheduledTask({ taskName, exec });
297
- if (tier === TIERS.SYSTEMD_RUN) return cleanupSystemdUnit({ unitId, exec });
1114
+ if (tier === TIERS.SYSTEMD_RUN) return cleanupSystemdUnit({ unitId, exec, includeService });
298
1115
  if (tier === TIERS.DETACHED_PROCESS) return removeDetachedProcess({ pidPath });
299
1116
  return { ok: true, error: null };
300
1117
  }