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/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 (`...\DeepSeek Harness\DeepSeek Harness.exe`, no CLI args
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
- * @param {{ env?: object, execPath?: string, pid?: number, ppid?: number, platform?: string }} [overrides]
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 isDesktopApp = /deepseek[ -]harness/i.test(exe);
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
- appExePath: isDesktopApp ? exe : null,
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
- * It polls the health route for `timeoutMs`; a healthy answer ends the watch. When
237
- * the host does not come back, the snapshot taken before the restart is copied back
238
- * over the profile (configuration files, and the plugin tree when the backup holds
239
- * one), a report is written next to the checkpoint, and the host is launched again
240
- * with the restored configuration.
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 plugin
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
- ' try { $r = Invoke-RestMethod -Uri $health -TimeoutSec 5; if ($r.ok -eq $true) { $healthy = $true; break } } catch { }',
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
- '$rolledBack = $false',
297
- '$configBackup = Join-Path $backup \'config\'',
298
- 'if (Test-Path $configBackup) {',
299
- ' robocopy $configBackup $profile /E /NFL /NDL /NJH /NJS /NP | Out-Null',
300
- ' $rolledBack = $true',
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
- '$pluginsBackup = Join-Path $backup \'node_modules\'',
303
- 'if (Test-Path $pluginsBackup) {',
304
- ' robocopy $pluginsBackup (Join-Path $profile \'node_modules\') /MIR /NFL /NDL /NJH /NJS /NP | Out-Null',
305
- ' $rolledBack = $true',
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
- '$payload = [ordered]@{ ok = $false; at = (Get-Date).ToString(\'o\'); waitedMs = ' + timeout + '; rolledBack = $rolledBack; backupDir = $backup; crashLog = $crashName; crashTail = $tail }',
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
- 'Start-Process -FilePath $exe',
313
- 'exit 0',
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
- try {
428
- mkdirSync(path.dirname(watchFile), { recursive: true });
429
- const temporary = watchFile + '.tmp';
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
- try {
441
- mkdirSync(path.dirname(scriptFile), { recursive: true });
442
- const temporary = scriptFile + '.tmp';
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
- * @param {{ command?: string, dryRun?: boolean }} [options]
500
- * @returns {{ ok: boolean, command: string|null, dryRun: boolean, pid?: number|null, error?: string }}
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({ command, dryRun = false } = {}) {
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
- try {
505
- if (!text.trim()) {
506
- return { ok: false, command: null, dryRun: dryRun === true, error: 'runCommandNow requires a command' };
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 (dryRun === true) {
509
- return { ok: true, command: text, dryRun: true, pid: null };
1234
+ if (!child) {
1235
+ finish({ error: 'the restart command could not be started' });
1236
+ return;
510
1237
  }
511
- const child = spawn(text, { shell: true, detached: true, windowsHide: true, stdio: 'ignore' });
512
- child.on('error', () => {});
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
- return { ok: true, command: text, dryRun: false, pid: child.pid ?? null };
519
- } catch (error) {
520
- return { ok: false, command: text || null, dryRun: dryRun === true, error: errorText(error) };
521
- }
1288
+ });
522
1289
  }
523
1290
 
524
1291
  /**