dsh-autorestart 0.6.1 → 0.7.0
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/README.md +457 -41
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/assets/screenshot-4.png +0 -0
- package/assets/screenshot-5.png +0 -0
- package/client.js +5973 -3222
- package/cordis.patch.yml +55 -9
- package/docs/README.zh.md +305 -27
- package/index.js +2522 -127
- package/lib/access.js +212 -0
- package/lib/activity.js +124 -0
- package/lib/autostart-advice.js +198 -0
- package/lib/backup.js +131 -4
- package/lib/blockers.js +228 -0
- package/lib/checkpoint.js +147 -0
- package/lib/diagnostics.js +180 -0
- package/lib/health.js +264 -0
- package/lib/host-kind.js +145 -0
- package/lib/lifecycle.js +17 -4
- package/lib/node-runtime.js +144 -0
- package/lib/queued.js +146 -0
- package/lib/restart-verdict.js +327 -0
- package/lib/restart.js +823 -56
- package/lib/sleep.js +46 -49
- package/lib/startup-mode.js +491 -0
- package/lib/supervisor-config.js +735 -0
- package/lib/supervisor-install.js +557 -0
- package/lib/supervisor.js +1276 -0
- package/locale/en.json +247 -60
- package/locale/zh.json +247 -60
- package/package.json +7 -2
- package/screenshots.json +6 -4
- package/scripts/emergency-disable.cmd +27 -0
- package/scripts/emergency-toggle.ps1 +302 -0
- package/scripts/run-tests.ps1 +46 -0
- package/supervisor/supervisor.js +884 -0
package/lib/restart.js
CHANGED
|
@@ -15,12 +15,26 @@
|
|
|
15
15
|
* ESM only, Node builtins only. No DSH / npm imports.
|
|
16
16
|
*/
|
|
17
17
|
import { execFileSync, spawn } from 'node:child_process';
|
|
18
|
-
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { mkdirSync, readFileSync, readlinkSync, renameSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
|
|
21
21
|
/** Default name of the one-shot scheduled-task fallback. */
|
|
22
22
|
export const DEFAULT_TASK_NAME = 'dsh-autorestart-restart';
|
|
23
23
|
|
|
24
|
+
/** Default name of the one-shot scheduled task that runs the rollback watcher. */
|
|
25
|
+
export const DEFAULT_WATCH_TASK_NAME = 'dsh-autorestart-watch';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How long the restart command may run before the plugin stops waiting for it.
|
|
29
|
+
*
|
|
30
|
+
* The command is the user's own: it usually returns in a second or two
|
|
31
|
+
* (`systemctl --user restart …`, a small shell script). A command that starts
|
|
32
|
+
* the host in the FOREGROUND never returns — that is not a failure, but the
|
|
33
|
+
* plugin must not wait for it for ever, so the window is bounded and the
|
|
34
|
+
* outcome is reported as "still running" instead of "failed".
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_COMMAND_TIMEOUT_MS = 30000;
|
|
37
|
+
|
|
24
38
|
/** Default image name that `taskkill` matches on Windows. */
|
|
25
39
|
export const DEFAULT_IMAGE_NAME = 'DeepSeek Harness.exe';
|
|
26
40
|
|
|
@@ -43,14 +57,360 @@ function nonEmptyString(value) {
|
|
|
43
57
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
44
58
|
}
|
|
45
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The FILE NAMES of the desktop app's executable.
|
|
62
|
+
*
|
|
63
|
+
* The verdict is about the FILE NAME, never about the whole path: the desktop
|
|
64
|
+
* app ships its own node INSIDE its installation directory
|
|
65
|
+
* (`…\DeepSeek Harness\resources\runtime\…\node\bin\node.exe`), and a HOSTED
|
|
66
|
+
* deployment that runs with that node is NOT the desktop app. The old rule (a
|
|
67
|
+
* regex over the whole path) called every such host the desktop app, so the
|
|
68
|
+
* derivation refused to write a restart command for it (`desktop-app`, nothing
|
|
69
|
+
* to write) even though the host was a perfectly ordinary hosted one.
|
|
70
|
+
*/
|
|
71
|
+
const DESKTOP_EXE_NAMES = new Set(['deepseek harness']);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The images that BREAK the parent chain.
|
|
75
|
+
*
|
|
76
|
+
* A shell between this process and the Electron shell means that SOMEBODY ELSE
|
|
77
|
+
* started it (a terminal, a service, a scheduled task): the desktop app is not
|
|
78
|
+
* in charge of this host, whatever the environment says.
|
|
79
|
+
*/
|
|
80
|
+
const SHELL_IMAGE_NAMES = new Set([
|
|
81
|
+
'cmd',
|
|
82
|
+
'powershell',
|
|
83
|
+
'pwsh',
|
|
84
|
+
'bash',
|
|
85
|
+
'sh',
|
|
86
|
+
'zsh',
|
|
87
|
+
'fish',
|
|
88
|
+
'dash',
|
|
89
|
+
'conhost',
|
|
90
|
+
'terminal',
|
|
91
|
+
'windowsterminal',
|
|
92
|
+
'wt',
|
|
93
|
+
'nu',
|
|
94
|
+
'alacritty',
|
|
95
|
+
'wezterm-gui',
|
|
96
|
+
'hyper',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/** How far up the parent chain the plugin looks for the Electron shell. */
|
|
100
|
+
const MAX_ANCESTOR_DEPTH = 4;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The FILE NAME of an executable — the last segment, whatever the separator.
|
|
104
|
+
*
|
|
105
|
+
* @param {unknown} execPath
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
export function executableBasename(execPath) {
|
|
109
|
+
const text = typeof execPath === 'string' ? execPath.trim() : '';
|
|
110
|
+
if (text === '') return '';
|
|
111
|
+
const parts = text.split(/[\\/]/);
|
|
112
|
+
return parts[parts.length - 1] ?? '';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The name of an executable, normalized for comparison: lower case, without
|
|
117
|
+
* the `.exe` suffix, and the separators (` `, `-`, `_`) folded into one space.
|
|
118
|
+
*
|
|
119
|
+
* @param {unknown} name
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
export function normalizedExecutableName(name) {
|
|
123
|
+
return String(name ?? '')
|
|
124
|
+
.toLowerCase()
|
|
125
|
+
.replace(/\.exe$/i, '')
|
|
126
|
+
.replace(/[-_\s]+/g, ' ')
|
|
127
|
+
.trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Is this the file name of the desktop app?
|
|
132
|
+
*
|
|
133
|
+
* `DeepSeek Harness.exe` and `deepseek-harness.exe` are the same app; a
|
|
134
|
+
* `node.exe` that happens to live inside the app's directory is not.
|
|
135
|
+
*
|
|
136
|
+
* @param {unknown} execPath
|
|
137
|
+
* @returns {boolean}
|
|
138
|
+
*/
|
|
139
|
+
export function isDesktopExecutableName(execPath) {
|
|
140
|
+
return DESKTOP_EXE_NAMES.has(normalizedExecutableName(executableBasename(execPath)));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* What is known about one process (POSIX: /proc).
|
|
145
|
+
*
|
|
146
|
+
* @param {number} pid
|
|
147
|
+
* @returns {{ pid: number, ppid: number, imageName: string, commandLine: string }|null}
|
|
148
|
+
*/
|
|
149
|
+
function readPosixProcessInfo(pid) {
|
|
150
|
+
const number = Number(pid);
|
|
151
|
+
if (!Number.isFinite(number) || number <= 0) return null;
|
|
152
|
+
const base = '/proc/' + number;
|
|
153
|
+
let status = '';
|
|
154
|
+
try {
|
|
155
|
+
status = readFileSync(base + '/status', 'utf8');
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const parent = /PPid:\s*(\d+)/.exec(status);
|
|
160
|
+
let commandLine = '';
|
|
161
|
+
try {
|
|
162
|
+
const raw = readFileSync(base + '/cmdline');
|
|
163
|
+
commandLine = (Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw))
|
|
164
|
+
.split('\0')
|
|
165
|
+
.filter((part) => part !== '')
|
|
166
|
+
.join(' ');
|
|
167
|
+
} catch {
|
|
168
|
+
commandLine = '';
|
|
169
|
+
}
|
|
170
|
+
let imageName = '';
|
|
171
|
+
try {
|
|
172
|
+
imageName = path.basename(readlinkSync(base + '/exe'));
|
|
173
|
+
} catch {
|
|
174
|
+
imageName = '';
|
|
175
|
+
}
|
|
176
|
+
if (imageName === '') {
|
|
177
|
+
const first = commandLine.split(' ')[0] ?? '';
|
|
178
|
+
imageName = executableBasename(first);
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
pid: number,
|
|
182
|
+
ppid: parent ? Number(parent[1]) : 0,
|
|
183
|
+
imageName,
|
|
184
|
+
commandLine,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Parse the CSV table of `wmic process get …`.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} text
|
|
192
|
+
* @returns {Array<{ pid: number, ppid: number, imageName: string }>}
|
|
193
|
+
*/
|
|
194
|
+
function parseWmicProcessTable(text) {
|
|
195
|
+
const lines = String(text ?? '')
|
|
196
|
+
.split(/\r?\n/)
|
|
197
|
+
.map((line) => line.trim())
|
|
198
|
+
.filter((line) => line !== '');
|
|
199
|
+
if (lines.length < 2) return [];
|
|
200
|
+
const header = lines[0].split(',').map((part) => part.trim().toLowerCase());
|
|
201
|
+
const nameIndex = header.indexOf('name');
|
|
202
|
+
const ppidIndex = header.indexOf('parentprocessid');
|
|
203
|
+
const pidIndex = header.indexOf('processid');
|
|
204
|
+
if (nameIndex < 0 || ppidIndex < 0 || pidIndex < 0) return [];
|
|
205
|
+
const last = Math.max(nameIndex, ppidIndex, pidIndex);
|
|
206
|
+
const rows = [];
|
|
207
|
+
for (const line of lines.slice(1)) {
|
|
208
|
+
const cells = line.split(',').map((cell) => cell.trim().replace(/^"(.*)"$/, '$1'));
|
|
209
|
+
if (cells.length <= last) continue;
|
|
210
|
+
const pid = Number(cells[pidIndex]);
|
|
211
|
+
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
212
|
+
rows.push({ pid, ppid: Number(cells[ppidIndex]) || 0, imageName: cells[nameIndex] });
|
|
213
|
+
}
|
|
214
|
+
return rows;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Parse the JSON table of the CIM query.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} text
|
|
221
|
+
* @returns {Array<{ pid: number, ppid: number, imageName: string }>}
|
|
222
|
+
*/
|
|
223
|
+
function parseCimProcessTable(text) {
|
|
224
|
+
const trimmed = String(text ?? '').trim();
|
|
225
|
+
if (trimmed === '') return [];
|
|
226
|
+
let parsed;
|
|
227
|
+
try {
|
|
228
|
+
parsed = JSON.parse(trimmed);
|
|
229
|
+
} catch {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
const list = Array.isArray(parsed) ? parsed : [parsed];
|
|
233
|
+
const rows = [];
|
|
234
|
+
for (const entry of list) {
|
|
235
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
236
|
+
const pid = Number(entry.ProcessId);
|
|
237
|
+
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
238
|
+
rows.push({
|
|
239
|
+
pid,
|
|
240
|
+
ppid: Number(entry.ParentProcessId) || 0,
|
|
241
|
+
imageName: String(entry.Name ?? ''),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return rows;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Ask Windows for its processes: `wmic` first (it answers in ~150 ms), the CIM
|
|
249
|
+
* query of PowerShell as the fallback for the versions that removed `wmic`.
|
|
250
|
+
*
|
|
251
|
+
* @returns {Array<{ pid: number, ppid: number, imageName: string }>}
|
|
252
|
+
*/
|
|
253
|
+
function queryWindowsProcesses() {
|
|
254
|
+
try {
|
|
255
|
+
const stdout = execFileSync(
|
|
256
|
+
'wmic',
|
|
257
|
+
['process', 'get', 'Name,ParentProcessId,ProcessId', '/FORMAT:CSV'],
|
|
258
|
+
{ windowsHide: true, timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' },
|
|
259
|
+
);
|
|
260
|
+
const rows = parseWmicProcessTable(String(stdout ?? ''));
|
|
261
|
+
if (rows.length > 0) return rows;
|
|
262
|
+
} catch {
|
|
263
|
+
// `wmic` is gone on the newer Windows versions: the CIM query below.
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const stdout = execFileSync(
|
|
267
|
+
'powershell.exe',
|
|
268
|
+
[
|
|
269
|
+
'-NoProfile',
|
|
270
|
+
'-NonInteractive',
|
|
271
|
+
'-Command',
|
|
272
|
+
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Json -Compress',
|
|
273
|
+
],
|
|
274
|
+
{ windowsHide: true, timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' },
|
|
275
|
+
);
|
|
276
|
+
return parseCimProcessTable(String(stdout ?? ''));
|
|
277
|
+
} catch {
|
|
278
|
+
// No process table: the parent chain stays unavailable (and silent).
|
|
279
|
+
}
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The Windows process table — read once per process, then remembered. */
|
|
284
|
+
let windowsProcessTable = null;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The whole Windows process table: the image name and the parent of every
|
|
288
|
+
* process.
|
|
289
|
+
*
|
|
290
|
+
* @returns {Map<number, { pid: number, ppid: number, imageName: string }>}
|
|
291
|
+
*/
|
|
292
|
+
function readWindowsProcessTable() {
|
|
293
|
+
if (windowsProcessTable !== null) return windowsProcessTable;
|
|
294
|
+
const table = new Map();
|
|
295
|
+
for (const row of queryWindowsProcesses()) {
|
|
296
|
+
table.set(row.pid, row);
|
|
297
|
+
}
|
|
298
|
+
windowsProcessTable = table;
|
|
299
|
+
return table;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The parent chain of a process, nearest ancestor first.
|
|
304
|
+
*
|
|
305
|
+
* POSIX reads /proc (free); Windows reads the process table once. When the
|
|
306
|
+
* platform offers neither, the chain is EMPTY — an unknown chain is not
|
|
307
|
+
* evidence, it only means the chain cannot be used.
|
|
308
|
+
*
|
|
309
|
+
* @param {number} rootPid the pid to start from (the parent of this process)
|
|
310
|
+
* @param {{ platform?: string }} [options]
|
|
311
|
+
* @returns {Array<{ pid: number, ppid: number, imageName: string, commandLine?: string }>}
|
|
312
|
+
*/
|
|
313
|
+
export function readProcessChain(rootPid, { platform = process.platform } = {}) {
|
|
314
|
+
const start = Number(rootPid);
|
|
315
|
+
if (!Number.isFinite(start) || start <= 0) return [];
|
|
316
|
+
const chain = [];
|
|
317
|
+
const seen = new Set();
|
|
318
|
+
let pid = start;
|
|
319
|
+
while (pid > 0 && chain.length < MAX_ANCESTOR_DEPTH && !seen.has(pid)) {
|
|
320
|
+
seen.add(pid);
|
|
321
|
+
const info = platform === 'win32'
|
|
322
|
+
? readWindowsProcessTable().get(pid) ?? null
|
|
323
|
+
: readPosixProcessInfo(pid);
|
|
324
|
+
if (info === null) break;
|
|
325
|
+
chain.push(info);
|
|
326
|
+
pid = Number(info.ppid) || 0;
|
|
327
|
+
}
|
|
328
|
+
return chain;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Is this process the child of the Electron shell of the desktop app?
|
|
333
|
+
*
|
|
334
|
+
* The desktop host is started by the Electron MAIN process, which runs the
|
|
335
|
+
* Electron binary as node: the host therefore carries `ELECTRON_RUN_AS_NODE=1`
|
|
336
|
+
* AND its parent is the Electron shell.
|
|
337
|
+
*
|
|
338
|
+
* The marker ALONE is not enough: a host that was started from a shell the
|
|
339
|
+
* desktop app spawned inherits it too. The parent chain decides — and a chain
|
|
340
|
+
* that cannot be read is simply not used (it never denies the desktop verdict,
|
|
341
|
+
* it only stays silent).
|
|
342
|
+
*
|
|
343
|
+
* @param {{
|
|
344
|
+
* env?: object,
|
|
345
|
+
* ppid?: number,
|
|
346
|
+
* platform?: string,
|
|
347
|
+
* readChain?: Function,
|
|
348
|
+
* }} [options]
|
|
349
|
+
* @returns {{ isElectronShell: boolean, evidence: string }}
|
|
350
|
+
*/
|
|
351
|
+
export function detectElectronShell({
|
|
352
|
+
env = process.env,
|
|
353
|
+
ppid = process.ppid,
|
|
354
|
+
platform = process.platform,
|
|
355
|
+
readChain = readProcessChain,
|
|
356
|
+
} = {}) {
|
|
357
|
+
const marker = String((env && typeof env === 'object' ? env.ELECTRON_RUN_AS_NODE : '') ?? '')
|
|
358
|
+
.trim()
|
|
359
|
+
.toLowerCase();
|
|
360
|
+
if (marker !== '1' && marker !== 'true') return { isElectronShell: false, evidence: '' };
|
|
361
|
+
let chain = [];
|
|
362
|
+
try {
|
|
363
|
+
chain = readChain(ppid, { platform });
|
|
364
|
+
} catch {
|
|
365
|
+
chain = [];
|
|
366
|
+
}
|
|
367
|
+
if (!Array.isArray(chain) || chain.length === 0) {
|
|
368
|
+
// The chain could not be read: this signal stays silent.
|
|
369
|
+
return { isElectronShell: false, evidence: '' };
|
|
370
|
+
}
|
|
371
|
+
for (const ancestor of chain) {
|
|
372
|
+
const image = normalizedExecutableName(ancestor && ancestor.imageName);
|
|
373
|
+
if (image === '') continue;
|
|
374
|
+
if (SHELL_IMAGE_NAMES.has(image)) break; // somebody else started this host
|
|
375
|
+
if (DESKTOP_EXE_NAMES.has(image) || image === 'electron') {
|
|
376
|
+
return {
|
|
377
|
+
isElectronShell: true,
|
|
378
|
+
evidence: 'ancestor ' + ancestor.pid + ': ' + ancestor.imageName,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { isElectronShell: false, evidence: '' };
|
|
383
|
+
}
|
|
384
|
+
|
|
46
385
|
/**
|
|
47
386
|
* Describe the runtime we are executing in.
|
|
48
387
|
*
|
|
388
|
+
* THE DESKTOP APP IS RECOGNISED BY THE FILE NAME OF THE EXECUTABLE.
|
|
389
|
+
*
|
|
49
390
|
* Verified: when the Host runs inside the desktop app, `process.execPath` is the
|
|
50
|
-
* app launcher itself (
|
|
391
|
+
* app launcher itself (`…\DeepSeek Harness\DeepSeek Harness.exe`, no CLI args
|
|
51
392
|
* needed). Any other runtime is not a desktop app and has no app exe to launch.
|
|
52
393
|
*
|
|
53
|
-
*
|
|
394
|
+
* The whole PATH is never used: the desktop app ships its own node inside its
|
|
395
|
+
* installation directory, and a hosted deployment that runs with that node
|
|
396
|
+
* (`…\DeepSeek Harness\resources\runtime\…\node.exe`) is a hosted host, not the
|
|
397
|
+
* desktop app. Judging it by the path sent every such host into the
|
|
398
|
+
* "desktop app, nothing to do" branch — it never got a restart command.
|
|
399
|
+
*
|
|
400
|
+
* When the file name cannot answer the question, the harder evidence is used:
|
|
401
|
+
* the desktop host is the child of the Electron main process (it runs the
|
|
402
|
+
* Electron binary as node, so it carries `ELECTRON_RUN_AS_NODE=1` and its
|
|
403
|
+
* parent is the Electron shell). A process tree that cannot be read never
|
|
404
|
+
* denies the desktop verdict — it only stays silent.
|
|
405
|
+
*
|
|
406
|
+
* @param {{
|
|
407
|
+
* env?: object,
|
|
408
|
+
* execPath?: string,
|
|
409
|
+
* pid?: number,
|
|
410
|
+
* ppid?: number,
|
|
411
|
+
* platform?: string,
|
|
412
|
+
* readChain?: Function,
|
|
413
|
+
* }} [overrides]
|
|
54
414
|
* @returns {{
|
|
55
415
|
* platform: string,
|
|
56
416
|
* pid: number,
|
|
@@ -69,17 +429,25 @@ export function detectEnvironment({
|
|
|
69
429
|
pid = process.pid,
|
|
70
430
|
ppid = process.ppid,
|
|
71
431
|
platform = process.platform,
|
|
432
|
+
readChain = readProcessChain,
|
|
72
433
|
} = {}) {
|
|
73
|
-
void env; // accepted for signature compatibility / future overrides
|
|
74
434
|
const exe = typeof execPath === 'string' ? execPath : '';
|
|
75
|
-
const
|
|
435
|
+
const nameIsDesktop = isDesktopExecutableName(exe);
|
|
436
|
+
// The process tree is only consulted when the file name does not answer.
|
|
437
|
+
const electron = nameIsDesktop
|
|
438
|
+
? { isElectronShell: false, evidence: '' }
|
|
439
|
+
: detectElectronShell({ env, ppid, platform, readChain });
|
|
440
|
+
const isDesktopApp = nameIsDesktop || electron.isElectronShell;
|
|
76
441
|
return {
|
|
77
442
|
platform,
|
|
78
443
|
pid,
|
|
79
444
|
ppid,
|
|
80
445
|
execPath: exe,
|
|
81
446
|
isDesktopApp,
|
|
82
|
-
|
|
447
|
+
// The exe the plugin may launch again is the app launcher ITSELF. When
|
|
448
|
+
// only the process tree says "desktop app", the binary is unknown — and
|
|
449
|
+
// an unknown binary is never started again by the plugin.
|
|
450
|
+
appExePath: nameIsDesktop ? exe : null,
|
|
83
451
|
hostPid: pid,
|
|
84
452
|
mainPid: ppid,
|
|
85
453
|
killPid: ppid,
|
|
@@ -230,62 +598,132 @@ export function buildGateScript({
|
|
|
230
598
|
/** How long the watcher waits for the relaunched host before it rolls back. */
|
|
231
599
|
export const DEFAULT_ROLLBACK_TIMEOUT_MS = 90000;
|
|
232
600
|
|
|
601
|
+
/**
|
|
602
|
+
* How long ONE restore step (the configuration files, the plugin tree) may take
|
|
603
|
+
* before the watcher gives it up.
|
|
604
|
+
*
|
|
605
|
+
* A `robocopy` of a 117 MB plugin tree can take a while, but it must never
|
|
606
|
+
* hang the watcher for ever: a restore that does not finish in time is
|
|
607
|
+
* abandoned and the reason is written into the rollback report.
|
|
608
|
+
*/
|
|
609
|
+
export const DEFAULT_ROLLBACK_COPY_TIMEOUT_MS = 120000;
|
|
610
|
+
|
|
233
611
|
/**
|
|
234
612
|
* Build the PowerShell watcher the `.cmd` helper runs right after the relaunch.
|
|
235
613
|
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
614
|
+
* WHAT IT DOES NOW (and what it did wrong before):
|
|
615
|
+
*
|
|
616
|
+
* - It does NOT poll the health route of the OLD instance any more. The
|
|
617
|
+
* desktop app listens on a RANDOM port (`--port 0`), so the port of the
|
|
618
|
+
* instance that is being replaced is worthless: the watcher waited for an
|
|
619
|
+
* answer that could never come, and then rolled back a perfectly healthy
|
|
620
|
+
* restart. The port of the NEW instance is read from the instance record
|
|
621
|
+
* (lib/access.js), which also carries the boot id — so the watcher knows
|
|
622
|
+
* that the answer comes from the instance that replaced the old one.
|
|
623
|
+
* - The restore runs with a TIME LIMIT. A `robocopy` that hangs is stopped,
|
|
624
|
+
* and the report says so.
|
|
625
|
+
* - The restore does NOT start a second copy of the host. The old behaviour
|
|
626
|
+
* (`Start-Process -FilePath $exe`) launched a SECOND app next to the one
|
|
627
|
+
* that was already starting up — two web servers, two ports, and a
|
|
628
|
+
* confusing window. The watcher only restores the files and writes the
|
|
629
|
+
* report; the host that was launched is left alone.
|
|
630
|
+
* - The watcher cleans up after itself: the helper script and the marker
|
|
631
|
+
* file are removed when it ends.
|
|
241
632
|
*
|
|
242
|
-
* The report is written WITHOUT a BOM even on PowerShell 5.1, because the
|
|
243
|
-
* parses it with JSON.parse.
|
|
633
|
+
* The report is written WITHOUT a BOM even on PowerShell 5.1, because the
|
|
634
|
+
* plugin parses it with JSON.parse.
|
|
244
635
|
*
|
|
245
636
|
* @param {{
|
|
246
637
|
* healthUrl?: string,
|
|
638
|
+
* healthPath?: string,
|
|
639
|
+
* portFile?: string,
|
|
640
|
+
* expectedBootId?: string|null,
|
|
641
|
+
* outcomeFile?: string,
|
|
247
642
|
* backupDir?: string,
|
|
248
643
|
* profileDir?: string,
|
|
249
644
|
* reportPath?: string,
|
|
645
|
+
* lockFile?: string,
|
|
250
646
|
* appExePath?: string|null,
|
|
251
647
|
* timeoutMs?: number,
|
|
648
|
+
* rollbackTimeoutMs?: number,
|
|
649
|
+
* since?: string|number|null,
|
|
650
|
+
* selfCleanPaths?: string[],
|
|
252
651
|
* }} [options]
|
|
253
652
|
* @returns {string} script text (CRLF line endings)
|
|
254
653
|
*/
|
|
255
654
|
export function buildWatchScript({
|
|
256
655
|
healthUrl = '',
|
|
656
|
+
healthPath = '/__dsh_autorestart/health',
|
|
657
|
+
portFile = '',
|
|
658
|
+
expectedBootId = null,
|
|
659
|
+
outcomeFile = '',
|
|
257
660
|
backupDir = '',
|
|
258
661
|
profileDir = '',
|
|
259
662
|
reportPath = '',
|
|
663
|
+
lockFile = '',
|
|
260
664
|
appExePath = null,
|
|
261
665
|
timeoutMs = DEFAULT_ROLLBACK_TIMEOUT_MS,
|
|
666
|
+
rollbackTimeoutMs = DEFAULT_ROLLBACK_COPY_TIMEOUT_MS,
|
|
262
667
|
since = null,
|
|
668
|
+
selfCleanPaths = [],
|
|
263
669
|
} = {}) {
|
|
264
670
|
const timeout =
|
|
265
671
|
typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs >= 1000
|
|
266
672
|
? Math.trunc(timeoutMs)
|
|
267
673
|
: DEFAULT_ROLLBACK_TIMEOUT_MS;
|
|
674
|
+
const copyTimeout =
|
|
675
|
+
typeof rollbackTimeoutMs === 'number' && Number.isFinite(rollbackTimeoutMs) && rollbackTimeoutMs >= 1000
|
|
676
|
+
? Math.trunc(rollbackTimeoutMs)
|
|
677
|
+
: DEFAULT_ROLLBACK_COPY_TIMEOUT_MS;
|
|
678
|
+
// `Wait-Job -Timeout` counts SECONDS.
|
|
679
|
+
const copyTimeoutSeconds = Math.max(1, Math.ceil(copyTimeout / 1000));
|
|
268
680
|
// Only a crash log written AFTER the restart is evidence about it: an older one
|
|
269
681
|
// would blame last week's failure for this one.
|
|
270
682
|
const sinceText = typeof since === 'string' && since.trim() !== ''
|
|
271
683
|
? since.trim()
|
|
272
684
|
: (Number.isFinite(Number(since)) ? new Date(Number(since)).toISOString() : '');
|
|
273
685
|
const quote = (value) => "'" + psSingle(typeof value === 'string' ? value : '') + "'";
|
|
686
|
+
const expected = typeof expectedBootId === 'string' && expectedBootId !== '' ? expectedBootId : '';
|
|
687
|
+
const cleanPaths = (Array.isArray(selfCleanPaths) ? selfCleanPaths : [])
|
|
688
|
+
.filter((entry) => typeof entry === 'string' && entry !== '')
|
|
689
|
+
.map((entry) => quote(entry));
|
|
274
690
|
const lines = [
|
|
275
691
|
'# dsh-autorestart: wait for the relaunched host; roll the pre-restart backup back when it never answers.',
|
|
276
692
|
"$ErrorActionPreference = 'SilentlyContinue'",
|
|
277
693
|
'$health = ' + quote(nonEmptyString(healthUrl) ?? ''),
|
|
694
|
+
'$healthPath = ' + quote(nonEmptyString(healthPath) ?? '/__dsh_autorestart/health'),
|
|
695
|
+
'$portFile = ' + quote(nonEmptyString(portFile) ?? ''),
|
|
696
|
+
'$expected = ' + quote(expected),
|
|
697
|
+
'$outcome = ' + quote(nonEmptyString(outcomeFile) ?? ''),
|
|
278
698
|
'$backup = ' + quote(nonEmptyString(backupDir) ?? ''),
|
|
279
699
|
'$profile = ' + quote(nonEmptyString(profileDir) ?? ''),
|
|
280
700
|
'$report = ' + quote(nonEmptyString(reportPath) ?? ''),
|
|
281
|
-
'$exe = ' + quote(nonEmptyString(appExePath) ?? ''),
|
|
282
701
|
'$deadline = (Get-Date).AddMilliseconds(' + timeout + ')',
|
|
283
702
|
'$healthy = $false',
|
|
703
|
+
'$decided = $false',
|
|
704
|
+
'$newPort = 0',
|
|
284
705
|
'while ((Get-Date) -lt $deadline) {',
|
|
285
|
-
'
|
|
706
|
+
' # The plugin may have decided on its own (the restart command failed): stand down.',
|
|
707
|
+
" if ($outcome -ne '' -and (Test-Path -LiteralPath $outcome)) {",
|
|
708
|
+
' $marker = $null',
|
|
709
|
+
' try { $marker = Get-Content -LiteralPath $outcome -Raw | ConvertFrom-Json } catch { $marker = $null }',
|
|
710
|
+
' if ($marker -and $marker.decided -eq $true) { $decided = $true; break }',
|
|
711
|
+
' }',
|
|
712
|
+
' # The NEW instance publishes its own port; the port of the old one is worthless.',
|
|
713
|
+
" if ($portFile -ne '' -and (Test-Path -LiteralPath $portFile)) {",
|
|
714
|
+
' $record = $null',
|
|
715
|
+
' try { $record = Get-Content -LiteralPath $portFile -Raw | ConvertFrom-Json } catch { $record = $null }',
|
|
716
|
+
' if ($record -and $record.bootId -and $record.bootId -ne $expected -and $record.port) {',
|
|
717
|
+
" $url = 'http://127.0.0.1:' + $record.port + $healthPath",
|
|
718
|
+
' try { $h = Invoke-RestMethod -Uri $url -TimeoutSec 5; if ($h.ok -eq $true) { $healthy = $true; $newPort = $record.port; break } } catch { }',
|
|
719
|
+
' }',
|
|
720
|
+
' } elseif ($health -ne \'\') {',
|
|
721
|
+
' # No instance record at all (a Host that does not publish one): the old URL.',
|
|
722
|
+
' try { $h = Invoke-RestMethod -Uri $health -TimeoutSec 5; if ($h.ok -eq $true) { $healthy = $true; break } } catch { }',
|
|
723
|
+
' }',
|
|
286
724
|
' Start-Sleep -Seconds 2',
|
|
287
725
|
'}',
|
|
288
|
-
'if ($healthy) { exit 0 }',
|
|
726
|
+
'if ($decided -or $healthy) { exit 0 }',
|
|
289
727
|
'$crashDir = Join-Path $env:APPDATA \'@deepseek-ai\\dsh-desktop\\logs\'',
|
|
290
728
|
'$since = ' + quote(sinceText),
|
|
291
729
|
'$crash = $null',
|
|
@@ -293,29 +731,218 @@ export function buildWatchScript({
|
|
|
293
731
|
'if (-not $crash) { $crash = Get-ChildItem $crashDir -Filter \'crash-*.log\' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 }',
|
|
294
732
|
"$tail = ''",
|
|
295
733
|
'if ($crash) { $tail = ((Get-Content $crash.FullName -Tail 60) -join [char]10) }',
|
|
296
|
-
'
|
|
297
|
-
'
|
|
298
|
-
'
|
|
299
|
-
'
|
|
300
|
-
'
|
|
734
|
+
'# THE LOCK: the plugin may be restoring the snapshot at the same time (it',
|
|
735
|
+
'# saw that the restart failed). Exactly ONE of them restores the files.',
|
|
736
|
+
'$lock = ' + quote(nonEmptyString(lockFile) ?? ''),
|
|
737
|
+
'$locked = $false',
|
|
738
|
+
'if ($lock -ne \'\') {',
|
|
739
|
+
' # A lock that is older than 10 minutes belongs to a process that died',
|
|
740
|
+
' # in the middle of its restore: it must not block the rollback for ever.',
|
|
741
|
+
' if (Test-Path -LiteralPath $lock) {',
|
|
742
|
+
' $age = (Get-Date) - (Get-Item -LiteralPath $lock).LastWriteTime',
|
|
743
|
+
' if ($age.TotalMinutes -gt 10) { Remove-Item -LiteralPath $lock -Force -ErrorAction SilentlyContinue }',
|
|
744
|
+
' }',
|
|
745
|
+
' try { $handle = [System.IO.File]::Open($lock, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None); $handle.Close(); $locked = $true } catch { $locked = $false }',
|
|
301
746
|
'}',
|
|
302
|
-
'$
|
|
303
|
-
'
|
|
304
|
-
'
|
|
305
|
-
' $
|
|
747
|
+
'$rolledBack = $false',
|
|
748
|
+
'$rollbackError = $null',
|
|
749
|
+
'if (-not $locked) {',
|
|
750
|
+
' $rollbackError = \'another rollback is already running\'',
|
|
751
|
+
'} else {',
|
|
752
|
+
' $configBackup = Join-Path $backup \'config\'',
|
|
753
|
+
' if (Test-Path $configBackup) {',
|
|
754
|
+
' $job = Start-Job -ScriptBlock { param($src, $dst) & robocopy $src $dst /E /NFL /NDL /NJH /NJS /NP | Out-Null } -ArgumentList $configBackup, $profile',
|
|
755
|
+
' if (Wait-Job $job -Timeout ' + copyTimeoutSeconds + ') { Receive-Job $job | Out-Null; $rolledBack = $true } else { Stop-Job $job; $rollbackError = \'the configuration restore did not finish within ' + copyTimeout + ' ms\' }',
|
|
756
|
+
' Remove-Job $job -Force',
|
|
757
|
+
' }',
|
|
758
|
+
' $pluginsBackup = Join-Path $backup \'node_modules\'',
|
|
759
|
+
' if (Test-Path $pluginsBackup) {',
|
|
760
|
+
' $job = Start-Job -ScriptBlock { param($src, $dst) & robocopy $src $dst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null } -ArgumentList $pluginsBackup, (Join-Path $profile \'node_modules\')',
|
|
761
|
+
' if (Wait-Job $job -Timeout ' + copyTimeoutSeconds + ') { Receive-Job $job | Out-Null; $rolledBack = $true } else { Stop-Job $job; $rollbackError = \'the plugin tree restore did not finish within ' + copyTimeout + ' ms\' }',
|
|
762
|
+
' Remove-Job $job -Force',
|
|
763
|
+
' }',
|
|
306
764
|
'}',
|
|
307
765
|
'$crashName = $null',
|
|
308
766
|
'if ($crash) { $crashName = $crash.Name }',
|
|
309
|
-
'
|
|
767
|
+
'# `appStarted` is always false: the rollback branch restores the files and',
|
|
768
|
+
'# writes the report, it never launches a SECOND copy of the host.',
|
|
769
|
+
'$payload = [ordered]@{ ok = $false; at = (Get-Date).ToString(\'o\'); waitedMs = ' + timeout + '; rolledBack = $rolledBack; rollbackError = $rollbackError; backupDir = $backup; newPort = $newPort; appStarted = $false; crashLog = $crashName; crashTail = $tail }',
|
|
310
770
|
'$json = $payload | ConvertTo-Json -Depth 4',
|
|
311
771
|
'[System.IO.File]::WriteAllText($report, $json, (New-Object System.Text.UTF8Encoding($false)))',
|
|
312
|
-
'
|
|
313
|
-
'
|
|
772
|
+
'# The watcher cleans up after itself (and releases the lock it holds).',
|
|
773
|
+
'foreach ($p in @(' + cleanPaths.join(', ') + ')) {',
|
|
774
|
+
' if ($p -and (Test-Path -LiteralPath $p)) { Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue }',
|
|
775
|
+
'}',
|
|
776
|
+
'# The lock is released by the party that HOLDS it — the one that stood',
|
|
777
|
+
'# down must not take it away from the one that is restoring.',
|
|
778
|
+
'if ($locked -and $lock -ne \'\') { Remove-Item -LiteralPath $lock -Force -ErrorAction SilentlyContinue }',
|
|
779
|
+
'exit 1',
|
|
314
780
|
'',
|
|
315
781
|
];
|
|
316
782
|
return lines.join('\r\n');
|
|
317
783
|
}
|
|
318
784
|
|
|
785
|
+
/**
|
|
786
|
+
* The command line that runs the rollback watcher as its own hidden process.
|
|
787
|
+
*
|
|
788
|
+
* @param {string} scriptPath
|
|
789
|
+
* @param {{ executable?: string }} [options]
|
|
790
|
+
* @returns {string}
|
|
791
|
+
*/
|
|
792
|
+
export function buildWatcherCommandLine(scriptPath, { executable = 'powershell.exe' } = {}) {
|
|
793
|
+
const file = nonEmptyString(scriptPath);
|
|
794
|
+
if (file === null) return '';
|
|
795
|
+
return executable
|
|
796
|
+
+ ' -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "'
|
|
797
|
+
+ file
|
|
798
|
+
+ '"';
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Write a helper script atomically: a temporary file + a rename, so a
|
|
803
|
+
* half-written script can never be executed.
|
|
804
|
+
*
|
|
805
|
+
* @param {string} file
|
|
806
|
+
* @param {string} text
|
|
807
|
+
* @returns {{ ok: boolean, error: string|null }}
|
|
808
|
+
*/
|
|
809
|
+
export function writeTextFileAtomic(file, text) {
|
|
810
|
+
const target = nonEmptyString(file);
|
|
811
|
+
if (target === null) return { ok: false, error: 'writeTextFileAtomic requires a path' };
|
|
812
|
+
try {
|
|
813
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
814
|
+
const temporary = target + '.tmp';
|
|
815
|
+
writeFileSync(temporary, String(text), { encoding: 'utf8' });
|
|
816
|
+
renameSync(temporary, target);
|
|
817
|
+
return { ok: true, error: null };
|
|
818
|
+
} catch (error) {
|
|
819
|
+
return { ok: false, error: 'could not write ' + target + ': ' + errorText(error) };
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Start the rollback watcher as a process that survives the death of the host.
|
|
825
|
+
*
|
|
826
|
+
* The watcher is the only thing that can confirm the new instance when the
|
|
827
|
+
* restart command kills the host itself — which is exactly what a hosted
|
|
828
|
+
* restart does. It is therefore started OUTSIDE the process tree of the host:
|
|
829
|
+
*
|
|
830
|
+
* - on Windows through WMI (the same mechanism the detached restart uses),
|
|
831
|
+
* with a one-shot scheduled task as the fallback;
|
|
832
|
+
* - everywhere else through a detached child (POSIX `setsid`), which is in
|
|
833
|
+
* its own session and therefore immune to a kill of the host's process
|
|
834
|
+
* group.
|
|
835
|
+
*
|
|
836
|
+
* @param {{
|
|
837
|
+
* watchScriptPath?: string|null,
|
|
838
|
+
* watchCommand?: string|null,
|
|
839
|
+
* taskName?: string,
|
|
840
|
+
* timeoutMs?: number,
|
|
841
|
+
* dryRun?: boolean,
|
|
842
|
+
* platform?: string,
|
|
843
|
+
* spawnFn?: Function,
|
|
844
|
+
* execFileSyncFn?: Function,
|
|
845
|
+
* }} [options]
|
|
846
|
+
* @returns {{
|
|
847
|
+
* ok: boolean,
|
|
848
|
+
* method: 'wmi'|'schtasks'|'spawn'|'none',
|
|
849
|
+
* command: string,
|
|
850
|
+
* error: string|null,
|
|
851
|
+
* dryRun: boolean,
|
|
852
|
+
* }}
|
|
853
|
+
*/
|
|
854
|
+
export function launchDetachedWatcher({
|
|
855
|
+
watchScriptPath = null,
|
|
856
|
+
watchCommand = null,
|
|
857
|
+
taskName = DEFAULT_WATCH_TASK_NAME,
|
|
858
|
+
timeoutMs = 20000,
|
|
859
|
+
dryRun = false,
|
|
860
|
+
platform = process.platform,
|
|
861
|
+
spawnFn = spawn,
|
|
862
|
+
execFileSyncFn = execFileSync,
|
|
863
|
+
} = {}) {
|
|
864
|
+
const command = nonEmptyString(watchCommand) ?? buildWatcherCommandLine(watchScriptPath);
|
|
865
|
+
const task = nonEmptyString(taskName) ?? DEFAULT_WATCH_TASK_NAME;
|
|
866
|
+
const base = {
|
|
867
|
+
ok: false,
|
|
868
|
+
method: /** @type {'wmi'|'schtasks'|'spawn'|'none'} */ ('none'),
|
|
869
|
+
command,
|
|
870
|
+
error: null,
|
|
871
|
+
dryRun: dryRun === true,
|
|
872
|
+
};
|
|
873
|
+
if (command === '') {
|
|
874
|
+
return { ...base, error: 'launchDetachedWatcher requires a watcher command' };
|
|
875
|
+
}
|
|
876
|
+
if (dryRun === true) {
|
|
877
|
+
return { ...base, ok: true, method: 'none' };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (platform === 'win32') {
|
|
881
|
+
const wmiCommand = buildWmiRestartCommand(command);
|
|
882
|
+
const schtasksCreate = ['/Create', '/SC', 'ONCE', '/ST', '23:59', '/TN', task, '/TR', command, '/F'];
|
|
883
|
+
const schtasksRun = ['/Run', '/TN', task];
|
|
884
|
+
const execOptions = { windowsHide: true, timeout: timeoutMs, stdio: 'ignore' };
|
|
885
|
+
let lastError = null;
|
|
886
|
+
try {
|
|
887
|
+
const stdout = execFileSyncFn(wmiCommand.file, wmiCommand.args, {
|
|
888
|
+
...execOptions,
|
|
889
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
890
|
+
encoding: 'utf8',
|
|
891
|
+
});
|
|
892
|
+
const match = /"ReturnValue"\s*:\s*(\d+)/.exec(String(stdout ?? ''));
|
|
893
|
+
if (match !== null && Number(match[1]) !== 0) {
|
|
894
|
+
throw new Error(`WMI Win32_Process.Create returned ReturnValue=${match[1]}`);
|
|
895
|
+
}
|
|
896
|
+
return { ...base, ok: true, method: 'wmi' };
|
|
897
|
+
} catch (error) {
|
|
898
|
+
lastError = errorText(error);
|
|
899
|
+
}
|
|
900
|
+
try {
|
|
901
|
+
execFileSyncFn('schtasks', schtasksCreate, execOptions);
|
|
902
|
+
execFileSyncFn('schtasks', schtasksRun, execOptions);
|
|
903
|
+
return { ...base, ok: true, method: 'schtasks', error: lastError };
|
|
904
|
+
} catch (error) {
|
|
905
|
+
lastError = `${lastError ? `${lastError}; ` : ''}${errorText(error)}`;
|
|
906
|
+
}
|
|
907
|
+
// The last resort: a detached child. On Windows it shares the job object
|
|
908
|
+
// of the host, so it may die with it — but a watcher that dies with the
|
|
909
|
+
// host is better than no watcher at all.
|
|
910
|
+
try {
|
|
911
|
+
const child = spawnFn(command, { shell: true, detached: true, windowsHide: true, stdio: 'ignore' });
|
|
912
|
+
if (typeof child.on === 'function') child.on('error', () => {});
|
|
913
|
+
try {
|
|
914
|
+
child.unref();
|
|
915
|
+
} catch {
|
|
916
|
+
/* nothing else to do */
|
|
917
|
+
}
|
|
918
|
+
if (typeof child.pid !== 'number') {
|
|
919
|
+
return { ...base, method: 'spawn', error: lastError || 'the watcher could not be started' };
|
|
920
|
+
}
|
|
921
|
+
return { ...base, ok: true, method: 'spawn', error: lastError };
|
|
922
|
+
} catch (error) {
|
|
923
|
+
return { ...base, method: 'spawn', error: `${lastError ? `${lastError}; ` : ''}${errorText(error)}` };
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// POSIX: a detached child is its own session leader (setsid), so it survives
|
|
928
|
+
// the death of the host — even when the host is killed as a process group.
|
|
929
|
+
try {
|
|
930
|
+
const child = spawnFn(command, { shell: true, detached: true, stdio: 'ignore' });
|
|
931
|
+
if (typeof child.on === 'function') child.on('error', () => {});
|
|
932
|
+
try {
|
|
933
|
+
child.unref();
|
|
934
|
+
} catch {
|
|
935
|
+
/* nothing else to do */
|
|
936
|
+
}
|
|
937
|
+
if (typeof child.pid !== 'number') {
|
|
938
|
+
return { ...base, method: 'spawn', error: 'the watcher could not be started' };
|
|
939
|
+
}
|
|
940
|
+
return { ...base, ok: true, method: 'spawn' };
|
|
941
|
+
} catch (error) {
|
|
942
|
+
return { ...base, method: 'spawn', error: errorText(error) };
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
319
946
|
/**
|
|
320
947
|
* Execute the detached restart. Primary method: WMI process creation. Fallback:
|
|
321
948
|
* a one-shot scheduled task. Never throws.
|
|
@@ -424,26 +1051,18 @@ export function scheduleDetachedRestart({
|
|
|
424
1051
|
if (watchFile !== null && watchText !== null) {
|
|
425
1052
|
// The watcher must exist before the .cmd runs it: same atomic write as the
|
|
426
1053
|
// gate script, so a half-written .ps1 can never be executed.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
writeFileSync(temporary, String(watchText), { encoding: 'utf8' });
|
|
431
|
-
renameSync(temporary, watchFile);
|
|
432
|
-
} catch (error) {
|
|
433
|
-
return { ...base, error: 'could not write the rollback watcher: ' + errorText(error) };
|
|
1054
|
+
const written = writeTextFileAtomic(watchFile, watchText);
|
|
1055
|
+
if (!written.ok) {
|
|
1056
|
+
return { ...base, error: 'could not write the rollback watcher: ' + written.error };
|
|
434
1057
|
}
|
|
435
1058
|
}
|
|
436
1059
|
|
|
437
1060
|
if (gated) {
|
|
438
1061
|
// The helper must exist before the scheduler runs it: write it atomically
|
|
439
1062
|
// (tmp file + rename) so a half-written .cmd can never be executed.
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
writeFileSync(temporary, String(script), { encoding: 'utf8' });
|
|
444
|
-
renameSync(temporary, scriptFile);
|
|
445
|
-
} catch (error) {
|
|
446
|
-
return { ...base, error: 'could not write the gate script: ' + errorText(error) };
|
|
1063
|
+
const written = writeTextFileAtomic(scriptFile, script);
|
|
1064
|
+
if (!written.ok) {
|
|
1065
|
+
return { ...base, error: 'could not write the gate script: ' + written.error };
|
|
447
1066
|
}
|
|
448
1067
|
}
|
|
449
1068
|
|
|
@@ -496,29 +1115,177 @@ export function scheduleDetachedRestart({
|
|
|
496
1115
|
* Run a command string detached, or (with `dryRun`) just report it. Tests use
|
|
497
1116
|
* `dryRun` exclusively so they never kill or relaunch anything.
|
|
498
1117
|
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
1118
|
+
* THE SUCCESS OF THE RESTART IS NOT "THE COMMAND STARTED".
|
|
1119
|
+
*
|
|
1120
|
+
* The old implementation spawned the command with `stdio: 'ignore'`, threw
|
|
1121
|
+
* the `error` event away and answered `ok: true` as soon as the spawn
|
|
1122
|
+
* succeeded. A command that does not exist (`command not found`), a command
|
|
1123
|
+
* that exits with a non-zero code and a command that dies immediately were
|
|
1124
|
+
* all reported as a successful restart. The host was then "restarted" into
|
|
1125
|
+
* nothing, and nobody was told.
|
|
1126
|
+
*
|
|
1127
|
+
* This implementation answers with what actually happened:
|
|
1128
|
+
*
|
|
1129
|
+
* - `exitCode` — the code the command ended with;
|
|
1130
|
+
* - `timedOut` — the command was still running when the window closed
|
|
1131
|
+
* (a command that starts the host in the foreground never returns, so this
|
|
1132
|
+
* is NOT a failure — but it is not a success either);
|
|
1133
|
+
* - `error` — the reason a failure happened (the command could not be
|
|
1134
|
+
* started at all, or it exited with a non-zero code);
|
|
1135
|
+
* - `output` — what the command printed (the tail of it), so the reason of a
|
|
1136
|
+
* failure is readable in the log.
|
|
1137
|
+
*
|
|
1138
|
+
* @param {{
|
|
1139
|
+
* command?: string,
|
|
1140
|
+
* dryRun?: boolean,
|
|
1141
|
+
* timeoutMs?: number,
|
|
1142
|
+
* spawnFn?: Function,
|
|
1143
|
+
* }} [options]
|
|
1144
|
+
* @returns {Promise<{
|
|
1145
|
+
* ok: boolean,
|
|
1146
|
+
* command: string|null,
|
|
1147
|
+
* dryRun: boolean,
|
|
1148
|
+
* pid: number|null,
|
|
1149
|
+
* exitCode: number|null,
|
|
1150
|
+
* signal: string|null,
|
|
1151
|
+
* timedOut: boolean,
|
|
1152
|
+
* error: string|null,
|
|
1153
|
+
* output: string,
|
|
1154
|
+
* }>}
|
|
501
1155
|
*/
|
|
502
|
-
export function runCommandNow({
|
|
1156
|
+
export function runCommandNow({
|
|
1157
|
+
command,
|
|
1158
|
+
dryRun = false,
|
|
1159
|
+
timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS,
|
|
1160
|
+
spawnFn = spawn,
|
|
1161
|
+
} = {}) {
|
|
503
1162
|
const text = typeof command === 'string' ? command : '';
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
1163
|
+
if (!text.trim()) {
|
|
1164
|
+
return Promise.resolve({
|
|
1165
|
+
ok: false,
|
|
1166
|
+
command: null,
|
|
1167
|
+
dryRun: dryRun === true,
|
|
1168
|
+
pid: null,
|
|
1169
|
+
exitCode: null,
|
|
1170
|
+
signal: null,
|
|
1171
|
+
timedOut: false,
|
|
1172
|
+
error: 'runCommandNow requires a command',
|
|
1173
|
+
output: '',
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
if (dryRun === true) {
|
|
1177
|
+
return Promise.resolve({
|
|
1178
|
+
ok: true,
|
|
1179
|
+
command: text,
|
|
1180
|
+
dryRun: true,
|
|
1181
|
+
pid: null,
|
|
1182
|
+
exitCode: null,
|
|
1183
|
+
signal: null,
|
|
1184
|
+
timedOut: false,
|
|
1185
|
+
error: null,
|
|
1186
|
+
output: '',
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
const budget = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
|
|
1190
|
+
? Math.trunc(Number(timeoutMs))
|
|
1191
|
+
: DEFAULT_COMMAND_TIMEOUT_MS;
|
|
1192
|
+
|
|
1193
|
+
return new Promise((resolve) => {
|
|
1194
|
+
let settled = false;
|
|
1195
|
+
let output = '';
|
|
1196
|
+
let timer = null;
|
|
1197
|
+
const finish = (result) => {
|
|
1198
|
+
if (settled) return;
|
|
1199
|
+
settled = true;
|
|
1200
|
+
if (timer !== null) {
|
|
1201
|
+
try {
|
|
1202
|
+
clearTimeout(timer);
|
|
1203
|
+
} catch {
|
|
1204
|
+
/* nothing else to do */
|
|
1205
|
+
}
|
|
1206
|
+
timer = null;
|
|
1207
|
+
}
|
|
1208
|
+
resolve({
|
|
1209
|
+
ok: false,
|
|
1210
|
+
command: text,
|
|
1211
|
+
dryRun: false,
|
|
1212
|
+
pid: null,
|
|
1213
|
+
exitCode: null,
|
|
1214
|
+
signal: null,
|
|
1215
|
+
timedOut: false,
|
|
1216
|
+
error: 'the restart command did not run',
|
|
1217
|
+
output,
|
|
1218
|
+
...result,
|
|
1219
|
+
});
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
let child;
|
|
1223
|
+
try {
|
|
1224
|
+
child = spawnFn(text, {
|
|
1225
|
+
shell: true,
|
|
1226
|
+
detached: true,
|
|
1227
|
+
windowsHide: true,
|
|
1228
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1229
|
+
});
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
finish({ error: 'the restart command could not be started: ' + errorText(error) });
|
|
1232
|
+
return;
|
|
507
1233
|
}
|
|
508
|
-
if (
|
|
509
|
-
|
|
1234
|
+
if (!child) {
|
|
1235
|
+
finish({ error: 'the restart command could not be started' });
|
|
1236
|
+
return;
|
|
510
1237
|
}
|
|
511
|
-
|
|
512
|
-
|
|
1238
|
+
|
|
1239
|
+
const collect = (chunk) => {
|
|
1240
|
+
output = (output + String(chunk ?? '')).slice(-4096);
|
|
1241
|
+
};
|
|
1242
|
+
if (child.stdout && typeof child.stdout.on === 'function') child.stdout.on('data', collect);
|
|
1243
|
+
if (child.stderr && typeof child.stderr.on === 'function') child.stderr.on('data', collect);
|
|
1244
|
+
|
|
1245
|
+
// A spawn that fails (the command does not exist) is reported — never
|
|
1246
|
+
// swallowed. This is the defect the old implementation hid.
|
|
1247
|
+
if (typeof child.on === 'function') {
|
|
1248
|
+
child.on('error', (error) => {
|
|
1249
|
+
finish({ error: 'the restart command could not be started: ' + errorText(error) });
|
|
1250
|
+
});
|
|
1251
|
+
child.on('exit', (code, signal) => {
|
|
1252
|
+
const exitCode = Number.isFinite(Number(code)) ? Number(code) : null;
|
|
1253
|
+
if (exitCode === 0) {
|
|
1254
|
+
finish({ ok: true, pid: child.pid ?? null, exitCode: 0, signal: signal ?? null, error: null });
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const tail = output.trim();
|
|
1258
|
+
finish({
|
|
1259
|
+
ok: false,
|
|
1260
|
+
pid: child.pid ?? null,
|
|
1261
|
+
exitCode,
|
|
1262
|
+
signal: signal ?? null,
|
|
1263
|
+
error: 'the restart command exited with code ' + String(exitCode)
|
|
1264
|
+
+ (tail === '' ? '' : ': ' + tail),
|
|
1265
|
+
});
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// The window: the command is given this long to finish. A command that
|
|
1270
|
+
// outlives it is NOT killed (it may be the host itself, running in the
|
|
1271
|
+
// foreground) — it is reported as "still running", and the caller decides.
|
|
1272
|
+
timer = setTimeout(() => {
|
|
1273
|
+
finish({
|
|
1274
|
+
ok: true,
|
|
1275
|
+
pid: child.pid ?? null,
|
|
1276
|
+
exitCode: null,
|
|
1277
|
+
signal: null,
|
|
1278
|
+
timedOut: true,
|
|
1279
|
+
error: null,
|
|
1280
|
+
});
|
|
1281
|
+
}, budget);
|
|
1282
|
+
|
|
513
1283
|
try {
|
|
514
1284
|
child.unref();
|
|
515
1285
|
} catch {
|
|
516
1286
|
/* nothing else to do */
|
|
517
1287
|
}
|
|
518
|
-
|
|
519
|
-
} catch (error) {
|
|
520
|
-
return { ok: false, command: text || null, dryRun: dryRun === true, error: errorText(error) };
|
|
521
|
-
}
|
|
1288
|
+
});
|
|
522
1289
|
}
|
|
523
1290
|
|
|
524
1291
|
/**
|