diff --git a/apps/nescope/src/handlers.rs b/apps/nescope/src/handlers.rs index c75212df..7c5f6886 100644 --- a/apps/nescope/src/handlers.rs +++ b/apps/nescope/src/handlers.rs @@ -537,14 +537,24 @@ 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; + // 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; } - if let Some(h) = h { - geo.size.h = h as i32; + 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/input_ipc.rs b/apps/nescope/src/input_ipc.rs index a159a644..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,14 +20,39 @@ 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() } } +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) if e.kind() == io::ErrorKind::ConnectionReset => return Ok(true), + Err(e) => return Err(e), + } + } + } +} + impl AsFd for InputIpcSource { fn as_fd(&self) -> BorrowedFd<'_> { self.stream.as_fd() @@ -45,22 +74,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 +93,11 @@ impl EventSource for InputIpcSource { callback(payload, &mut ()); } + 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); + } Ok(PostAction::Continue) } @@ -88,3 +113,44 @@ 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(), + closed: Arc::new(AtomicBool::new(false)), + }, + 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]); + } +} diff --git a/apps/nescope/src/main.rs b/apps/nescope/src/main.rs index 425def56..ea72abe0 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(); @@ -389,11 +400,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 +491,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 +594,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 @@ -834,3 +868,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/nescope/src/state.rs b/apps/nescope/src/state.rs index 622dcd04..4915d636 100644 --- a/apps/nescope/src/state.rs +++ b/apps/nescope/src/state.rs @@ -91,6 +91,43 @@ 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) +} + +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, @@ -239,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 ───────────────────────────────────────────────────────── @@ -376,6 +415,7 @@ impl NescopeState { width, height, launch_size: (width, height), + clamp_runs: std::collections::HashMap::new(), fps, loop_handle, game_launched: false, @@ -1213,3 +1253,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)); + } +} 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(); 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"); + } } } }