dsh-autorestart 0.6.2 → 0.8.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.
Files changed (47) hide show
  1. package/README.md +460 -12
  2. package/assets/screenshot-1.png +0 -0
  3. package/assets/screenshot-2.png +0 -0
  4. package/assets/screenshot-3.png +0 -0
  5. package/assets/screenshot-4.png +0 -0
  6. package/assets/screenshot-5.png +0 -0
  7. package/client.js +6329 -3863
  8. package/cordis.patch.yml +44 -1
  9. package/docs/README.zh.md +297 -9
  10. package/docs/platform-matrix.md +92 -0
  11. package/docs/rescue-drill.md +606 -0
  12. package/index.js +2867 -199
  13. package/lib/access.js +212 -0
  14. package/lib/autostart-advice.js +198 -0
  15. package/lib/backup.js +131 -4
  16. package/lib/blockers.js +228 -0
  17. package/lib/checkpoint.js +147 -0
  18. package/lib/diagnostics.js +188 -0
  19. package/lib/health.js +264 -0
  20. package/lib/host-kind.js +145 -0
  21. package/lib/node-runtime.js +144 -0
  22. package/lib/rescue-paths.js +253 -0
  23. package/lib/rescue.js +596 -0
  24. package/lib/restart-verdict.js +327 -0
  25. package/lib/restart.js +823 -56
  26. package/lib/scheduler-capability.js +300 -0
  27. package/lib/sleep.js +46 -49
  28. package/lib/startup-mode.js +491 -0
  29. package/lib/supervisor-config.js +735 -0
  30. package/lib/supervisor-install.js +557 -0
  31. package/lib/supervisor.js +1276 -0
  32. package/lib/watchdog-arbiter.js +117 -0
  33. package/lib/watchdog-schedule.js +310 -0
  34. package/lib/watchdog-tier.js +182 -0
  35. package/lib/watchdog.js +479 -0
  36. package/locale/en.json +274 -64
  37. package/locale/zh.json +274 -64
  38. package/package.json +7 -2
  39. package/screenshots.json +6 -4
  40. package/scripts/README.md +65 -0
  41. package/scripts/drill-rescue.ps1 +508 -0
  42. package/scripts/emergency-disable.cmd +27 -0
  43. package/scripts/emergency-toggle.ps1 +373 -0
  44. package/scripts/restore-profile.cmd +44 -0
  45. package/scripts/restore-profile.ps1 +608 -0
  46. package/scripts/run-tests.ps1 +46 -0
  47. package/supervisor/supervisor.js +884 -0
@@ -0,0 +1,491 @@
1
+ /**
2
+ * dsh-autorestart — HOW THIS HOST WAS STARTED.
3
+ *
4
+ * A hosted web host can be started in a dozen ways, and the plugin has to
5
+ * behave correctly in EVERY one of them:
6
+ *
7
+ * | # | how it was started | what the plugin does |
8
+ * |---|---------------------------------------------|---------------------------------|
9
+ * | 1 | a plain terminal (`dsh web`) | own supervisor MAY be installed |
10
+ * | 2 | `nohup … &` / `setsid` (detached) | same, but the replay is detached|
11
+ * | 3 | a supervisor script (`supervise-*.sh`) | external → graceful exit only |
12
+ * | 4 | a systemd unit | external |
13
+ * | 5 | pm2 / forever | external |
14
+ * | 6 | docker / compose / kubernetes | no "at login"; compose fragment |
15
+ * | 7 | a Windows scheduled task / NSSM service | external |
16
+ * | 8 | WSL, started by a Windows process | unknown (the parent is invisible)|
17
+ * | 9 | `npx` / `pnpm dlx` | usable if the argv can be restored|
18
+ * |10 | another DSH plugin spawned it | external/unknown → never a second|
19
+ * |11 | tmux / screen / an SSH session | replay, but detached from the session |
20
+ *
21
+ * THE TWO RULES THAT MATTER MORE THAN THE TABLE:
22
+ *
23
+ * 1. **NEVER START A SECOND HOST.** Only a `bare` host may get a
24
+ * self-replay command, and only a `bare` host may get the supervisor of
25
+ * this plugin. Everything else is somebody else's business.
26
+ * 2. **WHEN IN DOUBT, BE CONSERVATIVE.** An `unknown` host gets
27
+ * `selfReplay: 'unknown'` and NO supervisor: the plugin would rather
28
+ * say "I cannot tell" than start a second host next to the first.
29
+ *
30
+ * The classifier is a PURE function: the process chain, the environment and
31
+ * the container markers are injected, so every row of the table can be
32
+ * tested without a machine.
33
+ *
34
+ * ESM only, Node builtins only.
35
+ */
36
+
37
+ /** The modes the classifier can answer. */
38
+ export const STARTUP_MODES = Object.freeze({
39
+ /** The desktop app: it relaunches itself. */
40
+ DESKTOP: 'desktop',
41
+ /** The supervisor of THIS plugin started the instance. */
42
+ SELF_SUPERVISED: 'self-supervised',
43
+ /** Somebody else (systemd, pm2, a script, a service, another plugin). */
44
+ EXTERNAL: 'external',
45
+ /** Nobody manages it: a terminal, a detached process, a session. */
46
+ BARE: 'bare',
47
+ /** A container: the orchestrator decides, not the machine. */
48
+ CONTAINER: 'container',
49
+ /** It could not be established. */
50
+ UNKNOWN: 'unknown',
51
+ })
52
+
53
+ /** The kinds of a startup mode (the finer answer). */
54
+ export const STARTUP_KINDS = Object.freeze({
55
+ DESKTOP_APP: 'desktop-app',
56
+ OWN_SUPERVISOR: 'dsh-autorestart-supervisor',
57
+ SYSTEMD: 'systemd',
58
+ PM2: 'pm2',
59
+ FOREVER: 'forever',
60
+ SCRIPT: 'script',
61
+ WINDOWS_TASK: 'windows-task',
62
+ WINDOWS_SERVICE: 'windows-service',
63
+ DSH_PLUGIN: 'dsh-plugin',
64
+ DOCKER: 'docker',
65
+ KUBERNETES: 'kubernetes',
66
+ TERMINAL: 'terminal',
67
+ DETACHED: 'detached',
68
+ SESSION: 'session',
69
+ PACKAGE_RUNNER: 'package-runner',
70
+ WSL_FROM_WINDOWS: 'wsl-from-windows',
71
+ OPAQUE_WRAPPER: 'opaque-wrapper',
72
+ UNREADABLE_CHAIN: 'unreadable-chain',
73
+ })
74
+
75
+ /** The reason codes of the derivation that follow from a startup mode. */
76
+ export const STARTUP_REASONS = Object.freeze({
77
+ DESKTOP: 'desktop-app',
78
+ OWN_SUPERVISOR: 'supervised',
79
+ EXTERNAL: 'external-supervisor',
80
+ BARE: 'ok',
81
+ CONTAINER: 'container-no-init',
82
+ UNKNOWN: 'unknown-startup',
83
+ })
84
+
85
+ /**
86
+ * The environment markers of the common supervisors.
87
+ *
88
+ * They are cheap, exact and need no process tree.
89
+ */
90
+ const SUPERVISOR_ENV_MARKERS = Object.freeze([
91
+ { key: 'INVOCATION_ID', kind: STARTUP_KINDS.SYSTEMD, label: 'a systemd unit' },
92
+ { key: 'pm_id', kind: STARTUP_KINDS.PM2, label: 'pm2' },
93
+ { key: 'PM2_HOME', kind: STARTUP_KINDS.PM2, label: 'pm2' },
94
+ ])
95
+
96
+ /**
97
+ * The ancestors that mean "somebody else is in charge".
98
+ *
99
+ * The ORDER does not matter here: an external match ANYWHERE in the chain
100
+ * wins, because the nearest ancestor may be a plain shell that a supervisor
101
+ * started (`systemd → bash → dsh web`).
102
+ */
103
+ const EXTERNAL_ANCESTORS = Object.freeze([
104
+ { kind: STARTUP_KINDS.SYSTEMD, pattern: /systemd/i, label: 'a systemd unit' },
105
+ { kind: STARTUP_KINDS.PM2, pattern: /\bpm2\b/i, label: 'pm2' },
106
+ { kind: STARTUP_KINDS.FOREVER, pattern: /\bforever\b/i, label: 'forever' },
107
+ {
108
+ kind: STARTUP_KINDS.SCRIPT,
109
+ pattern: /supervise[-_][\w.-]*\.(sh|bash|py|js)|supervisord|\brunsv\b|\bs6-supervise\b|\bmonit\b/i,
110
+ label: 'a supervisor script',
111
+ },
112
+ { kind: STARTUP_KINDS.WINDOWS_SERVICE, pattern: /\bnssm(\.exe)?\b/i, label: 'a Windows service (NSSM)' },
113
+ {
114
+ kind: STARTUP_KINDS.WINDOWS_TASK,
115
+ pattern: /\bsvchost(\.exe)?\b|\btaskhostw(\.exe)?\b/i,
116
+ label: 'the Windows side (a scheduled task or a service)',
117
+ },
118
+ {
119
+ kind: STARTUP_KINDS.DSH_PLUGIN,
120
+ pattern: /[\\/]plugins[\\/][^\\/]+[\\/]/i,
121
+ label: 'another DSH plugin',
122
+ },
123
+ ])
124
+
125
+ /** The ancestors that mean "a session" (not a supervisor, but not free either). */
126
+ const SESSION_ANCESTORS = Object.freeze([
127
+ { pattern: /\btmux\b/i, label: 'a tmux session' },
128
+ { pattern: /\bscreen\b/i, label: 'a screen session' },
129
+ { pattern: /\bsshd\b|\bssh\b/i, label: 'an SSH session' },
130
+ ])
131
+
132
+ /** The ancestors that mean "the command was detached from its shell". */
133
+ const DETACHED_ANCESTORS = Object.freeze([
134
+ { pattern: /\bnohup\b/i, label: 'a nohup command' },
135
+ { pattern: /\bsetsid\b/i, label: 'a setsid command' },
136
+ ])
137
+
138
+ /** The ancestors that mean "a human started it in a terminal". */
139
+ const TERMINAL_ANCESTORS = Object.freeze([
140
+ { pattern: /\b(bash|sh|zsh|fish|dash|ksh|csh|tcsh)\b/i, label: 'a shell' },
141
+ { pattern: /\b(cmd(\.exe)?|powershell(\.exe)?|pwsh(\.exe)?|conhost(\.exe)?|WindowsTerminal(\.exe)?)\b/i, label: 'a Windows terminal' },
142
+ ])
143
+
144
+ /** The package runners that may hide the real command line. */
145
+ const PACKAGE_RUNNERS = Object.freeze([
146
+ { pattern: /^(npx|pnpm|npm|yarn|bunx|dlx)(\.cmd|\.exe|\.ps1)?$/i, label: 'a package runner' },
147
+ ])
148
+
149
+ /** The places in a path that say "a package runner was here". */
150
+ const PACKAGE_RUNNER_PATHS = Object.freeze([
151
+ /[\\/]_npx[\\/]/i,
152
+ /[\\/]\.pnpm[\\/]/i,
153
+ /[\\/]node_modules[\\/]\.bin[\\/]/i,
154
+ /[\\/]node_modules[\\/][^\\/]+[\\/]bin[\\/]/i,
155
+ ])
156
+
157
+ /**
158
+ * The text of one ancestor (the image name and the command line).
159
+ *
160
+ * @param {{ imageName?: string, commandLine?: string }} entry
161
+ * @returns {string}
162
+ */
163
+ function ancestorText(entry) {
164
+ if (!entry || typeof entry !== 'object') return ''
165
+ return [entry.imageName ?? '', entry.commandLine ?? ''].filter(Boolean).join(' ')
166
+ }
167
+
168
+ /**
169
+ * The evidence of one ancestor: what it is and how it was found.
170
+ *
171
+ * @param {{ pid?: number, imageName?: string, commandLine?: string }} entry
172
+ * @returns {string}
173
+ */
174
+ function ancestorEvidence(entry) {
175
+ const pid = Number(entry?.pid)
176
+ const text = ancestorText(entry).trim()
177
+ return 'ancestor:' + (Number.isFinite(pid) ? pid : '?') + ': ' + (text === '' ? '?' : text)
178
+ }
179
+
180
+ /**
181
+ * The first ancestor that matches one of the patterns.
182
+ *
183
+ * @param {Array<object>} chain
184
+ * @param {Array<{ pattern: RegExp, label: string, kind?: string }>} patterns
185
+ * @returns {{ entry: object, label: string, kind: string }|null}
186
+ */
187
+ function firstMatch(chain, patterns) {
188
+ for (const entry of chain) {
189
+ const text = ancestorText(entry)
190
+ if (text === '') continue
191
+ for (const rule of patterns) {
192
+ if (rule.pattern.test(text)) {
193
+ return { entry, label: rule.label, kind: rule.kind ?? '' }
194
+ }
195
+ }
196
+ }
197
+ return null
198
+ }
199
+
200
+ /**
201
+ * Is this a WSL instance?
202
+ *
203
+ * @param {object} env
204
+ * @returns {boolean}
205
+ */
206
+ export function isWsl(env) {
207
+ if (!env || typeof env !== 'object') return false
208
+ if (typeof env.WSL_DISTRO_NAME === 'string' && env.WSL_DISTRO_NAME !== '') return true
209
+ if (typeof env.WSL_INTEROP === 'string' && env.WSL_INTEROP !== '') return true
210
+ return false
211
+ }
212
+
213
+ /**
214
+ * Can the real command line be restored?
215
+ *
216
+ * A package runner (`npx dsh web`) starts the host with the RESOLVED script
217
+ * path in `argv[1]` — that is replayable. A wrapper that erased the command
218
+ * line is not: the plugin must not guess which script it was.
219
+ *
220
+ * @param {{ argv?: string[], execPath?: string }} options
221
+ * @returns {{ ok: boolean, kind: string, label: string, reason: string }}
222
+ */
223
+ export function inspectCommandLine(options = {}) {
224
+ const argv = Array.isArray(options.argv) ? options.argv.map(String) : []
225
+ const execPath = String(options.execPath ?? '')
226
+ const script = String(argv[1] ?? '').trim()
227
+ const runner = PACKAGE_RUNNERS.find((rule) => (
228
+ rule.pattern.test(argv[0] ?? '') || rule.pattern.test(execPath.split(/[\\/]/).pop() ?? '')
229
+ ))
230
+ const runnerInPath = PACKAGE_RUNNER_PATHS.some((rule) => rule.test(script))
231
+ if (runner === undefined && !runnerInPath) {
232
+ // Not a package runner: the command line is used as it is.
233
+ if (script === '') {
234
+ return { ok: false, kind: STARTUP_KINDS.OPAQUE_WRAPPER, label: 'an unreadable command line', reason: 'the command line of this host cannot be read back' }
235
+ }
236
+ return { ok: true, kind: '', label: '', reason: '' }
237
+ }
238
+ const label = runner !== undefined ? runner.label : 'a package runner'
239
+ // A package runner: the resolved script path must be there, or nothing can
240
+ // be replayed.
241
+ if (script !== '' && (/[\\/]/.test(script) || /\.(mjs|cjs|js)$/i.test(script))) {
242
+ return { ok: true, kind: STARTUP_KINDS.PACKAGE_RUNNER, label, reason: '' }
243
+ }
244
+ return {
245
+ ok: false,
246
+ kind: STARTUP_KINDS.OPAQUE_WRAPPER,
247
+ label,
248
+ reason: 'this host was started through ' + label + ', and the real command line cannot be restored: nothing is guessed',
249
+ }
250
+ }
251
+
252
+ /**
253
+ * The command that starts the host again, DETACHED from the shell that runs
254
+ * it.
255
+ *
256
+ * A host that was started with `nohup … &` (or inside a tmux/ssh session)
257
+ * must not die with the shell that runs the command: the new host has to
258
+ * outlive it.
259
+ *
260
+ * @param {string} command
261
+ * @param {string} platform
262
+ * @returns {string}
263
+ */
264
+ export function detachCommand(command, platform) {
265
+ const text = String(command ?? '').trim()
266
+ if (text === '') return ''
267
+ if (platform === 'win32') return 'start "" /B ' + text
268
+ return 'setsid nohup ' + text + ' >/dev/null 2>&1 &'
269
+ }
270
+
271
+ /**
272
+ * Classify HOW this host was started.
273
+ *
274
+ * @param {{
275
+ * platform?: string,
276
+ * env?: object,
277
+ * ppid?: number,
278
+ * isDesktopApp?: boolean,
279
+ * supervised?: boolean,
280
+ * container?: boolean,
281
+ * argv?: string[],
282
+ * execPath?: string,
283
+ * chain?: Array<object>,
284
+ * readChain?: Function,
285
+ * }} [options]
286
+ * @returns {{
287
+ * mode: string,
288
+ * kind: string,
289
+ * evidence: string,
290
+ * selfReplay: 'allowed'|'refused'|'unknown'|'n/a',
291
+ * canInstallSupervisor: boolean,
292
+ * detachCommand: boolean,
293
+ * suggestion: string,
294
+ * chain: Array<object>,
295
+ * }}
296
+ */
297
+ export function classifyStartup(options = {}) {
298
+ const platform = typeof options.platform === 'string' ? options.platform : process.platform
299
+ const env = options.env && typeof options.env === 'object' ? options.env : process.env
300
+ const chain = Array.isArray(options.chain)
301
+ ? options.chain
302
+ : readChainSafely(options)
303
+ const wsl = options.wsl === true || (options.wsl === undefined && isWsl(env))
304
+
305
+ const answer = (mode, kind, evidence, selfReplay, canInstallSupervisor, suggestion, extra = {}) => ({
306
+ mode,
307
+ kind,
308
+ evidence,
309
+ selfReplay,
310
+ canInstallSupervisor,
311
+ detachCommand: extra.detachCommand === true,
312
+ suggestion,
313
+ chain,
314
+ })
315
+
316
+ // (1) THE DESKTOP APP. It relaunches itself; nobody may install anything.
317
+ if (options.isDesktopApp === true) {
318
+ return answer(
319
+ STARTUP_MODES.DESKTOP,
320
+ STARTUP_KINDS.DESKTOP_APP,
321
+ 'the executable of this process is the desktop client',
322
+ 'n/a',
323
+ false,
324
+ 'the desktop app relaunches itself (restartMode: app) — there is no supervisor to install and no command to write',
325
+ )
326
+ }
327
+
328
+ // (2) OUR OWN SUPERVISOR started this instance: the restart is asked of it.
329
+ if (options.supervised === true) {
330
+ return answer(
331
+ STARTUP_MODES.SELF_SUPERVISED,
332
+ STARTUP_KINDS.OWN_SUPERVISOR,
333
+ 'the environment marker DSH_AUTORESTART_SUPERVISED says who started this instance',
334
+ 'n/a',
335
+ false,
336
+ 'the supervisor of this plugin is in charge: the restart is handed to it',
337
+ )
338
+ }
339
+
340
+ // (3) A CONTAINER. There is no "at login" inside a container; the
341
+ // orchestrator decides (compose: restart: always).
342
+ if (options.container === true) {
343
+ const kubernetes = typeof env.KUBERNETES_SERVICE_HOST === 'string' && env.KUBERNETES_SERVICE_HOST !== ''
344
+ return answer(
345
+ STARTUP_MODES.CONTAINER,
346
+ kubernetes ? STARTUP_KINDS.KUBERNETES : STARTUP_KINDS.DOCKER,
347
+ kubernetes ? 'the environment marker KUBERNETES_SERVICE_HOST' : 'the container markers of this process',
348
+ 'refused',
349
+ false,
350
+ 'this host runs in a container: there is no "at login" here — the orchestrator brings it back (compose: restart: always)',
351
+ )
352
+ }
353
+
354
+ // (4) THE ENVIRONMENT MARKERS of the common supervisors.
355
+ for (const marker of SUPERVISOR_ENV_MARKERS) {
356
+ const value = env[marker.key]
357
+ if (typeof value === 'string' && value !== '') {
358
+ return answer(
359
+ STARTUP_MODES.EXTERNAL,
360
+ marker.kind,
361
+ 'env:' + marker.key,
362
+ 'refused',
363
+ false,
364
+ 'this host is started by ' + marker.label + ' — it brings the host back, so the plugin only exits gracefully',
365
+ )
366
+ }
367
+ }
368
+
369
+ // (5) THE PROCESS CHAIN. An external match ANYWHERE in the chain wins: the
370
+ // nearest ancestor may be a plain shell that a supervisor started.
371
+ const external = firstMatch(chain, EXTERNAL_ANCESTORS)
372
+ if (external !== null) {
373
+ return answer(
374
+ STARTUP_MODES.EXTERNAL,
375
+ external.kind === '' ? STARTUP_KINDS.SCRIPT : external.kind,
376
+ ancestorEvidence(external.entry),
377
+ 'refused',
378
+ false,
379
+ 'this host is started by ' + external.label + ' — it brings the host back, so the plugin only exits gracefully',
380
+ )
381
+ }
382
+
383
+ // (6) WSL: a host that the WINDOWS side started has no visible parent
384
+ // here. It must NOT be treated as a bare host.
385
+ if (wsl && (chain.length === 0 || chain.every((entry) => Number(entry.pid) <= 1))) {
386
+ return answer(
387
+ STARTUP_MODES.UNKNOWN,
388
+ STARTUP_KINDS.WSL_FROM_WINDOWS,
389
+ chain.length === 0
390
+ ? 'the parent chain cannot be read (in WSL the Windows side is invisible)'
391
+ : 'the nearest ancestor is the WSL init — the Windows side that started it is invisible',
392
+ 'unknown',
393
+ false,
394
+ 'this host runs in WSL and was started by a Windows process: the parent is invisible here — pick the plain terminal (install your own supervisor) or configure the start command yourself',
395
+ )
396
+ }
397
+
398
+ // (7) THE COMMAND LINE. A package runner that hid the real command line
399
+ // cannot be replayed — and must not be guessed.
400
+ const command = inspectCommandLine({ argv: options.argv, execPath: options.execPath })
401
+ if (!command.ok) {
402
+ return answer(
403
+ STARTUP_MODES.UNKNOWN,
404
+ command.kind,
405
+ command.reason,
406
+ 'unknown',
407
+ false,
408
+ 'this host was started through ' + command.label + ' and the real command line cannot be restored: nothing is guessed, and no supervisor is installed',
409
+ )
410
+ }
411
+
412
+ // (8) A SESSION (tmux / screen / ssh): nobody restarts the host, so a
413
+ // replay is possible — but it must be detached from the session.
414
+ const session = firstMatch(chain, SESSION_ANCESTORS)
415
+ if (session !== null) {
416
+ return answer(
417
+ STARTUP_MODES.BARE,
418
+ STARTUP_KINDS.SESSION,
419
+ ancestorEvidence(session.entry),
420
+ 'allowed',
421
+ true,
422
+ 'this host runs in ' + session.label + ': nobody brings it back, so the restart command must be detached from the session (setsid/nohup) and needs your confirmation',
423
+ { detachCommand: true },
424
+ )
425
+ }
426
+
427
+ // (9) A DETACHED start (`nohup … &`): nobody brings it back either, but
428
+ // the replay must not die with the shell that runs it.
429
+ const detached = firstMatch(chain, DETACHED_ANCESTORS)
430
+ if (detached !== null) {
431
+ return answer(
432
+ STARTUP_MODES.BARE,
433
+ STARTUP_KINDS.DETACHED,
434
+ ancestorEvidence(detached.entry),
435
+ 'allowed',
436
+ true,
437
+ 'this host was started detached (' + detached.label + '): the restart command must outlive the shell that runs it (setsid/nohup)',
438
+ { detachCommand: true },
439
+ )
440
+ }
441
+
442
+ // (10) A PLAIN TERMINAL: nobody manages it. This is the only case in
443
+ // which the plugin may install its own supervisor.
444
+ if (chain.length > 0) {
445
+ const nearest = chain[0]
446
+ const terminal = firstMatch(chain, TERMINAL_ANCESTORS)
447
+ const label = terminal !== null ? terminal.label : 'the process that started it'
448
+ return answer(
449
+ STARTUP_MODES.BARE,
450
+ command.kind === STARTUP_KINDS.PACKAGE_RUNNER ? STARTUP_KINDS.PACKAGE_RUNNER : STARTUP_KINDS.TERMINAL,
451
+ 'parent:' + (Number.isFinite(Number(nearest.pid)) ? nearest.pid : '?') + ': '
452
+ + (nearest.imageName !== '' ? nearest.imageName : label)
453
+ + (command.kind === STARTUP_KINDS.PACKAGE_RUNNER ? ' (' + command.label + ')' : ''),
454
+ 'allowed',
455
+ true,
456
+ 'this host was started by hand in ' + label + ': nobody brings it back, so the plugin may install its own supervisor — or write down the command that starts it again (after your confirmation)',
457
+ )
458
+ }
459
+
460
+ // (11) NOTHING COULD BE READ. Say so, and stay conservative.
461
+ return answer(
462
+ STARTUP_MODES.UNKNOWN,
463
+ STARTUP_KINDS.UNREADABLE_CHAIN,
464
+ platform === 'win32'
465
+ ? 'the process table of Windows could not be read: who starts this host is unknown'
466
+ : 'the parent chain cannot be read: who starts this host is unknown',
467
+ 'unknown',
468
+ false,
469
+ 'it could not be established who starts this host: nothing is written and no supervisor is installed',
470
+ )
471
+ }
472
+
473
+ /**
474
+ * Read the parent chain without ever throwing.
475
+ *
476
+ * @param {{ ppid?: number, platform?: string, readChain?: Function }} options
477
+ * @returns {Array<object>}
478
+ */
479
+ function readChainSafely(options) {
480
+ const platform = typeof options.platform === 'string' ? options.platform : process.platform
481
+ const ppid = Number(options.ppid)
482
+ if (!Number.isFinite(ppid) || ppid <= 0) return []
483
+ const read = typeof options.readChain === 'function' ? options.readChain : null
484
+ if (read === null) return []
485
+ try {
486
+ const chain = read(ppid, { platform })
487
+ return Array.isArray(chain) ? chain : []
488
+ } catch {
489
+ return []
490
+ }
491
+ }