@ray1270/dsh-restart 0.0.0-stage → 0.3.7
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/LICENSE +21 -0
- package/README.md +289 -2
- package/cordis.patch.yml +33 -0
- package/dev.patch.yml +24 -0
- package/lib/client.js +740 -0
- package/lib/helper.ps1 +280 -0
- package/lib/index.js +660 -0
- package/package.json +58 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-restart — Host half.
|
|
3
|
+
*
|
|
4
|
+
* The Host runs as a child of the Electron shell, inside the application's own
|
|
5
|
+
* process tree. That tree does not survive the shell: whatever the Host starts
|
|
6
|
+
* dies when the application is terminated, so a helper spawned the ordinary way
|
|
7
|
+
* is killed at the exact moment it is needed — it performs the kill and is
|
|
8
|
+
* buried with the app before it can relaunch it. The helper is therefore
|
|
9
|
+
* created by the Windows WMI service instead ({@link ./helper.ps1} runs as a
|
|
10
|
+
* child of WmiPrvSE, outside the tree), which is what lets it outlive the kill.
|
|
11
|
+
*
|
|
12
|
+
* The division of labour:
|
|
13
|
+
*
|
|
14
|
+
* Host half pre-flight (active work), session hand-off, WMI launch + verify
|
|
15
|
+
* helper.ps1 sleep, kill the shell, wait for the Host, relaunch, report
|
|
16
|
+
* client half the header button, the two-click confirmation, session restore
|
|
17
|
+
*
|
|
18
|
+
* Routes (all under the same origin as the GUI):
|
|
19
|
+
*
|
|
20
|
+
* GET /api/dsh-restart/status readiness, PIDs, active work, last run
|
|
21
|
+
* POST /api/dsh-restart/restart schedule a restart ({ sessionId, force })
|
|
22
|
+
* GET /api/dsh-restart/pending the session to reopen after a restart
|
|
23
|
+
* POST /api/dsh-restart/pending/consume take that session exactly once
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-restart
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawn } from 'node:child_process'
|
|
29
|
+
import { existsSync } from 'node:fs'
|
|
30
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
31
|
+
import { homedir } from 'node:os'
|
|
32
|
+
import { dirname, join } from 'node:path'
|
|
33
|
+
import { fileURLToPath } from 'node:url'
|
|
34
|
+
|
|
35
|
+
export const name = 'dsh-restart'
|
|
36
|
+
|
|
37
|
+
/** The routes need the Host web server; everything else is a soft dependency. */
|
|
38
|
+
export const inject = ['webServer']
|
|
39
|
+
|
|
40
|
+
const API_BASE = '/api/dsh-restart'
|
|
41
|
+
|
|
42
|
+
/** Marker left by a restart and consumed by the next page load. */
|
|
43
|
+
const PENDING_FILE = 'pending-restore.json'
|
|
44
|
+
|
|
45
|
+
/** Machine-readable outcome written by the helper. */
|
|
46
|
+
const STATE_FILE = 'restart-state.json'
|
|
47
|
+
|
|
48
|
+
/** Helper log; kept beside the state so a failure is explainable. */
|
|
49
|
+
const LOG_FILE = 'helper.log'
|
|
50
|
+
|
|
51
|
+
/** Outcome of the WMI create call, written by the short-lived launcher. */
|
|
52
|
+
const LAUNCH_FILE = 'launch-result.json'
|
|
53
|
+
|
|
54
|
+
/** Present when the Host gave up on a restart the helper has not killed for yet. */
|
|
55
|
+
const CANCEL_FILE = 'cancel-restart'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How long the Host waits for the helper's first state write before giving up.
|
|
59
|
+
*
|
|
60
|
+
* Much longer than a direct spawn needs: the process is created by the WMI
|
|
61
|
+
* service, so a provider lookup, the create call and the helper's own startup
|
|
62
|
+
* all happen before `boot` is written.
|
|
63
|
+
*/
|
|
64
|
+
const HELPER_BOOT_TIMEOUT_MS = 15000
|
|
65
|
+
|
|
66
|
+
const DEFAULTS = Object.freeze({
|
|
67
|
+
/** Pause before the helper kills the shell, so the HTTP reply flushes first. */
|
|
68
|
+
delayMs: 1200,
|
|
69
|
+
/** Grace for the Host's own bounded graceful shutdown before it is force-killed. */
|
|
70
|
+
hostGraceMs: 8000,
|
|
71
|
+
/** How long a pending session marker stays believable. */
|
|
72
|
+
pendingTtlMinutes: 30,
|
|
73
|
+
/** Master switch; false hides the button and refuses the route. */
|
|
74
|
+
enabled: true,
|
|
75
|
+
/** Hard ceiling on the helper's wait for both processes to disappear. */
|
|
76
|
+
killTimeoutMs: 15000,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
function readOptions(config) {
|
|
80
|
+
const source = config ?? {}
|
|
81
|
+
const number = (key, min, max) => {
|
|
82
|
+
const value = source[key]
|
|
83
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max
|
|
84
|
+
? value
|
|
85
|
+
: DEFAULTS[key]
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
delayMs: number('delayMs', 0, 10000),
|
|
89
|
+
hostGraceMs: number('hostGraceMs', 1000, 60000),
|
|
90
|
+
pendingTtlMinutes: number('pendingTtlMinutes', 1, 24 * 60),
|
|
91
|
+
enabled: source.enabled === undefined ? DEFAULTS.enabled : source.enabled === true,
|
|
92
|
+
killTimeoutMs: number('killTimeoutMs', 1000, 120000),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Directory holding the marker, the state and the log. */
|
|
97
|
+
function resolveStateDir() {
|
|
98
|
+
const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim().length > 0
|
|
99
|
+
? process.env.DSH_HOME.trim()
|
|
100
|
+
: join(homedir(), '.dsh')
|
|
101
|
+
return join(home, 'dsh-restart')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether this Host runs under the desktop shell.
|
|
106
|
+
*
|
|
107
|
+
* The shell spawns the Host as the Electron binary in Node mode, so both facts
|
|
108
|
+
* must hold: the binary is Electron, and Node mode is on. A CLI Host reads files
|
|
109
|
+
* from the same Home but has no shell to restart.
|
|
110
|
+
*/
|
|
111
|
+
function detectDesktop() {
|
|
112
|
+
const electron = process.versions?.electron
|
|
113
|
+
if (typeof electron !== 'string' || electron.length === 0) {
|
|
114
|
+
return { desktop: false, reason: 'not running under the Electron shell (CLI or headless Host)' }
|
|
115
|
+
}
|
|
116
|
+
if (process.env.ELECTRON_RUN_AS_NODE !== '1') {
|
|
117
|
+
return { desktop: false, reason: 'Electron binary is not in Node mode, so this is not a Host process' }
|
|
118
|
+
}
|
|
119
|
+
if (!Number.isInteger(process.ppid) || process.ppid <= 0) {
|
|
120
|
+
return { desktop: false, reason: 'the Host has no parent process to restart' }
|
|
121
|
+
}
|
|
122
|
+
return { desktop: true, reason: null }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sendJson(res, status, payload) {
|
|
126
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
127
|
+
res.end(JSON.stringify(payload))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Same-origin guard for the destructive endpoint: browsers always send Origin on
|
|
132
|
+
* a cross-origin POST, so a mismatch is a drive-by request. Non-browser clients
|
|
133
|
+
* that send no Origin are the user's own tooling.
|
|
134
|
+
*/
|
|
135
|
+
function rejectCrossOrigin(req, res) {
|
|
136
|
+
const origin = req.headers?.origin
|
|
137
|
+
if (!origin) return false
|
|
138
|
+
const host = String(req.headers?.host ?? '')
|
|
139
|
+
let sameOrigin = false
|
|
140
|
+
try {
|
|
141
|
+
sameOrigin = host.length > 0 && new URL(origin).host === host.toLowerCase()
|
|
142
|
+
} catch {
|
|
143
|
+
sameOrigin = false
|
|
144
|
+
}
|
|
145
|
+
if (sameOrigin) return false
|
|
146
|
+
sendJson(res, 403, { error: 'cross-origin request refused' })
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readBody(req, limit = 64 * 1024) {
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const chunks = []
|
|
153
|
+
let size = 0
|
|
154
|
+
req.on('data', (chunk) => {
|
|
155
|
+
size += chunk.length
|
|
156
|
+
if (size > limit) {
|
|
157
|
+
reject(new Error('request body too large'))
|
|
158
|
+
req.destroy()
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
chunks.push(chunk)
|
|
162
|
+
})
|
|
163
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
164
|
+
req.on('error', reject)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function readJson(file) {
|
|
169
|
+
try {
|
|
170
|
+
const text = await readFile(file, 'utf8')
|
|
171
|
+
// PowerShell 5.1 helpers may leave a BOM behind; JSON.parse would choke on it.
|
|
172
|
+
return JSON.parse(text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text)
|
|
173
|
+
} catch {
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function writeJson(file, value) {
|
|
179
|
+
await writeFile(file, JSON.stringify(value, null, 2), 'utf8')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Quote one argument for a Windows command line.
|
|
184
|
+
*
|
|
185
|
+
* The only parser that sees this string is `CommandLineToArgvW` in the helper's
|
|
186
|
+
* process — no shell is involved, because WMI calls CreateProcess directly.
|
|
187
|
+
* Double quotes plus backslash-escaped embedded quotes is therefore the whole
|
|
188
|
+
* contract, and it is enough for the paths this plugin passes.
|
|
189
|
+
*/
|
|
190
|
+
export function quoteArgument(value) {
|
|
191
|
+
return `"${String(value).replaceAll('"', '\\"')}"`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The helper invocation the WMI service replays verbatim.
|
|
196
|
+
*
|
|
197
|
+
* Pure on purpose: the quoting is the fragile part of this hand-off, and a
|
|
198
|
+
* wrong backslash here is invisible until the one restart that matters.
|
|
199
|
+
*/
|
|
200
|
+
export function buildHelperCommandLine({
|
|
201
|
+
powershell,
|
|
202
|
+
helperPath,
|
|
203
|
+
shellPid,
|
|
204
|
+
hostPid,
|
|
205
|
+
shellPath,
|
|
206
|
+
stateDir,
|
|
207
|
+
delayMs,
|
|
208
|
+
hostGraceMs,
|
|
209
|
+
killTimeoutMs,
|
|
210
|
+
}) {
|
|
211
|
+
return [
|
|
212
|
+
quoteArgument(powershell),
|
|
213
|
+
'-NoProfile',
|
|
214
|
+
'-NonInteractive',
|
|
215
|
+
'-WindowStyle', 'Hidden',
|
|
216
|
+
'-ExecutionPolicy', 'Bypass',
|
|
217
|
+
'-File', quoteArgument(helperPath),
|
|
218
|
+
'-ShellPid', String(shellPid),
|
|
219
|
+
'-HostPid', String(hostPid),
|
|
220
|
+
'-ShellPath', quoteArgument(shellPath),
|
|
221
|
+
'-StateDir', quoteArgument(stateDir),
|
|
222
|
+
'-DelayMs', String(delayMs),
|
|
223
|
+
'-HostGraceMs', String(hostGraceMs),
|
|
224
|
+
'-KillTimeoutMs', String(killTimeoutMs),
|
|
225
|
+
].join(' ')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The PowerShell program that asks WMI to start the helper.
|
|
230
|
+
*
|
|
231
|
+
* Both variable strings arrive base64-encoded so nothing in a path (quotes,
|
|
232
|
+
* spaces, non-ASCII characters) can break the script. The result is written to
|
|
233
|
+
* a JSON file rather than stdout: the launcher is spawned with ignored stdio so
|
|
234
|
+
* it can never hold the Host's pipes open, and a failed create must still be
|
|
235
|
+
* explainable after the fact.
|
|
236
|
+
*
|
|
237
|
+
* The `Win32_ProcessStartup` record is not decoration. A bare
|
|
238
|
+
* `Win32_Process.Create` calls CreateProcess with no startup information, so a
|
|
239
|
+
* console program — the helper is powershell.exe — is given a fresh, visible
|
|
240
|
+
* console window the moment it exists. Measured on this machine: a helper
|
|
241
|
+
* created that way kept a console window on screen for its whole run (a black
|
|
242
|
+
* box appears, the app closes a second later). `-WindowStyle Hidden` does not
|
|
243
|
+
* prevent it; that switch hides a window after the console has already been
|
|
244
|
+
* shown, which is why it was invisible in tests and obvious to the user. Passing
|
|
245
|
+
* SW_HIDE through the startup record is what creates the process hidden in the
|
|
246
|
+
* first place.
|
|
247
|
+
*
|
|
248
|
+
* The result file records whether that record could be built (`windowHidden`),
|
|
249
|
+
* so a machine where it fails leaves a trace instead of a mystery window.
|
|
250
|
+
*/
|
|
251
|
+
export function buildWmiLauncherScript(commandLine, resultPath) {
|
|
252
|
+
const encode = (text) => Buffer.from(text, 'utf8').toString('base64')
|
|
253
|
+
return [
|
|
254
|
+
"$ErrorActionPreference = 'Stop'",
|
|
255
|
+
`$line = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encode(commandLine)}'))`,
|
|
256
|
+
`$resultPath = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encode(resultPath)}'))`,
|
|
257
|
+
'$startup = $null',
|
|
258
|
+
'try { $startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 } } catch { $startup = $null }',
|
|
259
|
+
'try {',
|
|
260
|
+
' $arguments = @{ CommandLine = $line }',
|
|
261
|
+
' if ($null -ne $startup) { $arguments.ProcessStartupInformation = $startup }',
|
|
262
|
+
' $created = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $arguments',
|
|
263
|
+
' $payload = [ordered]@{ ok = ($created.ReturnValue -eq 0); pid = [int]$created.ProcessId; returnValue = [int]$created.ReturnValue; windowHidden = ($null -ne $startup); error = $null; commandLine = $line; at = (Get-Date).ToString("o") }',
|
|
264
|
+
'} catch {',
|
|
265
|
+
' $payload = [ordered]@{ ok = $false; pid = 0; returnValue = -1; windowHidden = ($null -ne $startup); error = $_.Exception.Message; commandLine = $line; at = (Get-Date).ToString("o") }',
|
|
266
|
+
'}',
|
|
267
|
+
'[System.IO.File]::WriteAllText($resultPath, ($payload | ConvertTo-Json -Compress), (New-Object System.Text.UTF8Encoding($false)))',
|
|
268
|
+
'if ($payload.ok) { exit 0 } else { exit 1 }',
|
|
269
|
+
].join('\n')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Count work that a restart would interrupt.
|
|
274
|
+
*
|
|
275
|
+
* Mirrors the desktop shell's own admission check: a generating or tool-running
|
|
276
|
+
* agent (including subagents and turns waiting for approval), queued inbox
|
|
277
|
+
* messages, and running or stopping background jobs.
|
|
278
|
+
*
|
|
279
|
+
* Detection is fail-open on purpose. This is a safety net, not a lock: if a
|
|
280
|
+
* service face is missing, refusing to restart would strand the user with a
|
|
281
|
+
* button that never works. The failure is reported so the UI can say the count
|
|
282
|
+
* is incomplete.
|
|
283
|
+
*/
|
|
284
|
+
export function collectActiveWork(ctx) {
|
|
285
|
+
const counts = { agents: 0, jobs: 0, terminals: 0 }
|
|
286
|
+
let detectionAvailable = true
|
|
287
|
+
|
|
288
|
+
let agentList = []
|
|
289
|
+
try {
|
|
290
|
+
const agents = ctx.get('agents')
|
|
291
|
+
if (!agents || typeof agents.list !== 'function') throw new Error('agents service is unavailable')
|
|
292
|
+
const rows = agents.list()
|
|
293
|
+
agentList = Array.isArray(rows) ? rows : []
|
|
294
|
+
counts.agents = agentList.filter((agent) => agent && (
|
|
295
|
+
agent.status === 'running'
|
|
296
|
+
|| (Array.isArray(agent.inbox?.nextTurn) && agent.inbox.nextTurn.length > 0)
|
|
297
|
+
|| (Array.isArray(agent.inbox?.nextStep) && agent.inbox.nextStep.length > 0)
|
|
298
|
+
)).length
|
|
299
|
+
} catch {
|
|
300
|
+
detectionAvailable = false
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const jobs = ctx.get('jobs')
|
|
305
|
+
if (!jobs || typeof jobs.list !== 'function') throw new Error('jobs service is unavailable')
|
|
306
|
+
const seen = new Set()
|
|
307
|
+
for (const caller of [undefined, ...agentList]) {
|
|
308
|
+
const rows = jobs.list(caller)
|
|
309
|
+
for (const job of Array.isArray(rows) ? rows : []) {
|
|
310
|
+
const key = String(job?.id)
|
|
311
|
+
if (seen.has(key)) continue
|
|
312
|
+
seen.add(key)
|
|
313
|
+
if (job && (job.status === 'running' || job.status === 'stopping')) counts.jobs += 1
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
detectionAvailable = false
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
const seen = new Set()
|
|
322
|
+
const scan = (terminals, owner) => {
|
|
323
|
+
if (!terminals || typeof terminals.list !== 'function') return
|
|
324
|
+
for (const terminal of terminals.list(owner) ?? []) {
|
|
325
|
+
const key = String(terminal?.sessionId)
|
|
326
|
+
if (seen.has(key)) continue
|
|
327
|
+
seen.add(key)
|
|
328
|
+
if (terminal?.status?.kind === 'running') counts.terminals += 1
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
for (const agent of agentList) {
|
|
332
|
+
try {
|
|
333
|
+
scan(agent?.ctx?.get?.('terminals'), agent)
|
|
334
|
+
} catch {
|
|
335
|
+
detectionAvailable = false
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
scan(ctx.get('terminals'), undefined)
|
|
339
|
+
} catch {
|
|
340
|
+
detectionAvailable = false
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
...counts,
|
|
345
|
+
total: counts.agents + counts.jobs + counts.terminals,
|
|
346
|
+
detectionAvailable,
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** @param ctx - profile context carrying `webServer` */
|
|
351
|
+
export function apply(ctx, config) {
|
|
352
|
+
const options = readOptions(config)
|
|
353
|
+
const stateDir = resolveStateDir()
|
|
354
|
+
const helperPath = join(dirname(fileURLToPath(import.meta.url)), 'helper.ps1')
|
|
355
|
+
const pendingPath = join(stateDir, PENDING_FILE)
|
|
356
|
+
const statePath = join(stateDir, STATE_FILE)
|
|
357
|
+
const logPath = join(stateDir, LOG_FILE)
|
|
358
|
+
const launchPath = join(stateDir, LAUNCH_FILE)
|
|
359
|
+
const cancelPath = join(stateDir, CANCEL_FILE)
|
|
360
|
+
|
|
361
|
+
const desktop = detectDesktop()
|
|
362
|
+
// console, not ctx.logger: the logger face differs between Host versions, and a
|
|
363
|
+
// missing or differently-shaped service must never take this plugin's load down.
|
|
364
|
+
const log = (level, message) => console[level](`[dsh-restart] ${message}`)
|
|
365
|
+
if (!desktop.desktop) log('warn', `restart disabled: ${desktop.reason}`)
|
|
366
|
+
if (!existsSync(helperPath)) log('warn', `helper script missing: ${helperPath}`)
|
|
367
|
+
|
|
368
|
+
/** Set once a restart is in flight, so a second click cannot schedule another. */
|
|
369
|
+
let scheduled = null
|
|
370
|
+
|
|
371
|
+
async function readPending() {
|
|
372
|
+
const marker = await readJson(pendingPath)
|
|
373
|
+
if (!marker || typeof marker.sessionId !== 'string' || typeof marker.at !== 'number') return null
|
|
374
|
+
if (Date.now() - marker.at > options.pendingTtlMinutes * 60 * 1000) {
|
|
375
|
+
await rm(pendingPath, { force: true }).catch(() => {})
|
|
376
|
+
return null
|
|
377
|
+
}
|
|
378
|
+
return marker
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function status() {
|
|
382
|
+
const activeWork = collectActiveWork(ctx)
|
|
383
|
+
const pendingRestore = await readPending()
|
|
384
|
+
const lastRun = await readJson(statePath)
|
|
385
|
+
const lastLaunch = await readJson(launchPath)
|
|
386
|
+
const helperAvailable = existsSync(helperPath)
|
|
387
|
+
const canRestart = options.enabled && desktop.desktop && helperAvailable && scheduled === null
|
|
388
|
+
let reason = null
|
|
389
|
+
if (!options.enabled) reason = 'the plugin is disabled by configuration'
|
|
390
|
+
else if (!desktop.desktop) reason = desktop.reason
|
|
391
|
+
else if (!helperAvailable) reason = `helper script missing: ${helperPath}`
|
|
392
|
+
else if (scheduled !== null) reason = 'a restart is already scheduled'
|
|
393
|
+
return {
|
|
394
|
+
canRestart,
|
|
395
|
+
reason,
|
|
396
|
+
hostPid: process.pid,
|
|
397
|
+
shellPid: desktop.desktop ? process.ppid : null,
|
|
398
|
+
shellPath: desktop.desktop ? process.execPath : null,
|
|
399
|
+
stateDir,
|
|
400
|
+
logPath,
|
|
401
|
+
activeWork,
|
|
402
|
+
pendingRestore: pendingRestore === null ? null : { sessionId: pendingRestore.sessionId, at: pendingRestore.at },
|
|
403
|
+
lastRun: lastRun === null ? null : {
|
|
404
|
+
phase: lastRun.phase ?? null,
|
|
405
|
+
at: lastRun.at ?? null,
|
|
406
|
+
error: lastRun.error ?? null,
|
|
407
|
+
newShellPid: lastRun.newShellPid ?? 0,
|
|
408
|
+
hostPid: lastRun.hostPid ?? null,
|
|
409
|
+
shellPid: lastRun.shellPid ?? null,
|
|
410
|
+
},
|
|
411
|
+
lastLaunch: lastLaunch === null ? null : {
|
|
412
|
+
ok: lastLaunch.ok === true,
|
|
413
|
+
pid: lastLaunch.pid ?? null,
|
|
414
|
+
windowHidden: lastLaunch.windowHidden ?? null,
|
|
415
|
+
error: lastLaunch.error ?? null,
|
|
416
|
+
at: lastLaunch.at ?? null,
|
|
417
|
+
},
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Start the helper through the WMI service and wait for its first state write.
|
|
423
|
+
*
|
|
424
|
+
* The launcher itself is an ordinary child of this process, but it only asks
|
|
425
|
+
* the service to create the helper and then exits. The helper is a child of
|
|
426
|
+
* WmiPrvSE, on the other side of the process-tree boundary, which is the whole
|
|
427
|
+
* reason it can kill the shell and still be alive to start the replacement.
|
|
428
|
+
*
|
|
429
|
+
* Waiting matters: a helper that never starts would otherwise be discovered
|
|
430
|
+
* only after the application is already gone. The reply states whether the
|
|
431
|
+
* helper is actually running, not merely that a spawn call returned.
|
|
432
|
+
*/
|
|
433
|
+
async function spawnHelper() {
|
|
434
|
+
await mkdir(stateDir, { recursive: true })
|
|
435
|
+
await rm(statePath, { force: true }).catch(() => {})
|
|
436
|
+
await rm(logPath, { force: true }).catch(() => {})
|
|
437
|
+
await rm(launchPath, { force: true }).catch(() => {})
|
|
438
|
+
await rm(cancelPath, { force: true }).catch(() => {})
|
|
439
|
+
|
|
440
|
+
const powershell = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
441
|
+
const commandLine = buildHelperCommandLine({
|
|
442
|
+
powershell,
|
|
443
|
+
helperPath,
|
|
444
|
+
shellPid: process.ppid,
|
|
445
|
+
hostPid: process.pid,
|
|
446
|
+
shellPath: process.execPath,
|
|
447
|
+
stateDir,
|
|
448
|
+
delayMs: options.delayMs,
|
|
449
|
+
hostGraceMs: options.hostGraceMs,
|
|
450
|
+
killTimeoutMs: options.killTimeoutMs,
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
let spawnError = null
|
|
454
|
+
let launcher = null
|
|
455
|
+
let launcherExited = null
|
|
456
|
+
try {
|
|
457
|
+
launcher = spawn(powershell, [
|
|
458
|
+
'-NoProfile',
|
|
459
|
+
'-NonInteractive',
|
|
460
|
+
'-ExecutionPolicy', 'Bypass',
|
|
461
|
+
// The script goes in as UTF-16LE base64, so no argument in it needs
|
|
462
|
+
// quoting and no path can be split by the command line.
|
|
463
|
+
'-EncodedCommand', Buffer.from(buildWmiLauncherScript(commandLine, launchPath), 'utf16le').toString('base64'),
|
|
464
|
+
], {
|
|
465
|
+
// Ignored stdio keeps the Host's pipes out of the launcher's hands.
|
|
466
|
+
stdio: 'ignore',
|
|
467
|
+
windowsHide: true,
|
|
468
|
+
cwd: stateDir,
|
|
469
|
+
})
|
|
470
|
+
launcher.on('error', (error) => {
|
|
471
|
+
// Without a listener, a spawn failure would be an unhandled 'error'
|
|
472
|
+
// event and take the Host down.
|
|
473
|
+
spawnError = error
|
|
474
|
+
})
|
|
475
|
+
launcher.on('exit', (code) => {
|
|
476
|
+
launcherExited = code
|
|
477
|
+
})
|
|
478
|
+
launcher.unref()
|
|
479
|
+
} catch (error) {
|
|
480
|
+
spawnError = error
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const deadline = Date.now() + HELPER_BOOT_TIMEOUT_MS
|
|
484
|
+
while (Date.now() < deadline) {
|
|
485
|
+
const state = await readJson(statePath)
|
|
486
|
+
if (state && typeof state.phase === 'string') {
|
|
487
|
+
const launch = await readJson(launchPath)
|
|
488
|
+
// A helper created without the hidden-window record puts a console box on
|
|
489
|
+
// screen. The restart still works, so this is a warning, not a refusal —
|
|
490
|
+
// but it must be visible in the log instead of only in the task bar.
|
|
491
|
+
if (launch !== null && launch.ok === true && launch.windowHidden === false) {
|
|
492
|
+
log('warn', 'the helper was created without a hidden window; a console window will show')
|
|
493
|
+
}
|
|
494
|
+
return { started: true, state, launch, spawnError: null }
|
|
495
|
+
}
|
|
496
|
+
if (spawnError !== null) break
|
|
497
|
+
// The launcher is short-lived: once it is gone and its result file says
|
|
498
|
+
// the service refused, waiting out the rest of the budget would only make
|
|
499
|
+
// a broken WMI call feel like a hang.
|
|
500
|
+
if (launcherExited !== null) {
|
|
501
|
+
const launch = await readJson(launchPath)
|
|
502
|
+
if (launch !== null && launch.ok !== true) {
|
|
503
|
+
return { started: false, state: null, launch, spawnError }
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
507
|
+
}
|
|
508
|
+
const launch = await readJson(launchPath)
|
|
509
|
+
if (launcher !== null) {
|
|
510
|
+
// The launcher may still be waiting on the service; the helper cannot
|
|
511
|
+
// exist yet without its boot state, so stopping it here is safe.
|
|
512
|
+
try { launcher.kill('SIGKILL') } catch { /* already gone */ }
|
|
513
|
+
}
|
|
514
|
+
return { started: false, state: null, launch, spawnError }
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Tell a helper that may still be sleeping not to kill anything.
|
|
519
|
+
*
|
|
520
|
+
* The helper is not this process's child, so it cannot be killed directly and
|
|
521
|
+
* does not need to be: it checks this marker after its delay, before the kill.
|
|
522
|
+
* Written only when the launch could not be confirmed, where the alternative
|
|
523
|
+
* is a delayed helper tearing the application down after the UI said it would
|
|
524
|
+
* not.
|
|
525
|
+
*/
|
|
526
|
+
async function cancelHelper() {
|
|
527
|
+
try {
|
|
528
|
+
await writeFile(cancelPath, JSON.stringify({ at: Date.now() }), 'utf8')
|
|
529
|
+
} catch { /* the state directory may be gone; nothing left to cancel */ }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const routes = [
|
|
533
|
+
{
|
|
534
|
+
path: `${API_BASE}/status`,
|
|
535
|
+
handler: async (req, res) => {
|
|
536
|
+
if (req.method !== 'GET') {
|
|
537
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
sendJson(res, 200, await status())
|
|
542
|
+
} catch (error) {
|
|
543
|
+
sendJson(res, 500, { error: error?.message ?? String(error) })
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
path: `${API_BASE}/restart`,
|
|
549
|
+
handler: async (req, res) => {
|
|
550
|
+
if (req.method !== 'POST') {
|
|
551
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
if (rejectCrossOrigin(req, res)) return
|
|
555
|
+
try {
|
|
556
|
+
const raw = await readBody(req)
|
|
557
|
+
const body = raw.length === 0 ? {} : (JSON.parse(raw) ?? {})
|
|
558
|
+
const sessionId = typeof body.sessionId === 'string' && body.sessionId.length > 0 ? body.sessionId : null
|
|
559
|
+
|
|
560
|
+
if (!options.enabled) {
|
|
561
|
+
sendJson(res, 503, { error: 'the plugin is disabled by configuration' })
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
if (!desktop.desktop) {
|
|
565
|
+
sendJson(res, 503, { error: desktop.reason })
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
if (!existsSync(helperPath)) {
|
|
569
|
+
sendJson(res, 503, { error: `helper script missing: ${helperPath}` })
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
if (scheduled !== null) {
|
|
573
|
+
sendJson(res, 409, { error: 'a restart is already scheduled' })
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// The client confirms before it sends `force`. Without that flag an
|
|
578
|
+
// interrupted agent is a refusal, not a warning.
|
|
579
|
+
const activeWork = collectActiveWork(ctx)
|
|
580
|
+
if (activeWork.total > 0 && body.force !== true) {
|
|
581
|
+
sendJson(res, 409, {
|
|
582
|
+
error: `active work would be interrupted (agents ${activeWork.agents} / jobs ${activeWork.jobs} / terminals ${activeWork.terminals})`,
|
|
583
|
+
items: activeWork,
|
|
584
|
+
})
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
scheduled = { at: Date.now(), sessionId }
|
|
589
|
+
if (sessionId !== null) {
|
|
590
|
+
await mkdir(stateDir, { recursive: true })
|
|
591
|
+
await writeJson(pendingPath, { sessionId, at: Date.now() })
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const { started, state, launch, spawnError } = await spawnHelper()
|
|
595
|
+
if (!started) {
|
|
596
|
+
scheduled = null
|
|
597
|
+
await cancelHelper()
|
|
598
|
+
await rm(pendingPath, { force: true }).catch(() => {})
|
|
599
|
+
const detail = launch?.error ?? spawnError?.message ?? 'no boot state from the helper'
|
|
600
|
+
sendJson(res, 500, {
|
|
601
|
+
error: `the restart helper did not start (${detail}); the application was left running`,
|
|
602
|
+
detail,
|
|
603
|
+
launch: launch ?? null,
|
|
604
|
+
stateDir,
|
|
605
|
+
logPath,
|
|
606
|
+
launchPath,
|
|
607
|
+
})
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
sendJson(res, 200, {
|
|
612
|
+
ok: true,
|
|
613
|
+
helperPid: state.helperPid ?? null,
|
|
614
|
+
launchedPid: launch?.pid ?? null,
|
|
615
|
+
shellPid: process.ppid,
|
|
616
|
+
hostPid: process.pid,
|
|
617
|
+
delayMs: options.delayMs,
|
|
618
|
+
sessionId,
|
|
619
|
+
})
|
|
620
|
+
} catch (error) {
|
|
621
|
+
scheduled = null
|
|
622
|
+
sendJson(res, 400, { error: error?.message ?? String(error) })
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
path: `${API_BASE}/pending`,
|
|
628
|
+
handler: async (req, res) => {
|
|
629
|
+
if (req.method !== 'GET') {
|
|
630
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
const marker = await readPending()
|
|
634
|
+
sendJson(res, 200, marker === null ? { sessionId: null } : { sessionId: marker.sessionId, at: marker.at })
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
path: `${API_BASE}/pending/consume`,
|
|
639
|
+
handler: async (req, res) => {
|
|
640
|
+
if (req.method !== 'POST') {
|
|
641
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
if (rejectCrossOrigin(req, res)) return
|
|
645
|
+
const marker = await readPending()
|
|
646
|
+
if (marker !== null) await rm(pendingPath, { force: true }).catch(() => {})
|
|
647
|
+
sendJson(res, 200, marker === null ? { sessionId: null } : { sessionId: marker.sessionId, at: marker.at })
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
]
|
|
651
|
+
|
|
652
|
+
for (const route of routes) {
|
|
653
|
+
ctx.effect(
|
|
654
|
+
() => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }),
|
|
655
|
+
`dsh-restart ${route.path}`,
|
|
656
|
+
)
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export default { name, inject, apply }
|