release: nescope resize clamp, clean exit, info logging (#442)

dev → prod.

- fix(nescope): a window can't ask for more than the output has, and
clamped requests are logged (#438)
- fix(nescope): a hub that closes the input socket is no longer a panic;
nescope ends if the hub stays gone (#440)
- fix(nescope, neswire): default log filter is info; dropped audio
frames log once per burst (#441)
This commit is contained in:
Wanjohi
2026-10-18 13:15:41 +00:00
committed by GitHub
6 changed files with 259 additions and 27 deletions
+16 -6
View File
@@ -537,14 +537,24 @@ impl XwmHandler for CalloopData {
h: Option<u32>,
_reorder: Option<Reorder>,
) {
// 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);
}
+82 -16
View File
@@ -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<u8>,
/// Set once the hub has closed its end, for the main loop to act on.
closed: Arc<AtomicBool>,
}
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<AtomicBool> {
self.closed.clone()
}
pub fn try_clone(&self) -> io::Result<UnixStream> {
self.stream.try_clone()
}
}
impl InputIpcSource {
/// Read everything available into `buf`. `true` if the peer has closed.
fn fill(&mut self) -> io::Result<bool> {
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]);
}
}
+72 -3
View File
@@ -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<std::sync::Arc<std::sync::atomic::AtomicBool>> = 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<std::time::Instant> = 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)
);
}
}
+80
View File
@@ -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<u32>, Option<u32>),
output: (i32, i32),
) -> (i32, i32, i32, i32) {
let side = |req: Option<u32>, 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<u32>, Option<u32>), 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<NescopeState>,
@@ -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<u32, (std::time::Instant, u32)>,
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));
}
}
+1 -1
View File
@@ -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();
+8 -1
View File
@@ -83,6 +83,9 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender<Packet>)
/// 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<Packet>)
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<Packet>)
};
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");
}
}
}
}