fix(windows/installer): an update stops every bun before it replaces bun.exe

Updating to 0.22.1 could die on a modal: "C:\Program Files\punktfunk\bun\
bun.exe — DeleteFile failed; code 5. Access denied." Windows will not delete a
running executable, so that message means a bun was still alive when the copy
reached it, and the installer had no way to recover.

Two things run that bun, and neither is a child of the host service, so
stopping the service — which the installer does correctly wait for — never
touched either. Both are Task Scheduler tasks: PunktfunkWeb (SYSTEM, the
console) and PunktfunkScripting (LocalService, the plugin runner). The only
pre-copy stop we had looked for exactly one of them, and found processes by
task name or by who was listening on 47992/3000. The plugin runner matched
neither clause: different task, and it listens on no port at all. So anyone
who had run `plugins enable` held bun.exe mapped through the whole install and
failed this way on every single update.

The web console could lose the race too. Stop-ScheduledTask returns once
termination has been *requested* and Stop-Process is TerminateProcess, so the
old code went straight from asking to copying; the Rust twin of that routine
has always ended with a deliberate one-second settle. And neither task was
disabled for the duration, while both carry restart-on-failure (web ten times
a minute apart, scripting 999) and the web task also has a logon trigger — so
a force-kill invited a respawn into the middle of a copy that takes well over
a minute at lzma2/max.

StopBunRuntimes now disables both tasks before stopping them, kills any bun
whose image lives under the install dir — by path, so a developer's own bun
survives — keeps the old port sweep for pre-bun installs that ran node on
3000, and then waits until both are actually gone rather than assuming.
bun.exe also gains restartreplace, so if some bun still escapes all of that,
the file lands on the next restart instead of dead-ending the install.

Disabling a task is not free, though: unlike a stopped one it does not come
back at the next boot, so an install that aborts anywhere after that point
would take the console down for good. Hence two restores — a [Run] entry for
the normal flow, and DeinitializeSetup, which Inno calls even when the user
cancels. Both re-enable only what was enabled before the copy, and enabling an
enabled task does nothing, so the pair is idempotent.

Putting things back also turned up a second bug worth naming: the scripting
entry re-registers its task and then unconditionally disables it, which is
right on a first install and has silently switched an operator's plugin runner
off on every upgrade since. The restore runs after both re-registrations and
honours the prior state, so a fresh install still leaves the opt-in runner off.

The Pascal and the PowerShell were both compiled and run on the Windows CI
runner (ISCC, plus the query halves of each command) — but the install path
itself is not yet exercised on glass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:36:46 +02:00
co-authored by Claude Fable 5
parent b275e6d34c
commit c64ada5649
+117 -11
View File
@@ -209,8 +209,12 @@ Source: "{#FfmpegBin}\*.dll"; DestDir: "{app}"; Flags: ignoreversion
#endif
; The portable bun runtime -> {app}\bun\bun.exe. Shared by the web console AND the plugin/script
; runner (both run on bun), so stage it once when EITHER is bundled.
; restartreplace is the safety net under StopBunRuntimes: Windows refuses to delete a RUNNING image,
; so if any bun survives the pre-copy stop, DeleteFile fails with code 5 and - with no reboot-time
; MoveFileEx fallback - Inno can only show the user a dead-end Retry/Skip/Cancel box. With it, the
; install completes and the new runtime lands on the next restart instead.
#if defined(WithWeb) || defined(WithScripting)
Source: "{#BunExe}"; DestDir: "{app}\bun"; DestName: "bun.exe"; Flags: ignoreversion
Source: "{#BunExe}"; DestDir: "{app}\bun"; DestName: "bun.exe"; Flags: ignoreversion restartreplace uninsrestartdelete
#endif
#ifdef WithWeb
; The web management console: the self-contained Nitro SSR bundle (.output = server + public; deps
@@ -326,6 +330,16 @@ Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \
StatusMsg: "Registering the Punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
#endif
#if defined(WithWeb) || defined(WithScripting)
; Put back what StopBunRuntimes disabled to unlock bun.exe. Deliberately the LAST [Run] entry that
; touches the tasks: it has to follow `web setup`'s re-register AND the scripting entry above, whose
; unconditional Disable-ScheduledTask is correct for a fresh install but would otherwise silently
; switch an operator's plugin runner off on every upgrade. Skipped entirely when neither task was
; enabled beforehand (a fresh install), so it can't enable anything the user never asked for.
Filename: "powershell.exe"; Parameters: "{code:RestoreTasksParams}"; \
StatusMsg: "Restoring the console + script runner tasks..."; \
Flags: runhidden waituntilterminated; Check: NeedsTaskRestore
#endif
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
; icon appears without waiting for the next sign-in.
Filename: "{app}\punktfunk-tray.exe"; Flags: runasoriginaluser nowait skipifsilent; Tasks: trayicon
@@ -593,23 +607,96 @@ begin
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
#ifdef WithWeb
{ Stop a running web console + free its port BEFORE the file copy, so the old server doesn't lock
.output / web-run.cmd / bun.exe and the new task can bind. Killing the listener owner is
runtime-agnostic (an early install may have run node on :3000, the current one runs bun on
:47992 - sweep both). `web setup` repeats this idempotently after the copy. Best-effort; a
fresh install is a no-op. }
procedure StopWebConsole;
#if defined(WithWeb) || defined(WithScripting)
{ Each bun task's enabled state as it was BEFORE StopBunRuntimes disabled it, so the [Run] restore
entry can put it back. PunktfunkScripting is OPT-IN, which makes this load-bearing rather than
tidy: re-enabling it unconditionally would switch the plugin runner on for everyone, and leaving
it disabled would switch it off for everyone who had it on. }
var
WebTaskWasEnabled, ScriptingTaskWasEnabled: Boolean;
{ Escape a value for embedding in a single-quoted PowerShell literal ('' is PS's escaped quote).
The install dir is user-chosen, so it can legitimately contain an apostrophe. }
function PsLiteral(S: String): String;
begin
Result := S;
StringChangeEx(Result, '''', '''''', True);
end;
{ Is the task registered AND not Disabled? Answered through the exit code, so there is no temp file
to write, read back, and clean up. A missing task reports False. }
function TaskEnabled(TaskName: String): Boolean;
var
ResultCode: Integer;
begin
Result := False;
if Exec('powershell.exe',
'-NoProfile -ExecutionPolicy Bypass -Command "' +
'$t=Get-ScheduledTask -TaskName ''' + PsLiteral(TaskName) + ''' -ErrorAction SilentlyContinue; ' +
'if($t -and $t.State -ne ''Disabled''){exit 1}; exit 0"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
Result := ResultCode = 1;
end;
{ Free the bundled bun.exe (and the console's own files) BEFORE the copy. Windows will not delete a
running image, so a surviving bun means "DeleteFile failed; code 5" on bun\bun.exe - the modal a
user hit updating to 0.22.1.
Neither bun runs under the host service, so StopHostServiceAndWait does nothing for either: both
are Task Scheduler tasks - PunktfunkWeb (SYSTEM, the console) and PunktfunkScripting (LocalService,
the plugin runner) - and the runner listens on NO port, so the port sweep below cannot see it at
all. That is why this stops tasks by name and processes by image path, not just by socket.
DISABLE before stopping: both carry aggressive restart-on-failure (web 10x at 1min, scripting 999x
at 1min) and the web task also has a logon trigger, so a force-kill on its own invites a respawn
into the middle of a copy that takes well over a minute at lzma2/max. Then WAIT for the processes
to actually go - Stop-ScheduledTask returns when termination is merely requested, and Stop-Process
is TerminateProcess, also asynchronous.
Best-effort throughout; a fresh install is a no-op. }
procedure StopBunRuntimes;
var
ResultCode: Integer;
begin
WebTaskWasEnabled := TaskEnabled('PunktfunkWeb');
ScriptingTaskWasEnabled := TaskEnabled('PunktfunkScripting');
Exec('powershell.exe',
'-NoProfile -ExecutionPolicy Bypass -Command "' +
'$ErrorActionPreference=''SilentlyContinue''; ' +
'Stop-ScheduledTask -TaskName PunktfunkWeb; ' +
'Get-NetTCPConnection -LocalPort 47992,3000 -State Listen | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }"',
'$app=''' + PsLiteral(ExpandConstant('{app}')) + '''.ToLower(); ' +
'foreach($t in ''PunktfunkWeb'',''PunktfunkScripting''){ Disable-ScheduledTask -TaskName $t; Stop-ScheduledTask -TaskName $t }; ' +
{ Scoped to OUR bun by image path - a blanket kill would take out an unrelated bun (a dev box
runs its own). The port half stays runtime-agnostic: a pre-bun install ran node on :3000. }
'for($i=0; $i -lt 40; $i++){ ' +
'$b=@(Get-Process -Name bun | Where-Object { $_.Path -and $_.Path.ToLower().StartsWith($app) }); ' +
'$l=@(Get-NetTCPConnection -LocalPort 47992,3000 -State Listen); ' +
'if($b.Count -eq 0 -and $l.Count -eq 0){ break }; ' +
'$b | ForEach-Object { Stop-Process -Id $_.Id -Force }; ' +
'$l | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }; ' +
'Start-Sleep -Milliseconds 250 }"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
{ The [Run] restore, built here so it names only the tasks that were actually enabled before the
copy. Runs LAST, after `web setup` has re-registered PunktfunkWeb and after the scripting entry has
re-registered-then-disabled PunktfunkScripting - that entry is right for a fresh install and wrong
for an upgrade, and this is what puts an operator's enabled runner back.
The web task is only re-enabled, never started: `web setup` starts it on the builds that ship it,
and starting a console whose files a scripting-only build just removed would be worse than leaving
it for the next boot. The runner was running, so it is restarted. }
function RestoreTasksParams(Param: String): String;
begin
Result := '-NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=''SilentlyContinue''; ';
if WebTaskWasEnabled then
Result := Result + 'Enable-ScheduledTask -TaskName PunktfunkWeb | Out-Null; ';
if ScriptingTaskWasEnabled then
Result := Result +
'Enable-ScheduledTask -TaskName PunktfunkScripting | Out-Null; ' +
'Start-ScheduledTask -TaskName PunktfunkScripting | Out-Null; ';
Result := Result + '"';
end;
function NeedsTaskRestore: Boolean;
begin
Result := WebTaskWasEnabled or ScriptingTaskWasEnabled;
end;
#endif
const
@@ -672,11 +759,30 @@ begin
begin
StopHostServiceAndWait;
StopTrays; { upgrade-safe: unlock punktfunk-tray.exe before the copy }
#if defined(WithWeb) || defined(WithScripting)
StopBunRuntimes; { upgrade-safe: unlock the bundled bun.exe + free :47992 before the copy }
#endif
#ifdef WithWeb
StopWebConsole; { upgrade-safe: free :3000 + unlock the web files before the copy }
{ Stash the chosen password for `web setup` (fresh install only); the temp copy is auto-cleaned. }
if FreshWebInstall then
SaveStringToFile(ExpandConstant('{tmp}\webpw.txt'), Trim(WebPwPage.Values[0]), False);
#endif
end;
end;
#if defined(WithWeb) || defined(WithScripting)
{ The safety net under StopBunRuntimes' Disable-ScheduledTask. Inno calls this even when the user
cancels or an install fails, which is the case that matters: the [Run] restore would never have run,
and a task left DISABLED does not come back at the next boot the way a merely-stopped one does - an
aborted update would take the console down for good. Re-runs the same restore the [Run] entry
builds, so in the normal flow it is a no-op (enabling an enabled task does nothing).
Also called when Setup exits before ssInstall, where both flags are still False and this does
nothing at all. }
procedure DeinitializeSetup;
var
ResultCode: Integer;
begin
if NeedsTaskRestore then
Exec('powershell.exe', RestoreTasksParams(''), '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
#endif