@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/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
+ }