From 3bb30cb2f6bcff90b0569d396890c63f32b974f7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 30 Jul 2026 20:42:02 +0200 Subject: [PATCH] fix(windows/web-console): a bun exit no longer parks the console until the next sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/punktfunk-host/src/windows/install.rs | 26 +++++++- scripts/punktfunk-web.service | 5 +- scripts/windows/web-run.cmd | 65 ++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/crates/punktfunk-host/src/windows/install.rs b/crates/punktfunk-host/src/windows/install.rs index 68c5960e..e7ee0d84 100644 --- a/crates/punktfunk-host/src/windows/install.rs +++ b/crates/punktfunk-host/src/windows/install.rs @@ -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`). diff --git a/scripts/punktfunk-web.service b/scripts/punktfunk-web.service index 9843baac..2965a29c 100644 --- a/scripts/punktfunk-web.service +++ b/scripts/punktfunk-web.service @@ -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] diff --git a/scripts/windows/web-run.cmd b/scripts/windows/web-run.cmd index 93c5f8a8..02418c9b 100644 --- a/scripts/windows/web-run.cmd +++ b/scripts/windows/web-run.cmd @@ -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