From 94f0207cbdbdee73b275bce17f39380dcc4ad920 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 27 Jul 2026 08:44:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(windows/client):=20logs=20persist=20?= =?UTF-8?q?=E2=80=94=20%LOCALAPPDATA%\punktfunk\logs\client.log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell is a windows_subsystem binary and spawns punktfunk-session with CREATE_NO_WINDOW + inherited stderr: a normal GUI/MSIX launch has no console, so every log line — the shell's and the session's whole receive/decode/present forensic trail — evaporated exactly when a user hit something worth reporting. The 2026-07 PyroWave latency-sawtooth report had to be triaged from host logs alone; the deciding evidence (clock re-sync vs backlog-flush lines) lives client-side. - New logfile module: %LOCALAPPDATA%\punktfunk\logs\client.log, the host's rotation convention (10 MB cap -> .old at next start, one generation), best-effort (no dir -> plain stderr, never a startup failure). - The tracing subscriber tees to stderr AND the file (ANSI off — the file is what users send); startup logs the path. - The spawned session's stderr is piped through the same tee, line-buffered — dev-terminal runs keep their interleaved output, GUI runs finally keep anything at all. - The --console path and the punktfunk-console.exe MSIX shim forward the same way (a couch launch has no console either). Co-Authored-By: Claude Fable 5 --- clients/windows/src/bin/punktfunk-console.rs | 18 +++- clients/windows/src/logfile.rs | 98 ++++++++++++++++++++ clients/windows/src/main.rs | 22 ++++- clients/windows/src/spawn.rs | 8 +- 4 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 clients/windows/src/logfile.rs diff --git a/clients/windows/src/bin/punktfunk-console.rs b/clients/windows/src/bin/punktfunk-console.rs index 06538880..3b4c2a87 100644 --- a/clients/windows/src/bin/punktfunk-console.rs +++ b/clients/windows/src/bin/punktfunk-console.rs @@ -13,8 +13,17 @@ // console behind the couch UI looks like a crash. #![cfg_attr(windows, windows_subsystem = "windows")] +// The shared client log sink (std-only): a couch launch has no console, so the session's +// stderr would otherwise evaporate — same reasoning as the shell's tee. This shim only +// initializes + forwards; the module's subscriber-side surface stays unused here. +#[cfg(windows)] +#[path = "../logfile.rs"] +#[allow(dead_code)] +mod logfile; + #[cfg(windows)] fn main() { + logfile::init(); // The session binary ships beside us in the package; fall back to PATH for a dev run. let session = std::env::current_exe() .ok() @@ -27,7 +36,14 @@ fn main() { if !std::env::args().any(|a| a == "--windowed") { cmd.arg("--fullscreen"); } - match cmd.status() { + cmd.stderr(std::process::Stdio::piped()); + let run = cmd.spawn().and_then(|mut child| { + if let Some(stderr) = child.stderr.take() { + logfile::forward_child_stderr(stderr); + } + child.wait() + }); + match run { Ok(st) => std::process::exit(st.code().unwrap_or(0)), Err(_) => std::process::exit(1), } diff --git a/clients/windows/src/logfile.rs b/clients/windows/src/logfile.rs new file mode 100644 index 00000000..bfc66135 --- /dev/null +++ b/clients/windows/src/logfile.rs @@ -0,0 +1,98 @@ +//! Persistent client log file: `%LOCALAPPDATA%\punktfunk\logs\client.log`. +//! +//! The shell is a `windows_subsystem` binary and spawns `punktfunk-session` with +//! `CREATE_NO_WINDOW` — a normal GUI/MSIX launch has NO console, so before this module every +//! log line (the shell's and, worse, the session's whole receive/decode/present forensic +//! trail) evaporated exactly when a user hit a problem worth reporting. The 2026-07 PyroWave +//! latency-sawtooth field report had to be triaged from host logs alone because the client +//! side had nowhere to land. +//! +//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over +//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is +//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure. + +use std::fs::{File, OpenOptions}; +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; + +/// Rotate at the next start once the file exceeds this (the host's cap). +const ROTATE_BYTES: u64 = 10 * 1024 * 1024; + +static SINK: OnceLock>>> = OnceLock::new(); + +fn log_dir() -> Option { + Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs")) +} + +/// The log file's path, for the "logs land here" startup line (and any future UI affordance). +pub(crate) fn path() -> Option { + Some(log_dir()?.join("client.log")) +} + +/// Open (rotating first) and cache the sink. Called once at startup, before the tracing +/// subscriber installs; every later [`tee`] shares the handle. +pub(crate) fn init() { + SINK.get_or_init(|| { + let dir = log_dir()?; + std::fs::create_dir_all(&dir).ok()?; + let path = dir.join("client.log"); + if std::fs::metadata(&path).is_ok_and(|m| m.len() > ROTATE_BYTES) { + let old = dir.join("client.log.old"); + // Windows `rename` refuses an existing destination — drop the old generation first. + let _ = std::fs::remove_file(&old); + let _ = std::fs::rename(&path, &old); + } + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .ok()?; + Some(Arc::new(Mutex::new(file))) + }); +} + +/// A writer that duplicates onto stderr (dev runs from a terminal keep their interleaved +/// output) and the log file (GUI runs finally keep anything at all). The tracing subscriber's +/// `with_writer` factory and the session-stderr forwarder both use it. +pub(crate) struct Tee; + +/// `with_writer` factory (`fn() -> Tee` satisfies `MakeWriter`). +pub(crate) fn tee() -> Tee { + Tee +} + +impl Write for Tee { + fn write(&mut self, buf: &[u8]) -> io::Result { + let _ = io::stderr().write_all(buf); + if let Some(Some(f)) = SINK.get() { + let _ = f.lock().unwrap().write_all(buf); + } + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + let _ = io::stderr().flush(); + if let Some(Some(f)) = SINK.get() { + let _ = f.lock().unwrap().flush(); + } + Ok(()) + } +} + +/// Forward a spawned child's stderr into the [`Tee`], line-buffered so its lines never +/// interleave mid-line with the shell's own. Returns immediately; the thread dies with the +/// pipe (child exit). +pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) { + let _ = std::thread::Builder::new() + .name("punktfunk-session-log".into()) + .spawn(move || { + let mut reader = io::BufReader::new(stderr); + let mut line = String::new(); + let mut tee = Tee; + while matches!(reader.read_line(&mut line), Ok(n) if n > 0) { + let _ = tee.write_all(line.as_bytes()); + line.clear(); + } + }); +} diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs index aa256880..8fb9b995 100644 --- a/clients/windows/src/main.rs +++ b/clients/windows/src/main.rs @@ -26,6 +26,8 @@ mod discovery; #[cfg(windows)] mod gpu; #[cfg(windows)] +mod logfile; +#[cfg(windows)] mod probe; #[cfg(windows)] mod shell_window; @@ -49,11 +51,20 @@ fn main() { } set_app_user_model_id(); + // Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]): + // a GUI/MSIX launch has no console, so without the file the client side of any field report + // simply doesn't exist. ANSI off — the file is what users send, keep it grep-clean. + logfile::init(); tracing_subscriber::fmt() + .with_ansi(false) + .with_writer(logfile::tee) .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), ) .init(); + if let Some(p) = logfile::path() { + tracing::info!(path = %p.display(), "client log file (rotated at 10 MB, one .old kept)"); + } let args: Vec = std::env::args().collect(); let flag = |name: &str| args.iter().any(|a| a == name); @@ -88,7 +99,16 @@ fn main() { if !flag("--windowed") { cmd.arg("--fullscreen"); } - match cmd.status() { + // Spawn (not `status()`) so the session's stderr rides the log tee — a couch launch + // (Start-menu tile, Steam shortcut) has no console to inherit either. + cmd.stderr(std::process::Stdio::piped()); + let run = cmd.spawn().and_then(|mut child| { + if let Some(stderr) = child.stderr.take() { + logfile::forward_child_stderr(stderr); + } + child.wait() + }); + match run { Ok(st) => std::process::exit(st.code().unwrap_or(0)), Err(e) => { eprintln!("could not start the console UI: {e}"); diff --git a/clients/windows/src/spawn.rs b/clients/windows/src/spawn.rs index a300bf29..2357227b 100644 --- a/clients/windows/src/spawn.rs +++ b/clients/windows/src/spawn.rs @@ -169,13 +169,19 @@ fn spawn_with( cmd.stdin(Stdio::null()) .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs) + // Piped through the log tee: dev-terminal runs keep the interleaved stderr they always + // had, and GUI runs — which have no console — finally keep the session's whole + // receive/decode/present log in the client log file. + .stderr(Stdio::piped()) .creation_flags(CREATE_NO_WINDOW); let mut child = cmd .spawn() .map_err(|e| format!("couldn't start punktfunk-session: {e}"))?; tracing::info!(host = %host_label, "session binary spawned"); + if let Some(stderr) = child.stderr.take() { + crate::logfile::forward_child_stderr(stderr); + } let stdout = child.stdout.take().expect("piped stdout"); // Park the child where the kill handle (and the reader, for the final reap) reach it. *slot.0.lock().unwrap() = Some(child);