fix(windows/web): start the console once its inputs exist, and verify it started
arch / build-publish (push) Successful in 17m1s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 58s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
ci / bench (push) Successful in 6m19s
deb / build-publish (push) Successful in 9m35s
docker / deploy-docs (push) Successful in 29s
apple / screenshots (push) Successful in 10m56s
deb / build-publish-host (push) Successful in 13m40s
android / android (push) Successful in 16m19s
windows-host / package (push) Successful in 17m13s
ci / rust (push) Successful in 19m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m18s

A fresh install left PunktfunkWeb registered but not running: `web setup` waited
only for the mgmt token before firing `schtasks /run`, while `web-run.cmd` also
requires the host identity cert — and the host writes the token during argument
parsing but `cert.pem` only after the pure-Rust RSA-2048 keygen inside `serve`.
The launcher lost that race, exited 1, and since the task carried no trigger but
boot (Task Scheduler does not reliably restart on a non-zero exit code) the
console stayed down until the next reboot, with the installer still reporting
"web console set up + started".

- `web setup` gates on cert.pem (written last) as well as the token, 90 s budget.
- After `schtasks /run`, poll for the :47992 listener and retry before giving up;
  warn honestly instead of claiming a start that did not happen.
- `web-run.cmd` (installed + dev) waits in-process for the token + cert (~5 min)
  rather than exiting 1 and hoping restart-on-failure retries.
- Register the task with a logon trigger alongside boot, falling back to the
  boot-only XML if a Task Scheduler build rejects it.
- Linux had the same defect: punktfunk-web.service's Restart=on-failure gave up
  permanently after systemd's default 5-starts-in-10 s limit. StartLimitIntervalSec=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-24 23:22:09 +02:00
co-authored by Claude Opus 5
parent 7781d09e26
commit 5c7a9407ff
5 changed files with 145 additions and 50 deletions
+107 -31
View File
@@ -431,16 +431,69 @@ fn web_setup(args: &[String]) -> Result<()> {
) {
eprintln!("warning: could not add the firewall rule for TCP 47992");
}
// 5. wait briefly for the host's mgmt token, then start (restart-on-failure picks it up otherwise)
for _ in 0..30 {
if token_path.exists() {
break;
// 5. Wait for EVERY file the launcher needs, then start the task and VERIFY it came up.
//
// `web-run.cmd` refuses to serve without the mgmt token AND the host identity cert, and the
// host writes them in that order: the token during argument parsing (`main::parse_serve`,
// milliseconds after `serve` starts), the cert only once `serve` reaches
// `gamestream::cert::ServerIdentity::load_or_create` - behind a pure-Rust RSA-2048 keygen
// whose prime search has a multi-second tail. Gating on the token alone therefore fired
// `schtasks /run` while that keygen was still running: the launcher exited 1, and since the
// task carries no trigger other than boot (and Task Scheduler's restart-on-failure does not
// reliably fire for a plain non-zero exit code), a freshly installed console stayed down
// until the next reboot. Gate on `cert.pem` - written LAST, after `key.pem` - so all three
// files are on disk before the first start.
let cert_path = data_dir.join("cert.pem");
if !wait_for_files(&[token_path.as_path(), cert_path.as_path()], 90) {
eprintln!(
"warning: the host service has not written {} + {} yet - not starting the console now; \
it will start at the next boot (or run: schtasks /run /tn {WEB_TASK})",
token_path.display(),
cert_path.display()
);
println!("web console set up (https://<host-ip>:47992)");
return Ok(());
}
if start_web_task() {
println!("web console set up + started (https://<host-ip>:47992)");
} else {
// Never claim "started" when it isn't: the launcher's own diagnostics go to a task with no
// console, so this warning is the only trace an operator gets at install time.
eprintln!(
"warning: the {WEB_TASK} task did not bring up a listener on :47992 - check its Last Run \
Result in Task Scheduler; the console will be retried at the next boot"
);
println!("web console set up (https://<host-ip>:47992)");
}
Ok(())
}
/// Poll until every path exists, or `secs` elapse. Returns whether they all showed up.
fn wait_for_files(paths: &[&Path], secs: u64) -> bool {
for _ in 0..secs {
if paths.iter().all(|p| p.exists()) {
return true;
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
paths.iter().all(|p| p.exists())
}
/// `schtasks /run` + verify: the command reports only that a start was *attempted*, so poll for the
/// console's own listener and retry a couple of times before giving up. Returns whether :47992 ended
/// up listening.
fn start_web_task() -> bool {
for _ in 0..3 {
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
println!("web console set up + started (https://<host-ip>:47992)");
Ok(())
// Bun binds the port a second or two after launch on a warm box; allow for a cold one.
for _ in 0..10 {
std::thread::sleep(std::time::Duration::from_secs(1));
if !web_listener_pids().is_empty() {
return true;
}
}
}
false
}
/// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback.
@@ -505,30 +558,57 @@ fn random_password() -> String {
/// ("LISTENING"/"ABHOEREN"/...) is never parsed.
fn stop_web_console() {
run_quiet("schtasks", &["/end", "/tn", WEB_TASK]);
for line in run_capture("netstat", &["-ano", "-p", "tcp"]).lines() {
let toks: Vec<&str> = line.split_whitespace().collect();
if toks.len() >= 5
&& toks[0].eq_ignore_ascii_case("tcp")
&& toks[1].ends_with(":47992")
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0")
{
let pid = toks[toks.len() - 1];
if !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()) {
run_quiet("taskkill", &["/PID", pid, "/F"]);
}
}
for pid in web_listener_pids() {
run_quiet("taskkill", &["/PID", &pid, "/F"]);
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
/// Register the boot/SYSTEM/restart-on-failure task via a generated Task Scheduler XML (`schtasks /xml`,
/// no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
/// PIDs owning a LISTEN socket on :47992. Also the console's liveness probe (`start_web_task`).
fn web_listener_pids() -> Vec<String> {
run_capture("netstat", &["-ano", "-p", "tcp"])
.lines()
.filter_map(|line| {
let toks: Vec<&str> = line.split_whitespace().collect();
let listening = toks.len() >= 5
&& toks[0].eq_ignore_ascii_case("tcp")
&& toks[1].ends_with(":47992")
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0");
let pid = *toks.last()?;
(listening && !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()))
.then(|| pid.to_string())
})
.collect()
}
/// Register the boot+logon/SYSTEM/restart-on-failure task via a generated Task Scheduler XML
/// (`schtasks /xml`, no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
///
/// Two triggers, because boot alone left too many holes: a console that lost the install-time start
/// (or a box where the host service was started only later) had to wait for a full reboot. Logon is
/// free insurance - `MultipleInstancesPolicy=IgnoreNew` makes a redundant start a no-op. If a
/// Task Scheduler build rejects the two-trigger XML we fall back to the boot-only form rather than
/// fail registration outright (no task at all is strictly worse than a boot-only task).
fn register_web_task(cmd: &Path) -> Result<()> {
let cmd_xml = xml_escape(&cmd.to_string_lossy());
let boot = "<BootTrigger><Enabled>true</Enabled></BootTrigger>";
let boot_and_logon = "<BootTrigger><Enabled>true</Enabled></BootTrigger>\
<LogonTrigger><Enabled>true</Enabled></LogonTrigger>";
if try_register_web_task(&cmd_xml, boot_and_logon) || try_register_web_task(&cmd_xml, boot) {
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
Ok(())
} else {
bail!("schtasks /create {WEB_TASK} failed")
}
}
/// One `schtasks /create /xml` attempt with the given `<Triggers>` body. Returns whether it took.
fn try_register_web_task(cmd_xml: &str, triggers: &str) -> bool {
let xml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
<RegistrationInfo><Description>punktfunk web management console (Nitro SSR on bun, :47992)</Description></RegistrationInfo>\n\
<Triggers><BootTrigger><Enabled>true</Enabled></BootTrigger></Triggers>\n\
<Triggers>{triggers}</Triggers>\n\
<Principals><Principal id=\"Author\"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>\n\
<Settings>\n\
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
@@ -538,12 +618,13 @@ fn register_web_task(cmd: &Path) -> Result<()> {
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n\
<RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>\n\
</Settings>\n\
<Actions Context=\"Author\"><Exec><Command>{}</Command></Exec></Actions>\n\
</Task>",
xml_escape(&cmd.to_string_lossy())
<Actions Context=\"Author\"><Exec><Command>{cmd_xml}</Command></Exec></Actions>\n\
</Task>"
);
let xml_path = std::env::temp_dir().join("punktfunk-web-task.xml");
write_utf16le_bom(&xml_path, &xml)?;
if write_utf16le_bom(&xml_path, &xml).is_err() {
return false;
}
let ok = run_quiet(
"schtasks",
&[
@@ -556,12 +637,7 @@ fn register_web_task(cmd: &Path) -> Result<()> {
],
);
let _ = std::fs::remove_file(&xml_path);
if ok {
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
Ok(())
} else {
bail!("schtasks /create {WEB_TASK} failed")
}
ok
}
fn write_utf16le_bom(path: &Path, s: &str) -> Result<()> {
+1 -1
View File
@@ -66,7 +66,7 @@ the Windows specifics follow.
The installer also sets up the **web management console** (status, paired devices, the PIN pairing
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on
**`https://<this-PC>:47992`**, starting at boot.
**`https://<this-PC>:47992`**, starting at boot and at sign-in.
#### Console login password
+5
View File
@@ -12,6 +12,11 @@ Description=punktfunk management web console
# web-init generates the login password; the host writes the mgmt token. Order after both.
After=punktfunk-web-init.service punktfunk-host.service
Wants=punktfunk-web-init.service
# Retry indefinitely while the host is still writing the mgmt token + identity cert. Without this,
# systemd's default rate limit (5 starts / 10 s) plus RestartSec=2 gives up permanently after ~10 s
# - so a console enabled before the host's first run stayed dead until someone restarted it by hand
# (the same defect the Windows PunktfunkWeb task had).
StartLimitIntervalSec=0
[Service]
Type=simple
+18 -9
View File
@@ -16,17 +16,26 @@ set "CERTFILE=%PFDATA%\cert.pem"
set "KEYFILE=%PFDATA%\key.pem"
rem The host's `serve` writes the mgmt token + its identity cert/key on first run. Until they exist
rem we have no credential and no TLS material, so fail and let the task's restart-on-failure retry
rem (mirrors the Linux unit's Restart=on-failure waiting for the host to create them) rather than
rem silently downgrading to plain HTTP.
if not exist "%TOKENFILE%" (
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
exit /b 1
)
if not exist "%CERTFILE%" (
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
rem we have no credential and no TLS material, so WAIT rather than silently downgrading to plain HTTP.
rem
rem Wait in-process instead of exiting 1 and hoping the task's restart-on-failure retries: Task
rem Scheduler does not reliably restart on a plain non-zero exit code, so a console that started
rem before the host finished writing those files (the token lands at argument parse, the cert only
rem after the RSA-2048 keygen) used to stay down until the next reboot. ~5 min at 2 s, then give up
rem so a genuinely broken install still surfaces as a failed task rather than one that runs forever.
rem `timeout` needs a console this task does not have, so `ping -n 3` is the 2-second sleep.
set /a PFWAITS=0
:pfwait
if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready
if %PFWAITS% GEQ 150 (
echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host service running?
exit /b 1
)
if %PFWAITS%==0 echo [punktfunk-web] waiting for the host service to write the mgmt token + identity cert...
set /a PFWAITS+=1
ping -n 3 127.0.0.1 >nul 2>&1
goto pfwait
:pfready
rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and
rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment.
+13 -8
View File
@@ -18,16 +18,21 @@ set "CERTFILE=%PFDATA%\cert.pem"
set "KEYFILE=%PFDATA%\key.pem"
rem The host's `serve` writes the mgmt token + identity cert on first run. Until they exist the proxy
rem has no credential and no TLS material, so fail and let restart-on-failure retry (mirrors the
rem installed launcher / Linux unit) rather than silently serving plain HTTP.
if not exist "%TOKENFILE%" (
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
exit /b 1
)
if not exist "%CERTFILE%" (
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
rem has no credential and no TLS material, so WAIT for them (mirrors the installed launcher) rather
rem than silently serving plain HTTP - see scripts\windows\web-run.cmd for why waiting here beats
rem exiting 1 and relying on the task's restart-on-failure. ~5 min at 2 s, then give up.
set /a PFWAITS=0
:pfwait
if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready
if %PFWAITS% GEQ 150 (
echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host running?
exit /b 1
)
if %PFWAITS%==0 echo [punktfunk-web] waiting for the host to write the mgmt token + identity cert...
set /a PFWAITS+=1
ping -n 3 127.0.0.1 >nul 2>&1
goto pfwait
:pfready
rem Both files are single KEY=VALUE lines: PUNKTFUNK_MGMT_TOKEN=... and PUNKTFUNK_UI_PASSWORD=... .
rem Split on the first '=' and import each into the environment.