From 7059c4812c6161012f72b6ef4bf3622256870f11 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 18 Oct 2026 15:17:41 +0300 Subject: [PATCH 1/6] fix(nescope): a window cannot ask for more than the output has --- apps/nescope/src/handlers.rs | 19 +++++++----- apps/nescope/src/state.rs | 59 ++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/apps/nescope/src/handlers.rs b/apps/nescope/src/handlers.rs index c75212df..24173aa2 100644 --- a/apps/nescope/src/handlers.rs +++ b/apps/nescope/src/handlers.rs @@ -537,14 +537,19 @@ impl XwmHandler for CalloopData { h: Option, _reorder: Option, ) { - // Honor size requests within reason but always keep position at (0,0). - let mut geo = window.geometry(); - if let Some(w) = w { - geo.size.w = w as i32; - } - if let Some(h) = h { - geo.size.h = h as i32; + // Honor size requests, but never past the output: a window larger + // than it is cut off, and the pointer cannot reach the cut part. The + // output keeps following the client's window instead. + if window.is_override_redirect() { + return; } + let cur = window.geometry(); + let (x, y, w, h) = crate::state::clamp_to_output( + (cur.loc.x, cur.loc.y, cur.size.w, cur.size.h), + (w, h), + (self.state.width as i32, self.state.height as i32), + ); + let geo = Rectangle::new((x, y).into(), (w, h).into()); let _ = window.configure(geo); } diff --git a/apps/nescope/src/state.rs b/apps/nescope/src/state.rs index 622dcd04..31c80276 100644 --- a/apps/nescope/src/state.rs +++ b/apps/nescope/src/state.rs @@ -91,6 +91,25 @@ impl WinKey { } } +/// A window's geometry after it asked for `want` (either side may be absent), +/// held inside an `output` of its own size: no larger, and moved back in if +/// the request would put part of it outside. Pure so it can be tested. +pub fn clamp_to_output( + cur: (i32, i32, i32, i32), + want: (Option, Option), + output: (i32, i32), +) -> (i32, i32, i32, i32) { + let side = |req: Option, cur: i32, max: i32| { + let v = req.map_or(cur, |v| v.min(i32::MAX as u32) as i32); + v.clamp(1, max.max(1)) + }; + let w = side(want.0, cur.2, output.0); + let h = side(want.1, cur.3, output.1); + let x = cur.0.clamp(0, (output.0 - w).max(0)); + let y = cur.1.clamp(0, (output.1 - h).max(0)); + (x, y, w, h) +} + pub struct CalloopData { pub state: NescopeState, pub display: Display, @@ -1213,3 +1232,43 @@ mod fit_output_tests { assert_eq!(fit_output((10, 10), (1920, 1080)), (320, 320)); } } + +#[cfg(test)] +mod clamp_to_output_tests { + use super::clamp_to_output; + + const OUT: (i32, i32) = (1280, 720); + + #[test] + fn a_window_that_fits_is_left_alone() { + assert_eq!(clamp_to_output((0, 0, 800, 600), (Some(1000), Some(700)), OUT), (0, 0, 1000, 700)); + } + + #[test] + fn a_launch_size_game_is_held_to_the_shrunk_output() { + assert_eq!(clamp_to_output((0, 0, 1280, 720), (Some(1920), Some(1080)), OUT), (0, 0, 1280, 720)); + } + + #[test] + fn a_width_only_request_leaves_the_height() { + assert_eq!(clamp_to_output((0, 0, 1280, 500), (Some(1920), None), OUT), (0, 0, 1280, 500)); + } + + #[test] + fn a_height_only_request_leaves_the_width() { + assert_eq!(clamp_to_output((0, 0, 640, 720), (None, Some(2000)), OUT), (0, 0, 640, 720)); + } + + #[test] + fn a_window_pushed_past_the_edge_is_moved_back() { + assert_eq!(clamp_to_output((900, 500, 400, 300), (None, None), OUT), (880, 420, 400, 300)); + } + + #[test] + fn each_window_is_clamped_on_its_own() { + let a = clamp_to_output((0, 0, 1920, 1080), (None, None), OUT); + let b = clamp_to_output((100, 100, 300, 200), (Some(5000), None), OUT); + assert_eq!(a, (0, 0, 1280, 720)); + assert_eq!(b, (0, 100, 1280, 200)); + } +} From 4d44e7e59d8fc5fa3ab089c33e38ce0bc9c99cbb Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 18 Oct 2026 15:19:23 +0300 Subject: [PATCH 2/6] feat(nescope): log clamped configure requests and warn on a ping-pong --- apps/nescope/src/handlers.rs | 5 +++++ apps/nescope/src/state.rs | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/apps/nescope/src/handlers.rs b/apps/nescope/src/handlers.rs index 24173aa2..7c5f6886 100644 --- a/apps/nescope/src/handlers.rs +++ b/apps/nescope/src/handlers.rs @@ -543,12 +543,17 @@ impl XwmHandler for CalloopData { if window.is_override_redirect() { return; } + let (req_w, req_h) = (w, h); let cur = window.geometry(); let (x, y, w, h) = crate::state::clamp_to_output( (cur.loc.x, cur.loc.y, cur.size.w, cur.size.h), (w, h), (self.state.width as i32, self.state.height as i32), ); + let asked = (req_w, req_h); + if req_w.is_some_and(|v| v as i32 != w) || req_h.is_some_and(|v| v as i32 != h) { + self.state.note_clamp(window.window_id(), asked, (w, h)); + } let geo = Rectangle::new((x, y).into(), (w, h).into()); let _ = window.configure(geo); } diff --git a/apps/nescope/src/state.rs b/apps/nescope/src/state.rs index 31c80276..4915d636 100644 --- a/apps/nescope/src/state.rs +++ b/apps/nescope/src/state.rs @@ -110,6 +110,24 @@ pub fn clamp_to_output( (x, y, w, h) } +impl NescopeState { + /// Say a request was clamped. A window that keeps asking is fighting the + /// configure: warn once when the run passes 50 inside 5 seconds. + pub fn note_clamp(&mut self, id: u32, asked: (Option, Option), got: (i32, i32)) { + let now = std::time::Instant::now(); + let run = self.clamp_runs.entry(id).or_insert((now, 0)); + if now.duration_since(run.0) > std::time::Duration::from_secs(5) { + *run = (now, 0); + } + run.1 += 1; + let n = run.1; + tracing::debug!(window = id, ?asked, clamped = ?got, repeats = n, "configure_request clamped"); + if n == 50 { + tracing::warn!(window = id, ?asked, clamped = ?got, "window keeps asking past the output: configure ping-pong?"); + } + } +} + pub struct CalloopData { pub state: NescopeState, pub display: Display, @@ -258,6 +276,8 @@ pub struct NescopeState { /// counted for, ref(d-0036): a client's window can shrink the output /// below it, never grow it past. pub launch_size: (u32, u32), + /// Per X11 window: when its run of clamped requests began and how many. + pub clamp_runs: std::collections::HashMap, pub fps: u32, // ── Lifecycle ───────────────────────────────────────────────────────── @@ -395,6 +415,7 @@ impl NescopeState { width, height, launch_size: (width, height), + clamp_runs: std::collections::HashMap::new(), fps, loop_handle, game_launched: false, From 6c2ae159418c34734f5ba756919e4978bfbddd84 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 18 Oct 2026 15:43:19 +0300 Subject: [PATCH 3/6] fix(nescope): a hub that closes the input socket is not a panic The input source returned UnixStream EOF as an error, which calloop passes out of run() into the expect at the end of main. At box shutdown init signals every process at once, so neshub can go before nescope reads its SIGTERM: nescope panicked and skipped its cleanup. Deliver what was read, remove the source and warn instead. --- apps/nescope/src/input_ipc.rs | 83 ++++++++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 16 deletions(-) diff --git a/apps/nescope/src/input_ipc.rs b/apps/nescope/src/input_ipc.rs index a159a644..b92bbdbe 100644 --- a/apps/nescope/src/input_ipc.rs +++ b/apps/nescope/src/input_ipc.rs @@ -24,6 +24,22 @@ impl InputIpcSource { } } +impl InputIpcSource { + /// Read everything available into `buf`. `true` if the peer has closed. + fn fill(&mut self) -> io::Result { + let mut tmp = [0u8; 4096]; + loop { + match self.stream.read(&mut tmp) { + Ok(0) => return Ok(true), + Ok(n) => self.buf.extend_from_slice(&tmp[..n]), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(false), + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + } +} + impl AsFd for InputIpcSource { fn as_fd(&self) -> BorrowedFd<'_> { self.stream.as_fd() @@ -45,22 +61,13 @@ impl EventSource for InputIpcSource { where F: FnMut(Self::Event, &mut Self::Metadata), { - let mut tmp = [0u8; 4096]; - loop { - match self.stream.read(&mut tmp) { - Ok(0) => { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "IPC socket closed", - )); - } - Ok(n) => { - self.buf.extend_from_slice(&tmp[..n]); - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, - Err(e) => return Err(e), - } - } + // The hub closing its end is not an error of this source: it is what + // the hub does when the box is going down (init signals every process + // at once, so the hub can be gone before our own SIGTERM is read), and + // an `Err` here is returned out of the event loop and panics `main`, + // skipping the cleanup after it. Frames already read are still + // delivered, then the source removes itself and the loop carries on. + let closed = self.fill()?; while self.buf.len() >= 2 { let payload_len = u16::from_le_bytes([self.buf[0], self.buf[1]]) as usize; @@ -73,6 +80,10 @@ impl EventSource for InputIpcSource { callback(payload, &mut ()); } + if closed { + tracing::warn!("input IPC socket closed by the hub; no more input from it"); + return Ok(PostAction::Remove); + } Ok(PostAction::Continue) } @@ -88,3 +99,43 @@ impl EventSource for InputIpcSource { poll.unregister(self.as_fd()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn pair() -> (InputIpcSource, UnixStream) { + let (a, b) = UnixStream::pair().unwrap(); + a.set_nonblocking(true).unwrap(); + ( + InputIpcSource { + stream: a, + buf: Vec::new(), + }, + b, + ) + } + + #[test] + fn an_open_peer_with_nothing_to_say_is_not_closed() { + let (mut source, _peer) = pair(); + assert!(!source.fill().unwrap()); + } + + #[test] + fn a_closed_peer_is_closed_not_an_error() { + let (mut source, peer) = pair(); + drop(peer); + assert!(source.fill().unwrap()); + } + + #[test] + fn what_the_peer_sent_before_closing_is_kept() { + let (mut source, mut peer) = pair(); + peer.write_all(&[1, 0, 7]).unwrap(); + drop(peer); + assert!(source.fill().unwrap()); + assert_eq!(source.buf, vec![1, 0, 7]); + } +} From 1bbf15fdcad3b56db216bd04cd49f7385978552a Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 18 Oct 2026 15:46:14 +0300 Subject: [PATCH 4/6] fix(nescope): end nescope when the hub stays gone after its socket closes The input source now removes itself on EOF or reset and raises a flag; the main loop exits 2 s later unless a signal already ended it, so a hub crash mid-run still ends the compositor (and the box via CompositorDied) instead of holding a GPU with no input. Also corrects the comment that promised a reconnect that does not exist. --- apps/nescope/src/input_ipc.rs | 15 +++++++++++++++ apps/nescope/src/main.rs | 25 ++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/nescope/src/input_ipc.rs b/apps/nescope/src/input_ipc.rs index b92bbdbe..861590a3 100644 --- a/apps/nescope/src/input_ipc.rs +++ b/apps/nescope/src/input_ipc.rs @@ -1,12 +1,16 @@ use std::io::{self, Read}; use std::os::unix::io::{AsFd, BorrowedFd}; use std::os::unix::net::UnixStream; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use calloop::{EventSource, Interest, Mode, Poll, PostAction, Readiness, Token, TokenFactory}; pub struct InputIpcSource { stream: UnixStream, buf: Vec, + /// Set once the hub has closed its end, for the main loop to act on. + closed: Arc, } impl InputIpcSource { @@ -16,9 +20,17 @@ impl InputIpcSource { Ok(Self { stream, buf: Vec::new(), + closed: Arc::new(AtomicBool::new(false)), }) } + /// True once the hub has closed the socket. The main loop gives that a + /// grace period and then ends nescope: a compositor with no input holds + /// the GPU for nothing. + pub fn closed_flag(&self) -> Arc { + self.closed.clone() + } + pub fn try_clone(&self) -> io::Result { self.stream.try_clone() } @@ -34,6 +46,7 @@ impl InputIpcSource { Ok(n) => self.buf.extend_from_slice(&tmp[..n]), Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(false), Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) if e.kind() == io::ErrorKind::ConnectionReset => return Ok(true), Err(e) => return Err(e), } } @@ -81,6 +94,7 @@ impl EventSource for InputIpcSource { } if closed { + self.closed.store(true, Ordering::Relaxed); tracing::warn!("input IPC socket closed by the hub; no more input from it"); return Ok(PostAction::Remove); } @@ -112,6 +126,7 @@ mod tests { InputIpcSource { stream: a, buf: Vec::new(), + closed: Arc::new(AtomicBool::new(false)), }, b, ) diff --git a/apps/nescope/src/main.rs b/apps/nescope/src/main.rs index 425def56..eef9469f 100644 --- a/apps/nescope/src/main.rs +++ b/apps/nescope/src/main.rs @@ -389,11 +389,15 @@ fn main() { // ── Input IPC source ───────────────────────────────────────────────── // Connect to the neshub input socket and feed events into the - // compositor seat. Reconnection is handled in the idle callback. + // compositor seat. There is no reconnection: when the hub closes the socket + // the source removes itself, and the loop below ends nescope after a short + // grace (a signal that arrives first ends it sooner). let ipc_path = args.input_ipc.clone(); + let mut hub_closed_flag: Option> = None; match input_ipc::InputIpcSource::connect(&ipc_path) { Ok(source) => { tracing::info!("Connected to input IPC socket: {ipc_path}"); + hub_closed_flag = Some(source.closed_flag()); match source.try_clone() { Ok(write_stream) => { state.ipc_write = Some(write_stream); @@ -476,6 +480,7 @@ fn main() { // never arrives there is nothing to wait for and no game to run. let startup = std::time::Instant::now(); let mut xwayland_timed_out = false; + let mut hub_closed_at: Option = None; event_loop .run(Some(Duration::from_secs(1)), &mut data, move |data| { @@ -578,6 +583,24 @@ fn main() { } } + // ── Hub gone ────────────────────────────────────────────── + // neshub closing the input socket mid-run leaves a compositor + // nobody can drive. The grace lets a shutdown that is already + // under way (SIGTERM stops the loop itself) finish its own way; + // otherwise nescope ends, and nesinit ends the box on seeing it. + if hub_closed_flag + .as_ref() + .is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed)) + { + let since = hub_closed_at.get_or_insert_with(std::time::Instant::now); + if since.elapsed() > Duration::from_secs(2) { + tracing::error!("input hub gone for 2 s with no shutdown — exiting"); + kill_all_children(); + data.loop_signal.stop(); + return; + } + } + // ── Poll primary process ────────────────────────────────── // The launcher (e.g. Steam's shell wrapper) may exit quickly // while the real game client stays alive as a reparented From a43cb9fc7579ad21fff0974571df4c8525dd8057 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 18 Oct 2026 15:50:08 +0300 Subject: [PATCH 5/6] fix(nescope): default log filter to info when RUST_LOG is unset EnvFilter::from_default_env defaults to ERROR, so nescope's info and warn lines never reached the host journal. Fall back to info like neshub, nesinit and nesgamepad; align neswire, which had the same default. --- apps/nescope/src/main.rs | 50 ++++++++++++++++++++++++++++++++++++++-- apps/neswire/src/main.rs | 2 +- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/nescope/src/main.rs b/apps/nescope/src/main.rs index 425def56..f90c7aff 100644 --- a/apps/nescope/src/main.rs +++ b/apps/nescope/src/main.rs @@ -30,7 +30,7 @@ //! | `PROTON_ENABLE_WAYLAND` | Set to `1` always, so Proton renders through Wayland | //! | `XCURSOR_THEME` | XCursor theme name for the software cursor | //! | `XCURSOR_SIZE` | XCursor size in pixels | -//! | `RUST_LOG` | Tracing filter (e.g. `nescope=debug`) | +//! | `RUST_LOG` | Tracing filter, default `info` (e.g. `nescope=debug`) | //! //! # Ctrl+C / shutdown //! @@ -193,9 +193,20 @@ struct Args { // Entry point // --------------------------------------------------------------------------- +/// Filter from `RUST_LOG`, or `info` when it is unset or unparsable. +/// +/// `EnvFilter::from_default_env` alone defaults to ERROR, which hid every +/// info/warn line from the host journal (nesinit only forwards `RUST_LOG` if +/// it has it itself). Same fallback as neshub, nesinit and nesgamepad. +fn log_filter(rust_log: Option<&str>) -> tracing_subscriber::EnvFilter { + rust_log + .and_then(|v| tracing_subscriber::EnvFilter::try_new(v).ok()) + .unwrap_or_else(|| "info".into()) +} + fn main() { tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_env_filter(log_filter(std::env::var("RUST_LOG").ok().as_deref())) .init(); let args = Args::parse(); @@ -834,3 +845,38 @@ fn kill_all_children_sigkill() { } } } + +#[cfg(test)] +mod log_filter_tests { + use super::log_filter; + use tracing::Level; + use tracing_subscriber::filter::LevelFilter; + + #[test] + fn unset_defaults_to_info_so_warn_is_visible() { + let f = log_filter(None); + assert_eq!(f.max_level_hint(), Some(LevelFilter::INFO)); + assert!(Level::WARN <= Level::INFO); + } + + #[test] + fn debug_stays_out_of_the_default() { + assert_ne!(log_filter(None).max_level_hint(), Some(LevelFilter::DEBUG)); + } + + #[test] + fn explicit_rust_log_wins() { + assert_eq!( + log_filter(Some("debug")).max_level_hint(), + Some(LevelFilter::DEBUG) + ); + } + + #[test] + fn garbage_falls_back_to_info() { + assert_eq!( + log_filter(Some("=== not a filter ===")).max_level_hint(), + Some(LevelFilter::INFO) + ); + } +} diff --git a/apps/neswire/src/main.rs b/apps/neswire/src/main.rs index 86699804..6cd1022c 100644 --- a/apps/neswire/src/main.rs +++ b/apps/neswire/src/main.rs @@ -28,7 +28,7 @@ struct Args { fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .init(); let args = Args::parse(); From af3f8f34398de2eaeb03a695d7584eeb0c58ccde Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 18 Oct 2026 15:53:08 +0300 Subject: [PATCH 6/6] fix(neswire): log dropped audio frames once per burst, not once per frame A stalled encoder drops about fifty frames a second, and with warn now visible by default each drop would be its own journal line. Count them and log the first and every 250th. --- apps/neswire/src/sink.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/neswire/src/sink.rs b/apps/neswire/src/sink.rs index e9cd1b9c..69483110 100644 --- a/apps/neswire/src/sink.rs +++ b/apps/neswire/src/sink.rs @@ -83,6 +83,9 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender) /// Where the accumulator ends when the graph gives no position: a count /// of the frames received, so packets still carry a continuous timeline. counted_end: u64, + /// Frames dropped because the encoder was behind. A stalled encoder + /// drops ~50 a second, so only the first and every 250th are logged. + dropped: u64, } let state = State { @@ -93,6 +96,7 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender) sample_rate, next_ticks: None, counted_end: 0, + dropped: 0, }; let _listener = stream @@ -174,7 +178,10 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender) }; if state.tx.try_send(packet).is_err() { - tracing::warn!("encoder falling behind, dropping frame"); + state.dropped += 1; + if state.dropped % 250 == 1 { + tracing::warn!(dropped = state.dropped, "encoder falling behind, dropping frames"); + } } } }