From f995a069028f038d52b846da592360390a4e7836 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:17:20 +0800 Subject: [PATCH] fix(daemon): keep mobile input working with many open panes (#1159) --- crates/tty7-core/src/daemon/server.rs | 48 ++++++++++++++++ crates/tty7-server/tests/pane_send_input.rs | 64 ++++++++++++++++++++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 3a3c4aa5..333b91b4 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -386,6 +386,8 @@ macro_rules! startup_note { } pub fn run_daemon() -> anyhow::Result<()> { + #[cfg(unix)] + raise_open_file_limit(); let result = run_daemon_inner(); if let Err(e) = &result { log::error!("daemon exited with error: {e:#}"); @@ -393,6 +395,52 @@ pub fn run_daemon() -> anyhow::Result<()> { result } +/// A GUI-launched macOS process commonly inherits a 256-descriptor soft +/// limit. Each pane and desktop/phone connection owns several descriptors; +/// around fifty tabs can leave accept() enough room for a socket but not +/// try_clone(), so existing screens work while new input connections get EOF. +/// Do this before opening services, including when adopting a live handoff. +#[cfg(unix)] +fn raise_open_file_limit() { + const TARGET: libc::rlim_t = 4096; + let mut limits = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limits) } != 0 { + log::warn!( + "could not read the open-file limit: {}", + std::io::Error::last_os_error() + ); + return; + } + let before = limits.rlim_cur; + let target = TARGET.min(limits.rlim_max); + if before < target { + // Preserve the hard limit and never lower an already larger soft + // limit. A finite target also avoids macOS rejecting RLIM_INFINITY. + limits.rlim_cur = target; + if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &limits) } != 0 { + log::warn!( + "could not raise the open-file limit from {before} to {target}: {}", + std::io::Error::last_os_error() + ); + return; + } + log::info!( + "open-file soft limit raised from {before} to {target}; hard limit={}", + limits.rlim_max + ); + } + if limits.rlim_cur < TARGET { + log::warn!( + "open-file limit is only {}; many panes may exhaust it (hard limit={})", + limits.rlim_cur, + limits.rlim_max + ); + } +} + fn run_daemon_inner() -> anyhow::Result<()> { // First, before a handoff is adopted: the re-exec keeps every inherited // descriptor open, and adopting one marks it close-on-exec again. diff --git a/crates/tty7-server/tests/pane_send_input.rs b/crates/tty7-server/tests/pane_send_input.rs index 49322b85..d712f1f9 100644 --- a/crates/tty7-server/tests/pane_send_input.rs +++ b/crates/tty7-server/tests/pane_send_input.rs @@ -17,8 +17,14 @@ struct Daemon { impl Daemon { fn start() -> Daemon { + Self::start_with(|_| {}) + } + + fn start_with(configure: impl FnOnce(&mut Command)) -> Daemon { let dir = tempfile::TempDir::new().unwrap(); - let child = Command::new(env!("CARGO_BIN_EXE_tty7-server")) + let mut command = Command::new(env!("CARGO_BIN_EXE_tty7-server")); + configure(&mut command); + let child = command .arg("--daemon") .arg("--config-dir") .arg(dir.path()) @@ -138,6 +144,62 @@ fn collect_until(session: &mut tty7_core::client::PaneSession, marker: &[u8]) -> } } +#[test] +#[cfg(unix)] +fn a_gui_file_limit_does_not_break_mobile_input_with_many_desktop_panes() { + use std::os::unix::process::CommandExt; + + let daemon = Daemon::start_with(|command| unsafe { + // Launch Services on macOS normally gives an app a soft limit of + // 256. Set only this child to that limit; keep the runner unchanged. + command.pre_exec(|| { + let mut limit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) != 0 { + return Err(io::Error::last_os_error()); + } + limit.rlim_cur = 256.min(limit.rlim_max); + if libc::setrlimit(libc::RLIMIT_NOFILE, &limit) != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + }); + let panes = daemon.panes(); + let mut desktops = Vec::new(); + for n in 0..60 { + let pane = panes + .spawn(None, size(), Some(interactive_shell()), None, None) + .unwrap_or_else(|e| panic!("desktop pane {}: {e}", n + 1)); + desktops.push(pane); + } + let id = desktops[0].pane_id(); + for round in 0..3 { + let mut phone = panes.observe(id, size()).expect("phone can open the pane"); + phone.set_recv_timeout(Some(STREAM_WITHIN)).unwrap(); + let marker = format!("mobile-still-works-{round}"); + panes + .send_input( + id, + format!("printf 'mobile-%s\\n' still-works-{round}\r").as_bytes(), + ) + .expect("phone input is acknowledged beside the desktop connections"); + collect_until(&mut phone, marker.as_bytes()); + phone.detach().unwrap(); + } + // The desktop's existing connection still types and receives output too. + desktops[0].set_recv_timeout(Some(STREAM_WITHIN)).unwrap(); + desktops[0] + .input(b"printf 'desktop-%s\\n' still-works\r") + .unwrap(); + collect_until(&mut desktops[0], b"desktop-still-works"); + for pane in desktops { + pane.kill().unwrap(); + } +} + #[test] fn send_input_reaches_the_shell_without_displacing_the_controller() { let daemon = Daemon::start();