fix(windows/web-console): a bun exit no longer parks the console until the next sign-in

The PunktfunkWeb task registers RestartCount=10/PT1M, but Task Scheduler honours
restart-on-failure only when the action crashes or fails to start — never for a
plain non-zero exit. So any bun exit left the console down until the next boot or
interactive logon, with the host still streaming perfectly next to it. Seen on
glass on a 0.22.3 upgrade: `web setup` started the task one second after the new
service came up, bun exited at once (Last Run Result 0xFFFFFFFF), and the console
sat dead for hours while its box served clients — which reads to an operator as
"the host is gone".

web-run.cmd now supervises bun instead of exiting with it: restart on any exit,
indefinitely for a console that served a while and then died, but give up after
10 CONSECUTIVE fast exits so a genuinely broken install still surfaces as a failed
task. Any run lasting >= 60s resets that counter. It exits 0 rather than
respawning when the payload disappears, so an uninstall does not make it spin.

Uptime is measured with an unambiguous clock rather than parsed out of %TIME%,
which is locale-formatted (12-hour locales append " PM", many others use ',' as
the decimal separator) — the box this was found on prints 18:36:28,39.

stop_web_console() polls until :47992 is really free instead of sleeping a blind
second: both `schtasks /end` and `taskkill /F` are asynchronous, and web_setup
starts the new task immediately afterwards, where bun cannot bind a port the
corpse still holds. It re-ends the TASK halfway through, not just the listener —
with the launcher now supervising, killing bun alone would be undone by its own
restart loop.

The systemd unit had the same shape of hole: Restart=on-failure leaves a console
that exited 0 down. Now Restart=always, which an explicit `systemctl stop` still
overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 20:42:50 +02:00
co-authored by Claude Opus 5
parent e90c5d5bcd
commit 3bb30cb2f6
3 changed files with 94 additions and 2 deletions
+25 -1
View File
@@ -621,7 +621,31 @@ fn stop_web_console() {
for pid in web_listener_pids() {
run_quiet("taskkill", &["/PID", &pid, "/F"]);
}
std::thread::sleep(std::time::Duration::from_secs(1));
// Both stops are asynchronous - `schtasks /end` returns once the end has been REQUESTED, and
// `taskkill /F` is TerminateProcess - so the outgoing console can still own :47992 after this
// returns. The very next thing `web_setup` does is start the new task, and its bun cannot bind a
// port the corpse still holds; the blind 1 s sleep this replaces was not always enough. Poll
// until the listener is really gone (~10 s), re-killing any straggler halfway through, and warn
// rather than block forever if something else owns the port (Apollo/Sunshine, a dev console).
for i in 0..40 {
let pids = web_listener_pids();
if pids.is_empty() {
return;
}
if i == 20 {
// Re-end the TASK, not just the listener: `web-run.cmd` now supervises bun and relaunches
// it on any exit, so killing bun alone would be undone by its own restart loop. Ending the
// task takes the launcher down with it, which is why the `/end` above comes first.
run_quiet("schtasks", &["/end", "/tn", WEB_TASK]);
for pid in pids {
run_quiet("taskkill", &["/PID", &pid, "/F"]);
}
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
eprintln!(
"warning: something still holds TCP 47992 - the web console may not start until it exits"
);
}
/// PIDs owning a LISTEN socket on :47992. Also the console's liveness probe (`start_web_task`).
+4 -1
View File
@@ -35,7 +35,10 @@ Environment=PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem
Environment=PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem
Environment=PUNKTFUNK_UI_SECURE=1
ExecStart=/usr/bin/punktfunk-web-server
Restart=on-failure
# `always`, not `on-failure`: a console that exits 0 has still stopped serving, and on-failure would
# leave it down. An explicit `systemctl --user stop` is still honoured (Restart= never fights that).
# Windows parity: web-run.cmd likewise relaunches bun on ANY exit.
Restart=always
RestartSec=2
[Install]
+65
View File
@@ -59,4 +59,69 @@ if not exist "%BUN%" (
echo [punktfunk-web] bundled bun runtime missing at "%BUN%".
exit /b 1
)
rem Supervise bun instead of exiting with it. The task registers RestartCount=10/RestartInterval=PT1M,
rem but Task Scheduler honours restart-on-failure only when the action CRASHES or fails to start -
rem never for a plain non-zero exit - so before this loop any bun exit parked the console until the
rem next boot or interactive logon. Seen on glass 2026-07-30 (0.22.3 upgrade): `web setup` started the
rem task one second after the upgraded host service came up, bun exited at once (Last Run Result
rem 0xFFFFFFFF), and the console stayed down for hours on a box whose host was streaming perfectly -
rem which reads to the operator as "the host is gone". A single `schtasks /run /tn PunktfunkWeb`
rem revived it, i.e. whatever blocked the start was transient. That is exactly what a retry is for.
rem
rem Restart indefinitely for a console that served for a while and then died, but refuse to spin
rem forever on an install that can never work: PFFAILS counts only CONSECUTIVE fast exits and any run
rem lasting >= PFGOODSEC resets it, so a genuine crash-loop still surfaces as a failed task after
rem ~1 min of trying (the boot + logon triggers remain the outer backstop). Same shape as the Linux
rem unit's Restart= + StartLimitIntervalSec=0.
rem
rem INVARIANT for anything that STOPS the console (the installer's StopBunRuntimes, the host's
rem `stop_web_console`): end the TASK - `schtasks /end /tn PunktfunkWeb` / `Stop-ScheduledTask` -
rem which takes this launcher down with it. Killing bun.exe ALONE is now undone by the loop below,
rem which will relaunch it and re-lock the file you were trying to replace.
set /a PFMAXFAILS=10
set /a PFGOODSEC=60
rem `timeout` needs a console this task does not have; `ping -n N` sleeps about N-1 seconds.
set /a PFDELAYPINGS=6
set /a PFFAILS=0
:pfrun
call :pfnow PFT0
"%BUN%" "%SERVER%"
set "PFRC=%ERRORLEVEL%"
call :pfnow PFT1
set /a PFUPSEC=PFT1-PFT0
if %PFUPSEC% LSS 0 set /a PFUPSEC=0
if %PFUPSEC% GEQ %PFGOODSEC% (set /a PFFAILS=0) else (set /a PFFAILS+=1)
echo [punktfunk-web] bun exited rc=%PFRC% after %PFUPSEC%s - consecutive fast exits %PFFAILS%/%PFMAXFAILS%
if %PFFAILS% GEQ %PFMAXFAILS% (
echo [punktfunk-web] giving up after %PFMAXFAILS% consecutive fast exits - check that nothing else holds TCP 47992.
exit /b 1
)
rem The payload disappearing is a deliberate stop, not a crash: an uninstall deletes it, and an
rem upgrade replaces it. Exit 0 rather than respawning into a half-written {app} - `web setup` starts
rem the task again at the end of an upgrade, and an uninstall wants us gone.
if not exist "%BUN%" (
echo [punktfunk-web] "%BUN%" is gone - an install or uninstall is in progress; exiting instead of respawning.
exit /b 0
)
if not exist "%SERVER%" (
echo [punktfunk-web] "%SERVER%" is gone - an install or uninstall is in progress; exiting instead of respawning.
exit /b 0
)
ping -n %PFDELAYPINGS% 127.0.0.1 >nul 2>&1
goto pfrun
rem Unix seconds, for the uptime measurement above. Deliberately NOT parsed out of %TIME%: that is
rem locale-formatted - 12-hour locales append " PM" and many others use ',' as the decimal separator
rem - which makes hand-parsing it a portability trap in a launcher that ships worldwide. This runs
rem only after bun has already exited, so the ~0.3 s cost is irrelevant. `-Command` with an inline
rem expression is unaffected by a Restricted ExecutionPolicy (that gates script FILES). Should it
rem ever fail, PFN is empty, `set /a` reads that as 0, and the run is counted as a fast exit - the
rem conservative direction.
:pfnow
setlocal EnableExtensions
set "PFN="
for /f "usebackq tokens=* delims=" %%s in (`powershell -NoProfile -Command "[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()"`) do set "PFN=%%s"
endlocal & set "%~1=%PFN%"
goto :eof