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.
- package/CHANGELOG.md +313 -0
- package/README.md +465 -40
- package/assets/icon-9c245e5b.ico +0 -0
- package/assets/icon-rescue-1902bb15.ico +0 -0
- package/assets/icon-rescue-1902bb15.png +0 -0
- package/client.js +3002 -170
- package/cordis.patch.yml +12 -0
- package/docs/README.zh.md +231 -23
- package/docs/platform-matrix.md +171 -55
- package/docs/real-machine-verification-0.8.6.md +202 -0
- package/index.js +13272 -8513
- package/lib/autostart-advice.js +31 -2
- package/lib/backup.js +2768 -21
- package/lib/checkpoint.js +214 -7
- package/lib/cookie-clear.js +260 -0
- package/lib/lifecycle.js +23 -40
- package/lib/plugin-compare.js +499 -0
- package/lib/plugin-failure.js +382 -0
- package/lib/plugin-recover.js +1557 -0
- package/lib/plugin-watch.js +213 -0
- package/lib/rescue-paths.js +982 -56
- package/lib/restart-phase.js +5 -0
- package/lib/restart-verdict.js +379 -0
- package/lib/restart.js +244 -7
- package/lib/scheduler-capability.js +19 -3
- package/lib/sleep.js +190 -29
- package/lib/watchdog-disarm.js +46 -2
- package/lib/watchdog-schedule.js +834 -17
- package/lib/watchdog-tier.js +8 -1
- package/lib/watchdog.js +1222 -47
- package/locale/en.json +175 -68
- package/locale/zh.json +175 -68
- package/package.json +3 -2
- package/screenshots.json +6 -6
- package/scripts/README.md +6 -3
- package/scripts/build-icon.mjs +998 -0
- package/scripts/clear-dsh-auth-cookies.mjs +257 -0
- package/scripts/emergency-disable.sh +69 -2
- package/scripts/emergency-toggle.ps1 +85 -69
- package/scripts/rescue-strings.zh.json +36 -18
- package/scripts/restore-profile.ps1 +182 -1
- package/scripts/watchdog.mjs +432 -79
package/lib/watchdog-schedule.js
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
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
|
-
*
|
|
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, '&')
|
|
39
389
|
.replace(/</g, '<')
|
|
@@ -67,8 +417,8 @@ export function buildScheduledTaskXml({ startIso, endIso, command, description =
|
|
|
67
417
|
' </Settings>',
|
|
68
418
|
' <Actions Context="Author">',
|
|
69
419
|
' <Exec>',
|
|
70
|
-
' <Command>
|
|
71
|
-
' <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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
199
|
-
*
|
|
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
|
|
203
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
}
|