@ray1270/dsh-restart 0.0.0-stage → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +263 -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/helper.ps1
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
<#
|
|
2
|
+
dsh-restart — the detached process that actually restarts DeepSeek Harness.
|
|
3
|
+
|
|
4
|
+
Why this file exists
|
|
5
|
+
--------------------
|
|
6
|
+
A plugin runs inside the Host child process. The Host cannot reasonably kill its
|
|
7
|
+
own parent (the Electron shell) and then start a new one: whatever process does
|
|
8
|
+
the killing dies with the application's process tree. So the Host starts THIS
|
|
9
|
+
script through the Windows WMI service, hands it two PIDs, and gets out of the
|
|
10
|
+
way.
|
|
11
|
+
|
|
12
|
+
Why it is started by WMI and not by the Host
|
|
13
|
+
-------------------------------------------
|
|
14
|
+
A process the Host spawns itself is a descendant of the application, and the
|
|
15
|
+
application's tree goes away with it. Measured: such a helper wrote `boot`,
|
|
16
|
+
wrote `killing`, started the taskkill — and was terminated together with the
|
|
17
|
+
shell before it could start the replacement. The app closed and never came
|
|
18
|
+
back. `Win32_Process.Create` via WMI is performed by WmiPrvSE.exe, which is
|
|
19
|
+
outside the tree, so the helper outlives the kill and can do the relaunch.
|
|
20
|
+
(Its parent is WmiPrvSE.exe; helper.log records that.)
|
|
21
|
+
|
|
22
|
+
Why the console window must not exist
|
|
23
|
+
-------------------------------------
|
|
24
|
+
WMI creates a process with no startup information, and a console program so
|
|
25
|
+
created is handed a fresh, visible console window. The helper is powershell.exe,
|
|
26
|
+
so that window was on screen for every restart. It cannot be hidden from inside:
|
|
27
|
+
`-WindowStyle Hidden` applies after the console exists and did not prevent it
|
|
28
|
+
(measured). The window is avoided at creation instead, by passing
|
|
29
|
+
`Win32_ProcessStartup.ShowWindow = 0` to Win32_Process.Create, which the Host's
|
|
30
|
+
launcher script does. The console title below exists so the self-test can look
|
|
31
|
+
for that window on screen: a title nobody can see means the start was hidden.
|
|
32
|
+
|
|
33
|
+
What it does, in order
|
|
34
|
+
----------------------
|
|
35
|
+
1. write `boot` to restart-state.json (the Host waits for this before replying 200)
|
|
36
|
+
2. sleep DelayMs (lets the HTTP reply flush)
|
|
37
|
+
3. abort if the Host wrote a cancel marker (it gave up on this launch)
|
|
38
|
+
4. re-verify ShellPid still is ShellPath (defends against PID reuse)
|
|
39
|
+
5. taskkill /F the shell ONLY (no /T: the Host must exit on its own)
|
|
40
|
+
6. wait for BOTH pids to disappear (the Host disposes in <=5s)
|
|
41
|
+
7. force-kill the Host only if it overstays HostGraceMs
|
|
42
|
+
8. relaunch the executable (retry once)
|
|
43
|
+
9. record the outcome
|
|
44
|
+
|
|
45
|
+
Why no /T on the kill
|
|
46
|
+
---------------------
|
|
47
|
+
The Electron shell spawns the Host with an IPC channel. Terminating the shell
|
|
48
|
+
alone closes that channel, and the Host's own `process.once('disconnect')`
|
|
49
|
+
handler runs `application.shutdown.shutdown(0)` — a bounded, graceful dispose.
|
|
50
|
+
`taskkill /T` would kill the Host too, which is exactly the graceful shutdown we
|
|
51
|
+
want to keep.
|
|
52
|
+
|
|
53
|
+
Why ELECTRON_RUN_AS_NODE must be removed
|
|
54
|
+
----------------------------------------
|
|
55
|
+
Whenever this helper still inherits an environment that carries
|
|
56
|
+
`ELECTRON_RUN_AS_NODE=1` (that is how the Host itself runs under the Electron
|
|
57
|
+
binary), starting the executable with that flag set would launch a headless
|
|
58
|
+
Node, not the application. Start-Process inherits the helper's environment, so
|
|
59
|
+
the flag is stripped first. The WMI-started path does not normally carry it,
|
|
60
|
+
but the removal costs nothing and the failure it prevents is baffling.
|
|
61
|
+
|
|
62
|
+
Logs go to <StateDir>/helper.log and the machine-readable state to
|
|
63
|
+
<StateDir>/restart-state.json. Nothing here is encoded or obfuscated on purpose:
|
|
64
|
+
an antivirus flagging "hidden script kills an app and restarts it" should be
|
|
65
|
+
able to read exactly what it does.
|
|
66
|
+
#>
|
|
67
|
+
|
|
68
|
+
[CmdletBinding()]
|
|
69
|
+
param(
|
|
70
|
+
[Parameter(Mandatory = $true)][int]$ShellPid,
|
|
71
|
+
[Parameter(Mandatory = $true)][int]$HostPid,
|
|
72
|
+
[Parameter(Mandatory = $true)][string]$ShellPath,
|
|
73
|
+
[Parameter(Mandatory = $true)][string]$StateDir,
|
|
74
|
+
# Delay before killing, so the HTTP 200 reaches the browser first.
|
|
75
|
+
[int]$DelayMs = 1200,
|
|
76
|
+
# How long the Host may take to dispose before it is force-killed.
|
|
77
|
+
[int]$HostGraceMs = 8000,
|
|
78
|
+
# Hard ceiling on waiting for both processes to disappear.
|
|
79
|
+
[int]$KillTimeoutMs = 15000
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
$ErrorActionPreference = 'Stop'
|
|
83
|
+
|
|
84
|
+
$statePath = Join-Path $StateDir 'restart-state.json'
|
|
85
|
+
$logPath = Join-Path $StateDir 'helper.log'
|
|
86
|
+
$script:Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
87
|
+
|
|
88
|
+
function Write-Log {
|
|
89
|
+
param([string]$Message)
|
|
90
|
+
try {
|
|
91
|
+
$line = '{0} {1}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff'), $Message
|
|
92
|
+
Add-Content -LiteralPath $logPath -Value $line -Encoding UTF8
|
|
93
|
+
} catch { }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function Write-State {
|
|
97
|
+
param(
|
|
98
|
+
[string]$Phase,
|
|
99
|
+
[string]$Error = $null,
|
|
100
|
+
[int]$NewShellPid = 0
|
|
101
|
+
)
|
|
102
|
+
try {
|
|
103
|
+
$payload = [ordered]@{
|
|
104
|
+
phase = $Phase
|
|
105
|
+
helperPid = $PID
|
|
106
|
+
shellPid = $ShellPid
|
|
107
|
+
hostPid = $HostPid
|
|
108
|
+
shellPath = $ShellPath
|
|
109
|
+
newShellPid = $NewShellPid
|
|
110
|
+
error = $Error
|
|
111
|
+
at = (Get-Date).ToString('o')
|
|
112
|
+
}
|
|
113
|
+
$json = $payload | ConvertTo-Json -Compress
|
|
114
|
+
# Written without a BOM: the Host parses this file with JSON.parse, and
|
|
115
|
+
# PowerShell 5.1's `Set-Content -Encoding UTF8` prepends one.
|
|
116
|
+
[System.IO.File]::WriteAllText($statePath, $json, $script:Utf8NoBom)
|
|
117
|
+
} catch { }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# Image path of a live process, or $null when it is gone / not readable.
|
|
121
|
+
function Get-ProcessPath {
|
|
122
|
+
param([int]$ProcessId)
|
|
123
|
+
try {
|
|
124
|
+
$process = Get-Process -Id $ProcessId -ErrorAction Stop
|
|
125
|
+
return $process.Path
|
|
126
|
+
} catch {
|
|
127
|
+
return $null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function Test-Alive {
|
|
132
|
+
param([int]$ProcessId)
|
|
133
|
+
return $null -ne (Get-ProcessPath -ProcessId $ProcessId)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function Test-SameExecutable {
|
|
137
|
+
param([int]$ProcessId, [string]$ExpectedPath)
|
|
138
|
+
$actual = Get-ProcessPath -ProcessId $ProcessId
|
|
139
|
+
if ($null -eq $actual) { return $false }
|
|
140
|
+
try {
|
|
141
|
+
return ([System.IO.Path]::GetFullPath($actual) -eq [System.IO.Path]::GetFullPath($ExpectedPath))
|
|
142
|
+
} catch {
|
|
143
|
+
return $false
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function Stop-ProcessForcibly {
|
|
148
|
+
param([int]$ProcessId)
|
|
149
|
+
try {
|
|
150
|
+
$taskkill = Start-Process -FilePath 'taskkill.exe' -ArgumentList @('/PID', "$ProcessId", '/F') -NoNewWindow -Wait -PassThru
|
|
151
|
+
Write-Log ("taskkill {0} -> exit {1}" -f $ProcessId, $taskkill.ExitCode)
|
|
152
|
+
return $taskkill.ExitCode -eq 0
|
|
153
|
+
} catch {
|
|
154
|
+
Write-Log ("taskkill {0} threw: {1}" -f $ProcessId, $_.Exception.Message)
|
|
155
|
+
return $false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function Wait-ForExit {
|
|
160
|
+
param([int]$ProcessId, [int]$TimeoutMs)
|
|
161
|
+
$deadline = (Get-Date).AddMilliseconds($TimeoutMs)
|
|
162
|
+
while ((Get-Date) -lt $deadline) {
|
|
163
|
+
if (-not (Test-Alive -ProcessId $ProcessId)) { return $true }
|
|
164
|
+
Start-Sleep -Milliseconds 150
|
|
165
|
+
}
|
|
166
|
+
return (-not (Test-Alive -ProcessId $ProcessId))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
# The Host creates this directory, but the helper must not depend on that: a
|
|
171
|
+
# missing directory would silently swallow every state write.
|
|
172
|
+
if (-not (Test-Path -LiteralPath $StateDir)) {
|
|
173
|
+
New-Item -ItemType Directory -Force -Path $StateDir | Out-Null
|
|
174
|
+
}
|
|
175
|
+
# The state write comes first: the Host is waiting on it before it answers the
|
|
176
|
+
# click. Everything below this point is diagnostics.
|
|
177
|
+
Write-State -Phase 'boot'
|
|
178
|
+
|
|
179
|
+
# Identity for the self-test, which asserts that no window with this title is
|
|
180
|
+
# on screen while the helper runs. Harmless when there is no console.
|
|
181
|
+
try { $host.UI.RawUI.WindowTitle = 'dsh-restart helper' } catch { }
|
|
182
|
+
|
|
183
|
+
# Who started this process is the fact that decides whether it can survive the
|
|
184
|
+
# kill, so it belongs in the log next to the PIDs. On the WMI path this is
|
|
185
|
+
# WmiPrvSE.exe, not the Host.
|
|
186
|
+
try {
|
|
187
|
+
$self = Get-CimInstance Win32_Process -Filter "ProcessId=$PID" -ErrorAction Stop
|
|
188
|
+
$parent = Get-Process -Id $self.ParentProcessId -ErrorAction SilentlyContinue
|
|
189
|
+
$parentName = if ($null -eq $parent) { 'unknown' } else { $parent.ProcessName }
|
|
190
|
+
Write-Log ("boot pid={0} parent={1}({2}) shellPid={3} hostPid={4} shellPath='{5}'" -f $PID, $self.ParentProcessId, $parentName, $ShellPid, $HostPid, $ShellPath)
|
|
191
|
+
} catch {
|
|
192
|
+
Write-Log ("boot pid={0} shellPid={1} hostPid={2} shellPath='{3}'" -f $PID, $ShellPid, $HostPid, $ShellPath)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
Start-Sleep -Milliseconds $DelayMs
|
|
196
|
+
|
|
197
|
+
# The Host writes this when its launch could not be confirmed. Without the
|
|
198
|
+
# check a late helper would tear down an application the UI just told the user
|
|
199
|
+
# was still running.
|
|
200
|
+
if (Test-Path -LiteralPath (Join-Path $StateDir 'cancel-restart')) {
|
|
201
|
+
Write-Log 'aborted: the Host cancelled this restart before the kill'
|
|
202
|
+
Write-State -Phase 'aborted' -Error 'cancelled by the Host before the kill'
|
|
203
|
+
exit 7
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# PID reuse guard: if the shell already restarted, this PID belongs to someone
|
|
207
|
+
# else and killing it would be a disaster. Abort instead.
|
|
208
|
+
if (-not (Test-SameExecutable -ProcessId $ShellPid -ExpectedPath $ShellPath)) {
|
|
209
|
+
Write-Log ("aborted: pid {0} is not '{1}' anymore" -f $ShellPid, $ShellPath)
|
|
210
|
+
Write-State -Phase 'aborted' -Error 'shell pid no longer matches the expected executable'
|
|
211
|
+
exit 2
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
Write-State -Phase 'killing'
|
|
215
|
+
$killed = Stop-ProcessForcibly -ProcessId $ShellPid
|
|
216
|
+
if (-not $killed) {
|
|
217
|
+
Write-Log 'shell kill failed; nothing was stopped'
|
|
218
|
+
Write-State -Phase 'failed' -Error 'could not terminate the shell process'
|
|
219
|
+
exit 3
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# The Host exits on its own through the IPC disconnect handler; give it the
|
|
223
|
+
# grace window before forcing anything.
|
|
224
|
+
$hostExited = Wait-ForExit -ProcessId $HostPid -TimeoutMs $HostGraceMs
|
|
225
|
+
if (-not $hostExited) {
|
|
226
|
+
Write-Log ("host pid {0} still alive after {1}ms; forcing" -f $HostPid, $HostGraceMs)
|
|
227
|
+
Stop-ProcessForcibly -ProcessId $HostPid | Out-Null
|
|
228
|
+
Wait-ForExit -ProcessId $HostPid -TimeoutMs 3000 | Out-Null
|
|
229
|
+
} else {
|
|
230
|
+
Write-Log ("host pid {0} exited on its own" -f $HostPid)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
$shellExited = Wait-ForExit -ProcessId $ShellPid -TimeoutMs $KillTimeoutMs
|
|
234
|
+
if (-not $shellExited) {
|
|
235
|
+
Write-Log ("shell pid {0} survived the kill; refusing to relaunch" -f $ShellPid)
|
|
236
|
+
Write-State -Phase 'failed' -Error 'shell process survived the forced termination'
|
|
237
|
+
exit 4
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
Write-State -Phase 'killed'
|
|
241
|
+
|
|
242
|
+
# Inherited from the Host: starting the app with this set would run a headless
|
|
243
|
+
# Node instead of the application.
|
|
244
|
+
if (Test-Path Env:ELECTRON_RUN_AS_NODE) { Remove-Item Env:ELECTRON_RUN_AS_NODE }
|
|
245
|
+
if (Test-Path Env:ELECTRON_NO_ATTACH_CONSOLE) { Remove-Item Env:ELECTRON_NO_ATTACH_CONSOLE }
|
|
246
|
+
|
|
247
|
+
$workingDirectory = Split-Path -Parent $ShellPath
|
|
248
|
+
$newPid = 0
|
|
249
|
+
for ($attempt = 1; $attempt -le 2; $attempt++) {
|
|
250
|
+
if (-not (Test-Path -LiteralPath $ShellPath)) {
|
|
251
|
+
Write-Log ("relaunch aborted: '{0}' does not exist" -f $ShellPath)
|
|
252
|
+
break
|
|
253
|
+
}
|
|
254
|
+
Write-State -Phase 'relaunching'
|
|
255
|
+
try {
|
|
256
|
+
$started = Start-Process -FilePath $ShellPath -WorkingDirectory $workingDirectory -PassThru
|
|
257
|
+
$newPid = $started.Id
|
|
258
|
+
Write-Log ("relaunched attempt={0} newPid={1}" -f $attempt, $newPid)
|
|
259
|
+
} catch {
|
|
260
|
+
Write-Log ("relaunch attempt {0} threw: {1}" -f $attempt, $_.Exception.Message)
|
|
261
|
+
}
|
|
262
|
+
# A second instance against a surviving lock quits itself; give the new
|
|
263
|
+
# process a moment and relaunch once more if it folded immediately.
|
|
264
|
+
Start-Sleep -Milliseconds 2500
|
|
265
|
+
if ($newPid -ne 0 -and (Test-SameExecutable -ProcessId $newPid -ExpectedPath $ShellPath)) {
|
|
266
|
+
Write-Log ("relaunch verified newPid={0}" -f $newPid)
|
|
267
|
+
Write-State -Phase 'relaunched' -NewShellPid $newPid
|
|
268
|
+
exit 0
|
|
269
|
+
}
|
|
270
|
+
Write-Log ("relaunch attempt {0} did not survive; retrying" -f $attempt)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
Write-Log 'relaunch failed'
|
|
274
|
+
Write-State -Phase 'failed' -Error 'the application did not come back up'
|
|
275
|
+
exit 5
|
|
276
|
+
} catch {
|
|
277
|
+
Write-Log ("unhandled: {0}" -f $_.Exception.Message)
|
|
278
|
+
Write-State -Phase 'failed' -Error $_.Exception.Message
|
|
279
|
+
exit 1
|
|
280
|
+
}
|