From 66357ded9eadf00f2872bbac18f0951eee99f2aa Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Tue, 6 Oct 2026 09:02:51 +0300 Subject: [PATCH 1/6] build(winit): pin the fork revision with explicit Wayland connections The pinned fork commit 6884804b87 adds one change on top of the current eval-exec/winit pin 2290f91c60: an event loop can be built on a caller-owned Wayland connection, and a failed construction can be retried. A display-free daemon needs this to attach its first native frame without changing WAYLAND_DISPLAY after its threads have started. --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 5 +++-- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b895e97c4..4f1092b906 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1687,7 +1687,7 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "dpi" version = "0.1.2" -source = "git+https://github.com/eval-exec/winit?rev=2290f91c602f8efdf102972457a25e8b6e36f90c#2290f91c602f8efdf102972457a25e8b6e36f90c" +source = "git+https://github.com/thanosapollo/winit?rev=6884804b87b93c228b97ba683ddcc7d0acb4d023#6884804b87b93c228b97ba683ddcc7d0acb4d023" [[package]] name = "dwrote" @@ -8715,7 +8715,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.31.0-beta.3" -source = "git+https://github.com/eval-exec/winit?rev=2290f91c602f8efdf102972457a25e8b6e36f90c#2290f91c602f8efdf102972457a25e8b6e36f90c" +source = "git+https://github.com/thanosapollo/winit?rev=6884804b87b93c228b97ba683ddcc7d0acb4d023#6884804b87b93c228b97ba683ddcc7d0acb4d023" dependencies = [ "bitflags 2.13.1", "cfg_aliases 0.2.2", @@ -8741,7 +8741,7 @@ dependencies = [ [[package]] name = "winit-android" version = "0.31.0-beta.3" -source = "git+https://github.com/eval-exec/winit?rev=2290f91c602f8efdf102972457a25e8b6e36f90c#2290f91c602f8efdf102972457a25e8b6e36f90c" +source = "git+https://github.com/thanosapollo/winit?rev=6884804b87b93c228b97ba683ddcc7d0acb4d023#6884804b87b93c228b97ba683ddcc7d0acb4d023" dependencies = [ "android-activity", "bitflags 2.13.1", @@ -8756,7 +8756,7 @@ dependencies = [ [[package]] name = "winit-appkit" version = "0.31.0-beta.3" -source = "git+https://github.com/eval-exec/winit?rev=2290f91c602f8efdf102972457a25e8b6e36f90c#2290f91c602f8efdf102972457a25e8b6e36f90c" +source = "git+https://github.com/thanosapollo/winit?rev=6884804b87b93c228b97ba683ddcc7d0acb4d023#6884804b87b93c228b97ba683ddcc7d0acb4d023" dependencies = [ "bitflags 2.13.1", "block2", @@ -8778,7 +8778,7 @@ dependencies = [ [[package]] name = "winit-common" version = "0.31.0-beta.3" -source = "git+https://github.com/eval-exec/winit?rev=2290f91c602f8efdf102972457a25e8b6e36f90c#2290f91c602f8efdf102972457a25e8b6e36f90c" +source = "git+https://github.com/thanosapollo/winit?rev=6884804b87b93c228b97ba683ddcc7d0acb4d023#6884804b87b93c228b97ba683ddcc7d0acb4d023" dependencies = [ "block2", "dpi", @@ -8796,7 +8796,7 @@ dependencies = [ [[package]] name = "winit-core" version = "0.31.0-beta.3" -source = "git+https://github.com/eval-exec/winit?rev=2290f91c602f8efdf102972457a25e8b6e36f90c#2290f91c602f8efdf102972457a25e8b6e36f90c" +source = "git+https://github.com/thanosapollo/winit?rev=6884804b87b93c228b97ba683ddcc7d0acb4d023#6884804b87b93c228b97ba683ddcc7d0acb4d023" dependencies = [ "bitflags 2.13.1", "cursor-icon", @@ -8811,7 +8811,7 @@ dependencies = [ [[package]] name = "winit-orbital" version = "0.31.0-beta.3" -source = "git+https://github.com/eval-exec/winit?rev=2290f91c602f8efdf102972457a25e8b6e36f90c#2290f91c602f8efdf102972457a25e8b6e36f90c" +source = "git+https://github.com/thanosapollo/winit?rev=6884804b87b93c228b97ba683ddcc7d0acb4d023#6884804b87b93c228b97ba683ddcc7d0acb4d023" dependencies = [ "bitflags 2.13.1", "dpi", @@ -8827,7 +8827,7 @@ dependencies = [ [[package]] name = "winit-uikit" version = "0.31.0-beta.3" -source = "git+https://github.com/eval-exec/winit?rev=2290f91c602f8efdf102972457a25e8b6e36f90c#2290f91c602f8efdf102972457a25e8b6e36f90c" +source = "git+https://github.com/thanosapollo/winit?rev=6884804b87b93c228b97ba683ddcc7d0acb4d023#6884804b87b93c228b97ba683ddcc7d0acb4d023" dependencies = [ "bitflags 2.13.1", "block2", @@ -8847,7 +8847,7 @@ dependencies = [ [[package]] name = "winit-wayland" version = "0.31.0-beta.3" -source = "git+https://github.com/eval-exec/winit?rev=2290f91c602f8efdf102972457a25e8b6e36f90c#2290f91c602f8efdf102972457a25e8b6e36f90c" +source = "git+https://github.com/thanosapollo/winit?rev=6884804b87b93c228b97ba683ddcc7d0acb4d023#6884804b87b93c228b97ba683ddcc7d0acb4d023" dependencies = [ "bitflags 2.13.1", "calloop", @@ -8874,7 +8874,7 @@ dependencies = [ [[package]] name = "winit-web" version = "0.31.0-beta.3" -source = "git+https://github.com/eval-exec/winit?rev=2290f91c602f8efdf102972457a25e8b6e36f90c#2290f91c602f8efdf102972457a25e8b6e36f90c" +source = "git+https://github.com/thanosapollo/winit?rev=6884804b87b93c228b97ba683ddcc7d0acb4d023#6884804b87b93c228b97ba683ddcc7d0acb4d023" dependencies = [ "atomic-waker", "bitflags 2.13.1", @@ -8896,7 +8896,7 @@ dependencies = [ [[package]] name = "winit-win32" version = "0.31.0-beta.3" -source = "git+https://github.com/eval-exec/winit?rev=2290f91c602f8efdf102972457a25e8b6e36f90c#2290f91c602f8efdf102972457a25e8b6e36f90c" +source = "git+https://github.com/thanosapollo/winit?rev=6884804b87b93c228b97ba683ddcc7d0acb4d023#6884804b87b93c228b97ba683ddcc7d0acb4d023" dependencies = [ "bitflags 2.13.1", "cursor-icon", @@ -8916,7 +8916,7 @@ dependencies = [ [[package]] name = "winit-x11" version = "0.31.0-beta.3" -source = "git+https://github.com/eval-exec/winit?rev=2290f91c602f8efdf102972457a25e8b6e36f90c#2290f91c602f8efdf102972457a25e8b6e36f90c" +source = "git+https://github.com/thanosapollo/winit?rev=6884804b87b93c228b97ba683ddcc7d0acb4d023#6884804b87b93c228b97ba683ddcc7d0acb4d023" dependencies = [ "bitflags 2.13.1", "bytemuck", diff --git a/Cargo.toml b/Cargo.toml index c5f565d7b2..f799eb1fea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -458,8 +458,9 @@ strip = "none" # Never vendor dependencies into this repository. Always fork the crate on # GitHub and make changes on the fork's `patch` branch. Reference an exact Git # revision here and document the branch beside each dependency. -# Fork branch: patch (includes Wayland/X11 fixes, continuous XI2 units, and Windows precision touchpad panning). -winit = { git = "https://github.com/eval-exec/winit", rev = "2290f91c602f8efdf102972457a25e8b6e36f90c" } +# Fork branch: patch (includes inherited fixes and explicit Wayland connections +# with failed-construction retry; deferred daemon startup never mutates display env). +winit = { git = "https://github.com/thanosapollo/winit", rev = "6884804b87b93c228b97ba683ddcc7d0acb4d023" } # Fork branch: neomacs-present-timing. # Native presentation IDs/timestamps; see the Vulkan timing seam in this fork. wgpu-hal = { git = "https://github.com/eval-exec/wgpu", rev = "9717f9fe277beec2c98df550662a626f8fc0248c" } From 262184178e89b5957cf11dfe97d0f9079eae3502 Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Tue, 6 Oct 2026 09:02:51 +0300 Subject: [PATCH 2/6] feat(display-runtime): native frame realization for a deferred display Add what a display-free daemon needs to attach native windows after startup. The render loop can be built on a given Wayland socket and holds a valid state with zero windows. A RealizeFrame command creates a frame window and replies only once the window is ready, so admission to the queue no longer counts as success. Native window construction can be cancelled within a deadline, and a pending primary window can be retired. Desktop font settings, GPU startup and window-event handling follow the same rules, so closing the last window or abandoning a pending one never stops the loop. --- .../src/display_identity.rs | 11 ++ .../src/font_defaults/linux.rs | 55 ++++++- .../src/font_defaults/mod.rs | 10 ++ crates/neomacs-display-runtime/src/lib.rs | 1 + .../src/native_window_wait.rs | 118 +++++++++++++++ .../src/render_thread/app_handler.rs | 23 +++ .../src/render_thread/bootstrap.rs | 114 +++++++++++++++ .../src/render_thread/command_processing.rs | 37 +++++ .../src/render_thread/frame_windows.rs | 89 +++++++++++- .../render_thread/frame_windows/tests/mod.rs | 30 ++++ .../src/render_thread/lifecycle.rs | 50 +++---- .../src/render_thread/mod.rs | 9 +- .../src/render_thread/startup.rs | 127 +++++++++++++--- .../src/render_thread/state.rs | 2 + .../src/render_thread/tests.rs | 87 +++++++++++ .../tests/deferred_gui_native_test.rs | 135 ++++++++++++++++++ .../src/render_thread/window_commands.rs | 90 ++++++++++++ .../src/render_thread/window_events.rs | 12 +- .../src/thread_comm.rs | 40 ++++++ 19 files changed, 984 insertions(+), 56 deletions(-) create mode 100644 crates/neomacs-display-runtime/src/native_window_wait.rs create mode 100644 crates/neomacs-display-runtime/src/render_thread/tests/deferred_gui_native_test.rs diff --git a/crates/neomacs-display-runtime/src/display_identity.rs b/crates/neomacs-display-runtime/src/display_identity.rs index 94df089add..9980caac5b 100644 --- a/crates/neomacs-display-runtime/src/display_identity.rs +++ b/crates/neomacs-display-runtime/src/display_identity.rs @@ -21,9 +21,19 @@ pub struct DisplayIdentityResolver { enum WaylandConnectionName { InheritedSocket, Environment(Option), + Explicit(String), } impl DisplayIdentityResolver { + /// Provenance for the socket passed to the explicit Wayland constructor. + /// `resolve` still checks the opened native display handle, not the request. + pub fn explicit_wayland(name: String) -> Self { + Self { + wayland: WaylandConnectionName::Explicit(name), + x11: None, + } + } + pub fn capture_environment() -> Self { Self { wayland: if std::env::var_os("WAYLAND_SOCKET").is_some() { @@ -56,6 +66,7 @@ impl DisplayIdentityResolver { GraphicalBackend::Wayland => match self.wayland { WaylandConnectionName::InheritedSocket => None, WaylandConnectionName::Environment(name) => name, + WaylandConnectionName::Explicit(name) => Some(name), }, GraphicalBackend::X11 => self.x11, GraphicalBackend::Cocoa => Some(system_name.to_owned()), diff --git a/crates/neomacs-display-runtime/src/font_defaults/linux.rs b/crates/neomacs-display-runtime/src/font_defaults/linux.rs index dadc58f066..462528d90f 100644 --- a/crates/neomacs-display-runtime/src/font_defaults/linux.rs +++ b/crates/neomacs-display-runtime/src/font_defaults/linux.rs @@ -17,6 +17,7 @@ pub(super) struct Subscription { context: gio::glib::MainContext, stop: Arc, worker: Option>, + bypass: Option>, } impl Drop for Subscription { @@ -25,10 +26,20 @@ impl Drop for Subscription { // GLib guarantees that an early wakeup makes the next iteration // return without blocking, including the check-before-wait race. self.context.wakeup(); - if let Some(worker) = self.worker.take() - && worker.join().is_err() - { - tracing::error!("desktop font subscription worker panicked"); + if let Some(worker) = self.worker.take() { + if let Some(bypass) = &self.bypass { + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(100); + while !worker.is_finished() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + if !worker.is_finished() { + bypass.store(true, Ordering::Release); + return; // Native worker owns its context; never free it here. + } + } + if worker.join().is_err() { + tracing::error!("desktop font subscription worker panicked"); + } } } } @@ -59,6 +70,19 @@ fn read_fonts(schema: &SettingsSchema, settings: &Settings) -> SystemFonts { } pub(super) fn observe() -> io::Result { + observe_inner(None) +} + +pub(super) fn observe_controlled( + cancelled: &dyn Fn() -> bool, + bypass: Arc, +) -> io::Result { + observe_inner(Some((cancelled, bypass))) +} + +fn observe_inner( + control: Option<(&dyn Fn() -> bool, Arc)>, +) -> io::Result { let context = gio::glib::MainContext::new(); let stop = Arc::new(AtomicBool::new(false)); let worker_context = context.clone(); @@ -68,6 +92,9 @@ pub(super) fn observe() -> io::Result { let worker = std::thread::Builder::new() .name("desktop-font-settings".into()) .spawn(move || { + if worker_stop.load(Ordering::Acquire) { + return; + } worker_context .with_thread_default(|| { let Some(schema) = SettingsSchemaSource::default() @@ -111,8 +138,26 @@ pub(super) fn observe() -> io::Result { context, stop, worker: Some(worker), + bypass: control.as_ref().map(|(_, bypass)| bypass.clone()), + }; + let initial = if let Some((cancelled, _)) = control { + loop { + if cancelled() { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "Native font observation cancelled", + )); + } + match initial_rx.recv_timeout(std::time::Duration::from_millis(20)) { + Ok(initial) => break initial, + Err(crossbeam_channel::RecvTimeoutError::Timeout) => {} + Err(error) => return Err(io::Error::other(error)), + } + } + } else { + initial_rx.recv().map_err(io::Error::other)? }; - match initial_rx.recv().map_err(io::Error::other)? { + match initial { Some(initial) => Ok(super::FontDefaultsObserver { initial: super::GuiFontDefaults::Desktop(initial), changes, diff --git a/crates/neomacs-display-runtime/src/font_defaults/mod.rs b/crates/neomacs-display-runtime/src/font_defaults/mod.rs index 4768e57baf..f9fdbb30a2 100644 --- a/crates/neomacs-display-runtime/src/font_defaults/mod.rs +++ b/crates/neomacs-display-runtime/src/font_defaults/mod.rs @@ -53,6 +53,16 @@ impl FontDefaultsObserver { } } +/// Deferred Linux discovery bounds the wait and retains foreign-worker exit +/// disposition when a native backend cannot acknowledge cancellation. +#[cfg(target_os = "linux")] +pub fn observe_font_defaults_controlled( + cancelled: &dyn Fn() -> bool, + bypass: std::sync::Arc, +) -> std::io::Result { + linux::observe_controlled(cancelled, bypass) +} + /// Capture preferences before opening fonts. AppKit discovery stays on the /// calling main thread; Linux owns discovery and monitoring on one GIO thread. pub fn observe_font_defaults(backend: GraphicalBackend) -> std::io::Result { diff --git a/crates/neomacs-display-runtime/src/lib.rs b/crates/neomacs-display-runtime/src/lib.rs index 9044213218..a7893a3efb 100644 --- a/crates/neomacs-display-runtime/src/lib.rs +++ b/crates/neomacs-display-runtime/src/lib.rs @@ -22,6 +22,7 @@ pub mod font_defaults; pub mod gui_resources; pub mod macos_bundle_runtime; mod menus; +pub mod native_window_wait; mod presentation; mod presentation_feedback; pub mod redisplay; diff --git a/crates/neomacs-display-runtime/src/native_window_wait.rs b/crates/neomacs-display-runtime/src/native_window_wait.rs new file mode 100644 index 0000000000..a3011aa560 --- /dev/null +++ b/crates/neomacs-display-runtime/src/native_window_wait.rs @@ -0,0 +1,118 @@ +//! Exact lease for synchronous native window construction. The controller may +//! interrupt only the retained connection's duplicated socket, never native objects. +use std::collections::HashMap; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; +use std::time::Instant; + +#[derive(Default)] +struct State { + leases: HashMap, Instant)>, + active: Option, + terminal: bool, +} +#[derive(Default)] +pub struct NativeWindowWaits(Mutex); +impl NativeWindowWaits { + pub fn register(&self, frame: u64, live: Arc, deadline: Instant) { + self.0 + .lock() + .unwrap() + .leases + .insert(frame, (live, deadline)); + } + pub fn remove(&self, frame: u64) { + self.0.lock().unwrap().leases.remove(&frame); + } + pub fn terminal(&self) -> bool { + self.0.lock().unwrap().terminal + } + /// Claim terminal connection disposition before the exact socket shutdown. + /// Finishing the synchronous call is serialized with this claim. + pub fn interrupt_required(&self) -> bool { + let mut state = self.0.lock().unwrap(); + if state + .active + .and_then(|frame| state.leases.get(&frame)) + .is_some_and(|(live, deadline)| { + !live.load(Ordering::Acquire) || Instant::now() >= *deadline + }) + { + state.terminal = true; + } + state.terminal + } + pub fn run( + &self, + frame: u64, + work: impl FnOnce() -> Result, + ) -> Result { + { + let mut state = self.0.lock().unwrap(); + if state.terminal + || state.leases.get(&frame).is_some_and(|(live, deadline)| { + !live.load(Ordering::Acquire) || Instant::now() >= *deadline + }) + { + return Err("Native frame preparation cancelled".into()); + } + state.active = Some(frame); + } + let result = work(); + let mut state = self.0.lock().unwrap(); + state.active = None; + if state.terminal + || state.leases.get(&frame).is_some_and(|(live, deadline)| { + !live.load(Ordering::Acquire) || Instant::now() >= *deadline + }) + { + return Err("Native frame preparation cancelled".into()); + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn active_revocation_terminalizes_only_its_connection() { + let waits = Arc::new(NativeWindowWaits::default()); + let live = Arc::new(AtomicBool::new(true)); + waits.register( + 1, + live.clone(), + Instant::now() + std::time::Duration::from_secs(15), + ); + let result = waits.run(1, || { + live.store(false, Ordering::Release); + assert!(waits.interrupt_required()); + Ok(42) + }); + assert!(result.is_err()); + assert!(waits.terminal()); + let fresh = Arc::new(AtomicBool::new(true)); + waits.register( + 2, + fresh, + Instant::now() + std::time::Duration::from_secs(15), + ); + assert!(waits.run(2, || Ok(42)).is_err()); + } + #[test] + fn completed_constructor_loses_interrupt_authority() { + let waits = NativeWindowWaits::default(); + let live = Arc::new(AtomicBool::new(true)); + waits.register( + 1, + live.clone(), + Instant::now() + std::time::Duration::from_secs(15), + ); + assert_eq!(waits.run(1, || Ok(42)), Ok(42)); + live.store(false, Ordering::Release); + assert!(!waits.interrupt_required()); + assert!(!waits.terminal()); + } +} diff --git a/crates/neomacs-display-runtime/src/render_thread/app_handler.rs b/crates/neomacs-display-runtime/src/render_thread/app_handler.rs index 417e5cb4b2..c2cb4b77a5 100644 --- a/crates/neomacs-display-runtime/src/render_thread/app_handler.rs +++ b/crates/neomacs-display-runtime/src/render_thread/app_handler.rs @@ -4,6 +4,25 @@ use winit::event::WindowEvent; use winit::event_loop::ActiveEventLoop; use winit::window::WindowId; +impl RenderApp { + /// Called only for the exact currently pending native window. Late events + /// for an older WindowId never enter this boundary. + pub(super) fn retire_pending_primary(&mut self) { + let frame = self.frame_windows.primary_event_frame_id(); + self.cancel_gpu_startup(); + self.frame_windows + .reject_ready(frame, "Native window closed during GPU startup"); + self.frame_windows.take_primary_window(); + self.frame_windows.clear_primary_mapping(); + if frame != 0 { + self.comms + .send_input(crate::thread_comm::InputEvent::WindowClose { + emacs_frame_id: frame, + }); + } + } +} + impl ApplicationHandler for RenderApp { fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) { self.handle_resumed(event_loop); @@ -24,6 +43,10 @@ impl ApplicationHandler for RenderApp { self.observe_pending_content(*size); } if matches!(event, WindowEvent::CloseRequested | WindowEvent::Destroyed) { + if self.comms.keep_alive_without_frames { + self.retire_pending_primary(); + return; + } self.comms .send_input(crate::thread_comm::InputEvent::WindowClose { emacs_frame_id: 0 }); self.lifecycle_flags diff --git a/crates/neomacs-display-runtime/src/render_thread/bootstrap.rs b/crates/neomacs-display-runtime/src/render_thread/bootstrap.rs index 5af26142e8..904b36b7d0 100644 --- a/crates/neomacs-display-runtime/src/render_thread/bootstrap.rs +++ b/crates/neomacs-display-runtime/src/render_thread/bootstrap.rs @@ -482,6 +482,120 @@ pub fn build_render_event_loop() -> Result { build_render_event_loop_impl(false) } +/// Build on the OS-main owner using the exact caller-selected Wayland socket. +/// This path never consults ambient display/backend discovery. +#[cfg(target_os = "linux")] +pub fn build_render_event_loop_wayland(socket: &std::path::Path) -> Result { + use std::os::unix::net::UnixStream; + let stream = UnixStream::connect(socket).map_err(|error| { + format!( + "Cannot connect to Wayland socket {}: {error}", + socket.display() + ) + })?; + build_render_event_loop_wayland_stream(stream, socket) +} + +/// The caller retains an owned socket duplicate for cancellation of the actual +/// synchronous registry waits. Native construction remains on the caller. +#[cfg(target_os = "linux")] +pub fn build_render_event_loop_wayland_stream( + stream: std::os::unix::net::UnixStream, + socket: &std::path::Path, +) -> Result { + use winit::platform::wayland::WaylandConnection; + let connection = WaylandConnection::from_socket(stream).map_err(|error| { + format!( + "Cannot initialize Wayland socket {}: {error}", + socket.display() + ) + })?; + let mut builder = EventLoop::builder(); + builder.with_wayland_connection(connection); + builder + .build() + .map_err(|error| format!("Cannot open Wayland socket {}: {error}", socket.display())) +} + +/// OS-main daemon root. Once constructed, the native singleton is never +/// dropped merely because an attachment request or frame preparation failed. +pub struct DaemonRenderRoot { + event_loop: EventLoop, + app: startup::StartingApp, + cancelled: std::sync::Arc, +} + +impl DaemonRenderRoot { + pub fn new(event_loop: EventLoop) -> Self { + let cancelled = Default::default(); + Self { + event_loop, + app: startup::StartingApp::retained(), + cancelled, + } + } + pub fn event_loop(&self) -> &EventLoop { + &self.event_loop + } + pub fn proxy(&self) -> winit::event_loop::EventLoopProxy { + self.event_loop.create_proxy() + } + pub fn install( + &mut self, + comms: RenderComms, + initial: InitialWindowReceiver, + image_metadata: SharedImageRenderState, + shared_monitors: SharedMonitorInfo, + #[cfg(feature = "neo-term")] shared_terminals: crate::terminal::SharedTerminals, + ) { + let mut app = RenderApp::new( + comms, + 0, + 0, + "Neomacs".into(), + image_metadata, + shared_monitors, + false, + #[cfg(feature = "neo-term")] + shared_terminals, + ); + app.gpu_startup_cancelled = self.cancelled.clone(); + let proxy = self.event_loop.create_proxy(); + match super::frame_preparation::FramePreparation::spawn( + app.comms.frame_rx.clone(), + move || proxy.wake_up(), + ) { + Ok(worker) => app.frame_preparation = Some(worker), + Err(error) => tracing::warn!(%error, "frame preparation worker unavailable"), + } + #[cfg(feature = "video")] + { + let proxy = self.event_loop.create_proxy(); + app.video_wake = neomacs_video::VideoWake::new(move || proxy.wake_up()); + } + #[cfg(feature = "webview")] + { + let proxy = self.event_loop.create_proxy(); + app.webview_wake = neomacs_webview::WebViewWake::new(move || proxy.wake_up()); + } + self.app.install(initial, app); + } + pub fn pump(&mut self) -> bool { + use winit::event_loop::pump_events::{EventLoopExtPumpEvents, PumpStatus}; + matches!( + self.event_loop.pump_app_events(None, &mut self.app), + PumpStatus::Continue + ) + } + /// Retire exact attachment resources before root acknowledgement/re-exec. + pub fn retire(&mut self) { + self.app.retire(); + } + pub fn bypass_finalizers(&self) -> bool { + self.cancelled.load(std::sync::atomic::Ordering::Acquire) + } +} + /// Build a render event loop for the legacy render-thread helper. pub(crate) fn build_render_event_loop_any_thread() -> Result { build_render_event_loop_impl(true) diff --git a/crates/neomacs-display-runtime/src/render_thread/command_processing.rs b/crates/neomacs-display-runtime/src/render_thread/command_processing.rs index 906b691e72..cd33581174 100644 --- a/crates/neomacs-display-runtime/src/render_thread/command_processing.rs +++ b/crates/neomacs-display-runtime/src/render_thread/command_processing.rs @@ -2,8 +2,45 @@ use super::RenderApp; use crate::thread_comm::{ClipboardCommand, LifecycleCommand, RenderCommand, WindowCommand}; impl RenderApp { + /// During connection-only/GPU startup, configuration and frame lifecycle + /// live in CPU state. Other commands retain order in a bounded staging queue; + /// stop draining when full, leaving producer admission explicitly fallible. + pub(super) fn process_startup_commands(&mut self) -> bool { + self.retire_cancelled_frames(); + while self.startup_commands.len() < 64 { + let Ok(command) = self.comms.cmd_rx.try_recv() else { + break; + }; + if !self.comms.keep_alive_without_frames { + if matches!( + command, + RenderCommand::Lifecycle(LifecycleCommand::Shutdown) + ) { + self.lifecycle_flags + .request_shutdown(super::state::RenderShutdownReason::EvaluatorShutdown); + return true; + } + self.startup_commands.push_back(command); + continue; + } + match command { + RenderCommand::Lifecycle(LifecycleCommand::Shutdown) => { + self.lifecycle_flags + .request_shutdown(super::state::RenderShutdownReason::EvaluatorShutdown); + return true; + } + RenderCommand::Config(command) => self.handle_config(command), + RenderCommand::Window(command) => self.handle_window(command), + RenderCommand::Clipboard(command) => self.handle_clipboard(command), + other => self.startup_commands.push_back(other), + } + } + false + } + /// Process pending commands from Emacs. pub(super) fn process_commands(&mut self) -> bool { + self.retire_cancelled_frames(); let mut should_exit = false; while let Some(cmd) = self diff --git a/crates/neomacs-display-runtime/src/render_thread/frame_windows.rs b/crates/neomacs-display-runtime/src/render_thread/frame_windows.rs index a1aadafc42..df81d43908 100644 --- a/crates/neomacs-display-runtime/src/render_thread/frame_windows.rs +++ b/crates/neomacs-display-runtime/src/render_thread/frame_windows.rs @@ -1862,6 +1862,7 @@ pub(crate) struct GuiFrameWindowManager { pub pending_creates: Vec, /// Pending window destruction requests pub pending_destroys: Vec, + ready_replies: HashMap>>, /// Native chrome defaults applied to future secondary frame windows. pub(super) chrome_defaults: WindowChrome, /// Whether future secondary frame windows should start with FPS enabled. @@ -1889,6 +1890,7 @@ impl GuiFrameWindowManager { primary_winit_id: None, pending_creates: Vec::new(), pending_destroys: Vec::new(), + ready_replies: HashMap::new(), chrome_defaults: WindowChrome::default(), fps_enabled: false, option_as_alt: neomacs_display_protocol::OptionAsAltShape::Both, @@ -2037,6 +2039,30 @@ impl GuiFrameWindowManager { self.pending_destroys.contains(&emacs_frame_id) } + pub fn await_ready( + &mut self, + frame: u64, + reply: crossbeam_channel::Sender>, + ) { + if self + .get(frame) + .is_some_and(|window| matches!(window.lifecycle, FrameLifecycle::Active { .. })) + { + let _ = reply.send(Ok(())); + } else if self.get(frame).is_some() + || self + .pending_creates + .iter() + .any(|request| request.emacs_frame_id == frame) + { + self.ready_replies.insert(frame, reply); + } else { + let _ = reply.send(Err(format!( + "Native window for frame {frame} was not realized" + ))); + } + } + /// Process pending window creations. Must be called from the event loop /// (requires ActiveEventLoop for window creation). pub fn process_creates( @@ -2050,6 +2076,9 @@ impl GuiFrameWindowManager { ) { let pending = std::mem::take(&mut self.pending_creates); for req in pending { + if self.destroy_pending(req.emacs_frame_id) { + continue; + } if self .windows .contains_key(&FrameKey::Adopted(req.emacs_frame_id)) @@ -2069,7 +2098,7 @@ impl GuiFrameWindowManager { ); let attrs = crate::window_identity::apply_platform_window_identity(attrs, event_loop); - match event_loop.create_window(attrs) { + match comms.create_window(event_loop, attrs, req.emacs_frame_id) { Ok(window) => { let window: Arc = Arc::from(window); window_icon.apply(window.as_ref()); @@ -2230,6 +2259,63 @@ impl GuiFrameWindowManager { } } } + self.settle_ready_replies(); + } + + pub(super) fn reject_ready(&mut self, frame: u64, error: &str) { + if let Some(reply) = self.ready_replies.remove(&frame) { + let _ = reply.try_send(Err(error.to_owned())); + } + } + + pub(super) fn prepare_primary( + &mut self, + frame: u64, + width: u32, + height: u32, + title: String, + geometry_hints: Option, + ) { + self.set_primary_pending(GuiFrameWindowState { + pending_scale_factor: None, + lifecycle: FrameLifecycle::Pending { + width, + height, + scale_factor: 1.0, + mouse_hidden_for_typing: false, + ime_enabled: false, + last_ime_cursor_area: None, + chrome: WindowChrome { + title, + ..WindowChrome::default() + }, + geometry_hints, + }, + render: GuiFrameRenderState::new_without_device( + frame, + false, + neomacs_display_protocol::frame_time::observe_platform_now(), + ), + }); + self.adopt_primary_frame_id(frame); + } + + fn settle_ready_replies(&mut self) { + for (frame, reply) in std::mem::take(&mut self.ready_replies) { + let ready = self + .get(frame) + .is_some_and(|window| matches!(window.lifecycle, FrameLifecycle::Active { .. })); + let result = if ready { + Ok(()) + } else { + Err(format!( + "Native window/surface creation failed for frame {frame}" + )) + }; + if reply.send(result).is_err() && ready { + self.request_destroy(frame); + } + } } /// Process pending window destructions, reporting the frames that went @@ -2253,6 +2339,7 @@ impl GuiFrameWindowManager { pub fn destroy_all(&mut self) { self.pending_creates.clear(); self.pending_destroys.clear(); + self.ready_replies.clear(); self.winit_to_emacs.clear(); self.primary_winit_id = None; self.primary_emacs_frame_id = None; diff --git a/crates/neomacs-display-runtime/src/render_thread/frame_windows/tests/mod.rs b/crates/neomacs-display-runtime/src/render_thread/frame_windows/tests/mod.rs index e8613758c7..b60cf71934 100644 --- a/crates/neomacs-display-runtime/src/render_thread/frame_windows/tests/mod.rs +++ b/crates/neomacs-display-runtime/src/render_thread/frame_windows/tests/mod.rs @@ -10,6 +10,36 @@ use neomacs_display_protocol::FrameFaceMap; use neomacs_display_protocol::types::Color; use neovm_core::window::GuiFrameGeometryHints; +#[test] +fn deferred_gui_native_ready_requires_realized_frame() { + let mut windows = GuiFrameWindowManager::new(); + let (reply, receive) = crossbeam_channel::bounded(1); + windows.await_ready(42, reply); + assert!(receive.recv().unwrap().is_err()); + windows.request_create(43, 320, 200, "deferred".into(), default_geometry_hints()); + let (reply, receive) = crossbeam_channel::bounded(1); + windows.await_ready(43, reply); + assert!(matches!( + receive.try_recv(), + Err(crossbeam_channel::TryRecvError::Empty) + )); + windows.pending_creates.clear(); // Native constructor failed: no realized window. + windows.settle_ready_replies(); + assert!(receive.recv().unwrap().is_err()); + assert!(windows.ready_replies.is_empty()); +} + +#[test] +fn deferred_gui_pending_ready_is_cancelled_on_connection_shutdown() { + let mut windows = GuiFrameWindowManager::new(); + windows.request_create(43, 320, 200, "deferred".into(), default_geometry_hints()); + let (reply, receive) = crossbeam_channel::bounded(1); + windows.await_ready(43, reply); + windows.destroy_all(); + assert!(matches!(receive.recv(), Err(crossbeam_channel::RecvError))); + assert!(windows.pending_creates.is_empty()); +} + // ======================================================================= // Helper: create a FrameGlyphBuffer with specified identity fields // ======================================================================= diff --git a/crates/neomacs-display-runtime/src/render_thread/lifecycle.rs b/crates/neomacs-display-runtime/src/render_thread/lifecycle.rs index 17ac8d1687..ca179049bf 100644 --- a/crates/neomacs-display-runtime/src/render_thread/lifecycle.rs +++ b/crates/neomacs-display-runtime/src/render_thread/lifecycle.rs @@ -102,7 +102,7 @@ impl RenderApp { } } - if emit_change_event && had_snapshot { + if !had_snapshot || emit_change_event { self.comms .send_input(InputEvent::MonitorsChanged { monitors: snapshot }); } @@ -126,9 +126,7 @@ impl RenderApp { .1, self.frame_windows .primary_window() - .expect("primary window state") - .chrome() - .title + .map(|primary| &primary.chrome().title) ); self.lifecycle_flags.resumed_seen = true; } @@ -158,7 +156,11 @@ impl RenderApp { height, title ); - match event_loop.create_window(attrs) { + match self.comms.create_window( + event_loop, + attrs, + self.frame_windows.primary_event_frame_id(), + ) { Ok(window) => { let window: Arc = Arc::from(window); NativeTextInputPolicy::for_gui_frame().apply_to_window(window.as_ref()); @@ -271,6 +273,13 @@ impl RenderApp { event_loop.exit(); return; } + if self.gpu.is_none() && self.comms.keep_alive_without_frames { + if self.process_startup_commands() { + self.handle_exiting(); + event_loop.exit(); + return; + } + } if let Some(pending) = self.gpu_startup.take() { match pending.poll(&event_loop.create_proxy()) { Ok(super::gpu_startup::GpuPoll::Pending(pending)) => { @@ -279,33 +288,18 @@ impl RenderApp { Ok(super::gpu_startup::GpuPoll::Ready(prepared)) => self.install_wgpu(prepared), Err(error) => { self.startup_error = Some(error); - event_loop.exit(); + if !self.comms.keep_alive_without_frames { + event_loop.exit(); + } return; } } } - if self.gpu.is_none() - && self - .frame_windows - .primary_window() - .is_some_and(|primary| !primary.lifecycle.is_active()) - { - // Keep GPU-dependent commands in their original order. Shutdown - // must remain observable even for the legacy render-loop caller - // that has no evaluator preparation lifetime to monitor. - while let Ok(command) = self.comms.cmd_rx.try_recv() { - if matches!( - command, - crate::thread_comm::RenderCommand::Lifecycle( - crate::thread_comm::LifecycleCommand::Shutdown - ) - ) { - self.lifecycle_flags - .request_shutdown(super::state::RenderShutdownReason::EvaluatorShutdown); - event_loop.exit(); - return; - } - self.startup_commands.push_back(command); + if self.gpu.is_none() { + if self.process_startup_commands() { + self.handle_exiting(); + event_loop.exit(); + return; } event_loop.set_control_flow(ControlFlow::Wait); return; diff --git a/crates/neomacs-display-runtime/src/render_thread/mod.rs b/crates/neomacs-display-runtime/src/render_thread/mod.rs index 7535c82daf..67c0efd9b8 100644 --- a/crates/neomacs-display-runtime/src/render_thread/mod.rs +++ b/crates/neomacs-display-runtime/src/render_thread/mod.rs @@ -9,6 +9,9 @@ pub(crate) mod child_frames; mod command_processing; mod cursor; mod cursor_runtime; +#[cfg(all(test, target_os = "linux"))] +#[path = "tests/deferred_gui_native_test.rs"] +mod deferred_gui_native_test; mod device_loss; mod frame_compositor; mod frame_ingest; @@ -55,7 +58,11 @@ mod window_events; #[cfg(feature = "neo-term")] pub use bootstrap::run_render_loop_current_thread_with_terminals; -pub use bootstrap::{build_render_event_loop, run_render_loop, run_render_loop_current_thread}; +pub use bootstrap::{ + DaemonRenderRoot, build_render_event_loop, run_render_loop, run_render_loop_current_thread, +}; +#[cfg(target_os = "linux")] +pub use bootstrap::{build_render_event_loop_wayland, build_render_event_loop_wayland_stream}; pub(crate) use lifecycle::PopupCommit; pub use startup::{ InitialWindowLifetime, InitialWindowReceiver, InitialWindowReply, InitialWindowSize, diff --git a/crates/neomacs-display-runtime/src/render_thread/startup.rs b/crates/neomacs-display-runtime/src/render_thread/startup.rs index df2ee880c1..bc3a364b84 100644 --- a/crates/neomacs-display-runtime/src/render_thread/startup.rs +++ b/crates/neomacs-display-runtime/src/render_thread/startup.rs @@ -102,8 +102,11 @@ pub(super) enum InitialWindow { }, } -enum Phase { - Preparing { initial: InitialWindow, create: F }, +enum Phase { + Preparing { + initial: InitialWindow, + app: Box, + }, Running(Box), Stopped, } @@ -118,15 +121,38 @@ enum Outcome { GpuFailed(String), } -struct StartingApp { - phase: Phase, +pub(super) struct StartingApp { + phase: Phase, can_create_surfaces: bool, outcome: Rc>, evaluator: Option>>, - gpu_cancelled: std::sync::Arc, + retained: bool, +} + +impl StartingApp { + pub(super) fn retained() -> Self { + Self { + phase: Phase::Stopped, + can_create_surfaces: false, + outcome: Default::default(), + evaluator: None, + retained: true, + } + } + pub(super) fn install(&mut self, receiver: InitialWindowReceiver, app: RenderApp) { + self.retire(); + self.phase = Phase::Preparing { + initial: InitialWindow::Waiting(receiver), + app: Box::new(app), + }; + } + pub(super) fn retire(&mut self) { + self.evaluator = None; + self.phase = Phase::Stopped; + } } -impl RenderApp> ApplicationHandler for StartingApp { +impl ApplicationHandler for StartingApp { fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) { self.can_create_surfaces = true; if let Phase::Running(app) = &mut self.phase { @@ -158,7 +184,18 @@ impl RenderApp> ApplicationHandler for StartingA } fn about_to_wait(&mut self, event_loop: &dyn ActiveEventLoop) { - if let Phase::Preparing { initial, .. } = &mut self.phase { + if let Phase::Preparing { initial, app } = &mut self.phase { + // Consume CPU-owned configuration and frame identity even while no + // initial geometry exists. The bounded producer cannot deadlock the + // evaluator that must eventually publish that geometry. + if app.process_startup_commands() { + *self.outcome.borrow_mut() = Outcome::EvaluatorExited; + self.phase = Phase::Stopped; + if !self.retained { + event_loop.exit(); + } + return; + } if let InitialWindow::Waiting(receiver) = initial { let result = match receiver.0.try_recv() { Ok(result) => result, @@ -180,7 +217,9 @@ impl RenderApp> ApplicationHandler for StartingA Err(error) => { *self.outcome.borrow_mut() = Outcome::Failed(error); self.phase = Phase::Stopped; - event_loop.exit(); + if !self.retained { + event_loop.exit(); + } return; } } @@ -195,7 +234,9 @@ impl RenderApp> ApplicationHandler for StartingA // 0) from panic. Completion after readiness is not itself an error. *self.outcome.borrow_mut() = Outcome::EvaluatorExited; self.phase = Phase::Stopped; - event_loop.exit(); + if !self.retained { + event_loop.exit(); + } return; } if !self.can_create_surfaces { @@ -204,11 +245,17 @@ impl RenderApp> ApplicationHandler for StartingA } if let Phase::Preparing { initial: InitialWindow::Ready { size, evaluator }, - create, + mut app, } = std::mem::replace(&mut self.phase, Phase::Stopped) { - let mut app = Box::new(create(size)); - app.gpu_startup_cancelled = self.gpu_cancelled.clone(); + app.pending_content_size = size; + if let Some(primary) = app.frame_windows.primary_window_mut() + && let super::frame_windows::FrameLifecycle::Pending { width, height, .. } = + &mut primary.lifecycle + { + *width = size.width; + *height = size.height; + } app.can_create_surfaces(event_loop); self.evaluator = evaluator; self.phase = Phase::Running(app); @@ -219,17 +266,48 @@ impl RenderApp> ApplicationHandler for StartingA }) { *self.outcome.borrow_mut() = Outcome::EvaluatorExited; self.phase = Phase::Stopped; - event_loop.exit(); + if !self.retained { + event_loop.exit(); + } return; } if let Phase::Running(app) = &mut self.phase { - app.about_to_wait(event_loop); + if app.comms.keep_alive_without_frames && self.can_create_surfaces && app.gpu.is_none() + { + if app.process_startup_commands() { + *self.outcome.borrow_mut() = Outcome::EvaluatorExited; + self.phase = Phase::Stopped; + if !self.retained { + event_loop.exit(); + } + return; + } + if app.startup_error.is_none() + && app.gpu_startup.is_none() + && app.frame_windows.primary_window().is_some() + { + app.can_create_surfaces(event_loop); + } + } + if app.startup_error.is_none() { + app.about_to_wait(event_loop); + } if let Some(error) = app.startup_error.take() { - *self.outcome.borrow_mut() = Outcome::GpuFailed(error); - self.phase = Phase::Stopped; - event_loop.exit(); + if self.retained { + app.cancel_gpu_startup(); + let frame = app.frame_windows.primary_event_frame_id(); + app.frame_windows.reject_ready(frame, &error); + app.frame_windows.take_primary_window(); + app.frame_windows.clear_primary_mapping(); + } else { + *self.outcome.borrow_mut() = Outcome::GpuFailed(error); + self.phase = Phase::Stopped; + event_loop.exit(); + } } else if app.gpu.is_some() { - self.evaluator = None; + if !app.comms.keep_alive_without_frames { + self.evaluator = None; + } *self.outcome.borrow_mut() = Outcome::Running; } } @@ -248,12 +326,21 @@ pub(super) fn run( event_loop.set_control_flow(ControlFlow::Wait); let outcome = Rc::new(RefCell::new(Outcome::Preparing)); let gpu_cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let size = match &initial { + InitialWindow::Ready { size, .. } => *size, + InitialWindow::Waiting(_) => InitialWindowSize { + width: 0, + height: 0, + }, + }; + let mut app = Box::new(create(size)); + app.gpu_startup_cancelled = gpu_cancelled.clone(); let result = event_loop.run_app(StartingApp { - phase: Phase::Preparing { initial, create }, + phase: Phase::Preparing { initial, app }, can_create_surfaces: false, outcome: Rc::clone(&outcome), evaluator: None, - gpu_cancelled: gpu_cancelled.clone(), + retained: false, }); let successful_exit = || { if gpu_cancelled.load(std::sync::atomic::Ordering::Relaxed) { diff --git a/crates/neomacs-display-runtime/src/render_thread/state.rs b/crates/neomacs-display-runtime/src/render_thread/state.rs index 8c2a5f7fe1..12c5de27d2 100644 --- a/crates/neomacs-display-runtime/src/render_thread/state.rs +++ b/crates/neomacs-display-runtime/src/render_thread/state.rs @@ -889,6 +889,7 @@ pub(super) struct RenderApp { pub(super) gpu_startup_cancelled: Arc, pub(super) startup_error: Option, pub(super) startup_commands: std::collections::VecDeque, + pub(super) frame_leases: HashMap>, pub(super) renderer: Option, /// Native decoder workers signal this callback after replacing a latest /// frame or publishing control state. Production installs a winit proxy; @@ -1129,6 +1130,7 @@ impl RenderApp { gpu_startup_cancelled: Default::default(), startup_error: None, startup_commands: Default::default(), + frame_leases: Default::default(), menus: crate::menus::MenuPresentation::default(), tooltips: crate::tooltips::Tooltips::default(), renderer: None, diff --git a/crates/neomacs-display-runtime/src/render_thread/tests.rs b/crates/neomacs-display-runtime/src/render_thread/tests.rs index 0256b94121..eb5fc3555b 100644 --- a/crates/neomacs-display-runtime/src/render_thread/tests.rs +++ b/crates/neomacs-display-runtime/src/render_thread/tests.rs @@ -32,6 +32,93 @@ pub(super) fn make_test_app() -> RenderApp { ) } +#[test] +fn deferred_gui_connection_only_services_saturation_and_later_frame() { + use crate::thread_comm::{ConfigCommand, LifecycleCommand}; + let mut app = make_test_app(); + app.comms.keep_alive_without_frames = true; + // Repeatedly saturate the real capacity-64 transport before any GPU/window. + // Each pass reduces configuration into CPU state, not an unbounded backlog. + // The test owns the sender via a fresh matching channel. + let (emacs, render) = ThreadComms::new().split(); + app.comms = render; + app.comms.keep_alive_without_frames = true; + for pass in 0..8 { + for index in 0..64 { + emacs + .cmd_tx + .try_send(RenderCommand::Config(ConfigCommand::SetExtraSpacing { + line_spacing: (pass * 64 + index) as f32, + letter_spacing: 2.0, + })) + .unwrap(); + } + assert!( + emacs + .cmd_tx + .try_send(RenderCommand::Config(ConfigCommand::SetShowFps { + enabled: true + })) + .is_err() + ); + assert!(!app.process_startup_commands()); + assert!(app.startup_commands.is_empty()); + assert_eq!(app.extra_line_spacing, (pass * 64 + 63) as f32); + } + emacs + .cmd_tx + .try_send(RenderCommand::Window(WindowCommand::AdoptPrimaryFrame { + frame: FrameRef::Frame(0x42), + })) + .unwrap(); + assert!(!app.process_startup_commands()); + assert_eq!(app.frame_windows.primary_event_frame_id(), 0x42); + emacs + .cmd_tx + .try_send(RenderCommand::Lifecycle(LifecycleCommand::Shutdown)) + .unwrap(); + assert!(app.process_startup_commands()); + assert!(app.lifecycle_flags.is_shutting_down()); +} + +#[test] +fn deferred_gui_pending_primary_close_rejects_exact_frame_and_recreates() { + let mut app = make_test_app(); + app.comms.keep_alive_without_frames = true; + app.frame_windows.adopt_primary_frame_id(0x42); + let (reply, rx) = crossbeam_channel::bounded(1); + app.frame_windows.await_ready(0x42, reply); + assert!(matches!( + rx.try_recv(), + Err(crossbeam_channel::TryRecvError::Empty) + )); + app.retire_pending_primary(); + assert!(rx.try_recv().unwrap().is_err()); + assert!(app.frame_windows.primary_window().is_none()); + assert!(!app.lifecycle_flags.is_shutting_down()); + app.handle_window(WindowCommand::CreateWindow { + frame: FrameRef::Frame(0x43), + width: 901, + height: 603, + title: "replacement".into(), + geometry_hints: GuiFrameGeometryHints { + base_width: 0, + base_height: 0, + min_width: 1, + min_height: 1, + width_inc: 1, + height_inc: 1, + }, + }); + assert_eq!(app.frame_windows.primary_event_frame_id(), 0x43); + assert_eq!(app.pending_content_size.width, 901); + // A stale destroyed frame cannot remove the replacement. + app.handle_window(WindowCommand::DestroyWindow { + frame: FrameRef::Frame(0x42), + }); + assert_eq!(app.frame_windows.primary_event_frame_id(), 0x43); +} + #[test] fn pending_native_recreation_retains_last_usable_editor_size() { let mut content = super::startup::InitialWindowSize { diff --git a/crates/neomacs-display-runtime/src/render_thread/tests/deferred_gui_native_test.rs b/crates/neomacs-display-runtime/src/render_thread/tests/deferred_gui_native_test.rs new file mode 100644 index 0000000000..6371cf471f --- /dev/null +++ b/crates/neomacs-display-runtime/src/render_thread/tests/deferred_gui_native_test.rs @@ -0,0 +1,135 @@ +//! Opt-in real Wayland ownership regression. No fake native windows/driver. +use super::{gpu_startup::PendingGpu, tests::make_test_app}; +use crate::thread_comm::{FrameRef, InputEvent, WindowCommand}; +use std::{os::unix::net::UnixStream, sync::Arc}; +use winit::{ + application::ApplicationHandler, + event::WindowEvent, + event_loop::{ActiveEventLoop, EventLoop}, + platform::wayland::{EventLoopBuilderExtWayland, WaylandConnection}, +}; + +#[test] +#[ignore = "requires explicitly selected private genuine compositor socket"] +fn deferred_gui_native_gated_pending_close_recreates_and_preserves_default_close() { + let socket = + std::env::var("NEOMACS_TEST_WAYLAND_SOCKET").expect("selected compositor required"); + let connection = WaylandConnection::from_socket(UnixStream::connect(socket).unwrap()).unwrap(); + let mut builder = EventLoop::builder(); + // Rust harness thread only; production uses OS-main without this exception. + builder + .with_any_thread(true) + .with_wayland_connection(connection); + let event_loop = builder.build().unwrap(); + struct Probe { + done: Arc, + } + impl ApplicationHandler for Probe { + fn window_event( + &mut self, + _owner: &dyn ActiveEventLoop, + _id: winit::window::WindowId, + _event: WindowEvent, + ) { + } + fn can_create_surfaces(&mut self, owner: &dyn ActiveEventLoop) { + for close in [WindowEvent::CloseRequested, WindowEvent::Destroyed] { + let (emacs, render) = crate::thread_comm::ThreadComms::new().split(); + let mut app = make_test_app(); + app.comms = render; + app.comms.keep_alive_without_frames = true; + app.frame_windows.adopt_primary_frame_id(0x42); + let (reply, ready) = crossbeam_channel::bounded(1); + app.frame_windows.await_ready(0x42, reply); + assert!(matches!( + ready.try_recv(), + Err(crossbeam_channel::TryRecvError::Empty) + )); + let window: Arc = + Arc::from(owner.create_window(Default::default()).unwrap()); + let stale = window.id(); + let weak = Arc::downgrade(&window); + let (gate, receive) = crossbeam_channel::bounded(1); + // Genuine native window, controlled adapter phase (no foreign + // call entered): receiver cannot become Ready until gate opens. + app.gpu_startup = Some(PendingGpu::Adapters { + window, + reply: receive, + }); + app.window_event(owner, stale, close); + assert!( + weak.upgrade().is_none(), + "OS-owner must release pending native window" + ); + assert!(ready.try_recv().unwrap().is_err()); + assert!(matches!( + emacs.input_rx.try_recv().unwrap(), + InputEvent::WindowClose { + emacs_frame_id: 0x42 + } + )); + assert!(!app.lifecycle_flags.is_shutting_down()); + assert!( + app.gpu_startup_cancelled + .load(std::sync::atomic::Ordering::Relaxed) + ); + assert!( + gate.try_send(Err("late result".into())).is_err(), + "cancelled generation must reject worker completion" + ); + app.handle_window(WindowCommand::CreateWindow { + frame: FrameRef::Frame(0x43), + width: 640, + height: 480, + title: "recreated".into(), + geometry_hints: neovm_core::window::GuiFrameGeometryHints { + base_width: 0, + base_height: 0, + min_width: 1, + min_height: 1, + width_inc: 1, + height_inc: 1, + }, + }); + let replacement: Arc = + Arc::from(owner.create_window(Default::default()).unwrap()); + let replacement_id = replacement.id(); + let (_gate, receive) = crossbeam_channel::bounded(1); + app.gpu_startup = Some(PendingGpu::Adapters { + window: replacement, + reply: receive, + }); + app.window_event(owner, stale, WindowEvent::Destroyed); + assert_eq!( + app.gpu_startup.as_ref().unwrap().window_id(), + replacement_id + ); + assert_eq!(app.frame_windows.primary_event_frame_id(), 0x43); + app.window_event(owner, replacement_id, WindowEvent::CloseRequested); + assert!(!app.lifecycle_flags.is_shutting_down()); + } + // Ordinary non-daemon pending close still requests process shutdown. + let mut ordinary = make_test_app(); + let window: Arc = + Arc::from(owner.create_window(Default::default()).unwrap()); + let id = window.id(); + let (_gate, receive) = crossbeam_channel::bounded(1); + ordinary.gpu_startup = Some(PendingGpu::Adapters { + window, + reply: receive, + }); + ordinary.window_event(owner, id, WindowEvent::CloseRequested); + assert!(ordinary.lifecycle_flags.is_shutting_down()); + assert!( + ordinary + .gpu_startup_cancelled + .load(std::sync::atomic::Ordering::Relaxed) + ); + self.done.store(true, std::sync::atomic::Ordering::Relaxed); + owner.exit(); + } + } + let done = Arc::new(std::sync::atomic::AtomicBool::new(false)); + event_loop.run_app(Probe { done: done.clone() }).unwrap(); + assert!(done.load(std::sync::atomic::Ordering::Relaxed)); +} diff --git a/crates/neomacs-display-runtime/src/render_thread/window_commands.rs b/crates/neomacs-display-runtime/src/render_thread/window_commands.rs index 76c86a64c8..360c202502 100644 --- a/crates/neomacs-display-runtime/src/render_thread/window_commands.rs +++ b/crates/neomacs-display-runtime/src/render_thread/window_commands.rs @@ -7,6 +7,25 @@ use winit::dpi::PhysicalPosition; use winit::window::UserAttentionType; impl RenderApp { + pub(super) fn retire_cancelled_frames(&mut self) { + let cancelled: Vec<_> = self + .frame_leases + .iter() + .filter_map(|(frame, live)| { + (!live.load(std::sync::atomic::Ordering::Acquire)).then_some(*frame) + }) + .collect(); + for frame in cancelled { + self.frame_leases.remove(&frame); + if let Some(waits) = &self.comms.native_window_waits { + waits.remove(frame); + } + self.handle_window(WindowCommand::DestroyWindow { + frame: crate::thread_comm::FrameRef::Frame(frame), + }); + } + } + fn remove_pending_child_subtree(&mut self, frame_id: u64) { let mut subtree = std::collections::HashSet::from([frame_id]); loop { @@ -247,6 +266,57 @@ impl RenderApp { window.request_user_attention(attention); } } + WindowCommand::RealizeFrame { + frame, + width, + height, + title, + geometry_hints, + fullscreen, + visual, + adopt_primary, + reply, + live, + deadline, + } => { + if !live.load(std::sync::atomic::Ordering::Acquire) { + return; + } + let id = frame.raw_id(); + if let Some(waits) = &self.comms.native_window_waits { + if waits.terminal() { + let _ = reply.send(Err( + "Native connection closed during frame preparation".into(), + )); + return; + } + waits.register(id, live.clone(), deadline); + } + self.frame_leases.insert(id, live); + if let Some(visual) = visual { + self.handle_config(crate::thread_comm::ConfigCommand::SetVisualConfig(visual)); + } + if adopt_primary { + self.handle_window(WindowCommand::SetWindowTitle { title }); + self.handle_window(WindowCommand::SetFrameGeometryHints { + frame: crate::thread_comm::FrameRef::Primary, + geometry_hints, + }); + self.handle_window(WindowCommand::AdoptPrimaryFrame { frame }); + } else { + self.handle_window(WindowCommand::CreateWindow { + frame, + width, + height, + title, + geometry_hints, + }); + } + if let Some(mode) = fullscreen { + self.handle_window(WindowCommand::SetWindowFullscreen { frame, mode }); + } + self.frame_windows.await_ready(id, reply); + } WindowCommand::CreateWindow { frame, width, @@ -262,6 +332,20 @@ impl RenderApp { height, title ); + if self.gpu.is_none() + && self.comms.keep_alive_without_frames + && self.frame_windows.primary_window().is_none() + { + self.frame_windows.prepare_primary( + emacs_frame_id, + width, + height, + title, + Some(geometry_hints), + ); + self.pending_content_size = super::startup::InitialWindowSize { width, height }; + return; + } self.frame_windows.request_create( emacs_frame_id, width, @@ -297,6 +381,9 @@ impl RenderApp { .send_input(InputEvent::PresentationRetired { presentation }); } if self.frame_windows.is_primary_frame_id(emacs_frame_id) { + self.cancel_gpu_startup(); + self.frame_windows + .reject_ready(emacs_frame_id, "Native frame creation cancelled"); self.frame_windows.take_primary_window(); self.frame_windows.clear_primary_mapping(); } else { @@ -313,6 +400,9 @@ impl RenderApp { self.frame_coordinator .remove_window(super::frame_sched::NativeWindowId(0)); } + WindowCommand::AwaitFrameReady { frame, reply } => { + self.frame_windows.await_ready(frame.raw_id(), reply); + } WindowCommand::ShowChildFrame { frame_id } => { tracing::info!( frame_id, diff --git a/crates/neomacs-display-runtime/src/render_thread/window_events.rs b/crates/neomacs-display-runtime/src/render_thread/window_events.rs index fb0699d3ac..de0cec0c5d 100644 --- a/crates/neomacs-display-runtime/src/render_thread/window_events.rs +++ b/crates/neomacs-display-runtime/src/render_thread/window_events.rs @@ -223,7 +223,17 @@ impl RenderApp { emacs_fid, is_primary ); - if is_primary { + if self.comms.keep_alive_without_frames { + if is_primary { + self.frame_windows.take_primary_window(); + self.frame_windows.clear_primary_mapping(); + } else { + self.frame_windows.request_destroy(emacs_fid); + } + self.comms.send_input(InputEvent::WindowClose { + emacs_frame_id: emacs_fid, + }); + } else if is_primary { self.lifecycle_flags.request_shutdown( super::state::RenderShutdownReason::NativeWindowDestroyed, ); diff --git a/crates/neomacs-display-runtime/src/thread_comm.rs b/crates/neomacs-display-runtime/src/thread_comm.rs index bcce516900..bffe888429 100644 --- a/crates/neomacs-display-runtime/src/thread_comm.rs +++ b/crates/neomacs-display-runtime/src/thread_comm.rs @@ -394,8 +394,27 @@ pub enum WindowCommand { title: String, geometry_hints: GuiFrameGeometryHints, }, + /// One bounded admission owns creation, readiness and independent rollback. + RealizeFrame { + frame: FrameRef, + width: u32, + height: u32, + title: String, + geometry_hints: GuiFrameGeometryHints, + fullscreen: Option, + visual: Option, + adopt_primary: bool, + reply: Sender>, + live: std::sync::Arc, + deadline: std::time::Instant, + }, /// Associate the already-created primary OS window with its real Emacs frame ID. AdoptPrimaryFrame { frame: FrameRef }, + /// Acknowledge actual native window/surface realization, not queue admission. + AwaitFrameReady { + frame: FrameRef, + reply: Sender>, + }, /// Destroy an OS window for a top-level Emacs frame DestroyWindow { frame: FrameRef }, /// Mark a child frame visible again. @@ -1011,6 +1030,8 @@ impl ThreadComms { }; let render = RenderComms { + keep_alive_without_frames: false, + native_window_waits: None, input_stream: Default::default(), tooltip_context: self.tooltip_context, frame_rx: self.frame_rx, @@ -1040,6 +1061,9 @@ pub struct EmacsComms { /// Render thread communication handle pub struct RenderComms { + /// Daemon root, rather than the primary native frame, owns display lifetime. + pub keep_alive_without_frames: bool, + pub native_window_waits: Option>, input_stream: neomacs_display_protocol::input_progress::InputStream, pub frame_rx: FrameReceiver, pub cmd_rx: Receiver, @@ -1049,6 +1073,22 @@ pub struct RenderComms { } impl RenderComms { + pub(crate) fn create_window( + &self, + event_loop: &dyn winit::event_loop::ActiveEventLoop, + attrs: winit::window::WindowAttributes, + frame: u64, + ) -> Result, String> { + let create = || { + event_loop + .create_window(attrs) + .map_err(|error| error.to_string()) + }; + match &self.native_window_waits { + Some(waits) => waits.run(frame, create), + None => create(), + } + } fn observe_scroll_input(event: InputEvent) -> InputEvent { #[cfg(target_os = "linux")] if neomacs_display_protocol::input_latency::enabled() { From 6dd90e65708bb08c077a31d08a958bb58e8ed8fc Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Tue, 6 Oct 2026 09:03:18 +0300 Subject: [PATCH 3/6] feat(display): let a daemon open its display from Lisp A display host can now supply a GUI display initializer. When one is present, x-open-connection and a graphical x-create-frame ask it to connect to the requested display, or to WAYLAND_DISPLAY when none is given, on the evaluator thread. The resulting connection gets its own terminal, separate from the daemon's initial terminal and any TTY frames, and frames created on it use that terminal. Deleting the last frame does not delete it, so the next make-frame can reuse it. x-create-frame now waits until the host reports the native window ready, polling for quit, with a 15-second limit. If realization fails it deletes that exact frame, so no phantom frame is left behind. x-display-list reports the attached connection. A late WindowClose for a frame the daemon has already deleted is ignored, so it cannot end the root. --- .../src/emacs_core/display/display/mod.rs | 40 +++- .../display/tests/deferred_gui_test.rs | 223 ++++++++++++++++++ .../emacs_core/display/display/tests/mod.rs | 1 + .../emacs_core/display/display_host/mod.rs | 22 ++ .../src/emacs_core/display/terminal/pure.rs | 14 ++ .../src/emacs_core/display/window_cmds/mod.rs | 127 +++++++++- .../lisp/native/builtins/subrs/mod.rs | 4 +- .../src/emacs_core/runtime/eval/construct.rs | 1 + .../src/emacs_core/runtime/eval/gc_pacing.rs | 25 ++ .../src/emacs_core/runtime/eval/mod.rs | 7 + .../runtime/eval/pdump_reconstruct.rs | 1 + crates/neovm-core/src/keyboard.rs | 17 ++ crates/neovm-core/src/keyboard/tests/mod.rs | 28 +++ 13 files changed, 500 insertions(+), 10 deletions(-) create mode 100644 crates/neovm-core/src/emacs_core/display/display/tests/deferred_gui_test.rs diff --git a/crates/neovm-core/src/emacs_core/display/display/mod.rs b/crates/neovm-core/src/emacs_core/display/display/mod.rs index bf7b52e576..8e33a7430d 100644 --- a/crates/neovm-core/src/emacs_core/display/display/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/display/mod.rs @@ -1319,8 +1319,15 @@ pub(crate) fn builtin_display_supports_face_attributes_p( // --------------------------------------------------------------------------- /// (x-display-list) -> nil in batch-style vm context. -pub(crate) fn builtin_x_display_list(args: Vec) -> EvalResult { +pub(crate) fn builtin_x_display_list(eval: &Context, args: Vec) -> EvalResult { expect_max_args("x-display-list", &args, 0)?; + if let Some((_, identity)) = eval + .display_host + .as_ref() + .and_then(|host| host.gui_terminal()) + { + return Ok(Value::list(vec![Value::string(identity.terminal_name())])); + } Ok(Value::NIL) } @@ -3065,6 +3072,37 @@ pub(crate) fn builtin_x_open_connection( args: Vec, ) -> EvalResult { expect_args_range("x-open-connection", &args, 1, 3)?; + if eval.gui_display_initializer.is_some() { + let display = if args[0].is_nil() { + None + } else { + Some(display_string_text(&args[0]).ok_or_else(|| { + signal( + LispCondition::WrongTypeArgument, + vec![Value::symbol("stringp"), args[0]], + ) + })?) + }; + let resources = args + .get(1) + .filter(|value| !value.is_nil()) + .map(|resources| { + display_string_text(resources).ok_or_else(|| { + signal( + LispCondition::WrongTypeArgument, + vec![Value::symbol("stringp"), *resources], + ) + }) + }) + .transpose()?; + eval.initialize_gui_display(display.as_deref())?; + if let Some(resources) = resources { + if let Some(host) = eval.display_host.as_mut() { + host.set_gui_resource_database(&resources); + } + } + return Ok(Value::NIL); + } if x_window_system_active(eval) { if let Some(resources) = args.get(1).filter(|value| !value.is_nil()) { let resources = display_string_text(resources).ok_or_else(|| { diff --git a/crates/neovm-core/src/emacs_core/display/display/tests/deferred_gui_test.rs b/crates/neovm-core/src/emacs_core/display/display/tests/deferred_gui_test.rs new file mode 100644 index 0000000000..49f25623da --- /dev/null +++ b/crates/neovm-core/src/emacs_core/display/display/tests/deferred_gui_test.rs @@ -0,0 +1,223 @@ +use super::*; +use neomacs_display_protocol::{GraphicalBackend, GraphicalDisplayIdentity}; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +struct DeferredHost { + terminal: u64, + identity: GraphicalDisplayIdentity, + fail_frame: bool, + fail_completion: bool, + native_frames: Rc>>, +} + +impl DisplayHost for DeferredHost { + fn gui_terminal(&self) -> Option<(u64, GraphicalDisplayIdentity)> { + Some((self.terminal, self.identity.clone())) + } + fn gui_frame_metrics(&self) -> Option<(f32, f32, f32, f64)> { + Some((8.0, 16.0, 14.0, 1.0)) + } + fn realize_gui_frame(&mut self, request: GuiFrameHostRequest) -> Result<(), String> { + // Native command admission can precede a later realization failure. + self.native_frames.borrow_mut().push(request.frame_id); + if self.fail_frame { + Err("native frame refused".into()) + } else { + Ok(()) + } + } + fn resize_gui_frame(&mut self, _: GuiFrameHostRequest) -> Result<(), String> { + Ok(()) + } + fn destroy_gui_frame(&mut self, frame: crate::window::FrameId) -> Result<(), String> { + self.native_frames.borrow_mut().retain(|id| *id != frame); + Ok(()) + } + fn poll_gui_frame_ready(&mut self, _: crate::window::FrameId) -> Option> { + Some(if self.fail_completion { + Err("surface creation failed".into()) + } else { + Ok(()) + }) + } +} + +fn deferred_host(fail_frame: bool) -> DeferredHost { + let identity = + GraphicalDisplayIdentity::named(GraphicalBackend::Wayland, "wayland-owned").unwrap(); + let terminal = crate::emacs_core::terminal::pure::register_graphical_terminal(identity.clone()); + DeferredHost { + terminal, + identity, + fail_frame, + fail_completion: false, + native_frames: Rc::new(RefCell::new(Vec::new())), + } +} + +#[test] +fn deferred_gui_failed_open_preserves_context_and_can_retry() { + reset_terminal_thread_locals(); + let mut eval = Context::new(); + eval.eval_str("(setq preserved-state (list 7 11))").unwrap(); + let requests = Rc::new(Cell::new(0)); + let calls = requests.clone(); + eval.set_gui_display_initializer(Box::new(move |eval, display| { + assert_eq!(display, Some("wayland-owned")); + let n = calls.get(); + calls.set(n + 1); + if n == 0 { + return Err(crate::emacs_core::error::EvalError::signal( + intern("error"), + vec![Value::string("no display yet")], + None, + )); + } + if eval.display_host.is_none() { + eval.set_display_host(Box::new(deferred_host(false))); + } + Ok(()) + })); + assert!( + eval.eval_str("(x-open-connection \"wayland-owned\")") + .is_err() + ); + assert!( + eval.eval_str("(equal preserved-state '(7 11))") + .unwrap() + .is_truthy() + ); + eval.eval_str("(x-open-connection \"wayland-owned\")") + .unwrap(); + assert_eq!(requests.get(), 2); + assert!( + eval.eval_str("(equal preserved-state '(7 11))") + .unwrap() + .is_truthy() + ); + assert!(eval.shutdown_request().is_none()); +} + +#[test] +fn deferred_gui_open_is_not_frame_creation_and_validates_designators() { + reset_terminal_thread_locals(); + let mut eval = Context::new(); + eval.eval_str("(selected-frame)").unwrap(); + let before = eval.frames.frame_list(); + let requests = Rc::new(Cell::new(0)); + let calls = requests.clone(); + eval.set_gui_display_initializer(Box::new(move |eval, _| { + calls.set(calls.get() + 1); + eval.set_display_host(Box::new(deferred_host(false))); + Ok(()) + })); + assert!(eval.eval_str("(x-open-connection 42)").is_err()); + assert_eq!(requests.get(), 0); + eval.eval_str("(x-open-connection \"wayland-owned\")") + .unwrap(); + assert_eq!(eval.frames.frame_list(), before); + assert!( + eval.eval_str("(equal (x-display-list) '(\"wayland-owned\"))") + .unwrap() + .is_truthy() + ); +} + +#[test] +fn deferred_gui_delete_recreate_retains_separate_initial_terminal() { + reset_terminal_thread_locals(); + let mut eval = Context::new(); + let initial = eval.eval_str("(selected-frame)").unwrap(); + let initial_terminal = builtin_frame_terminal(&mut eval, vec![initial]).unwrap(); + let host = deferred_host(false); + let gui_terminal = host.terminal; + eval.set_display_host(Box::new(host)); + let frame = eval + .eval_str("(x-create-frame '((width . 40) (height . 20)))") + .unwrap(); + let fid = crate::window::FrameId(frame.as_frame_id().unwrap()); + assert_eq!(eval.frames.get(fid).unwrap().terminal_id, gui_terminal); + assert_eq!(eval.frames.get(fid).unwrap().char_width, 8.0); + assert!(eval.frames.get(fid).unwrap().width >= 320); + assert_ne!( + builtin_frame_terminal(&mut eval, vec![frame]).unwrap(), + initial_terminal + ); + eval.set_variable("owned-frame", frame); + eval.eval_str("(setq owned-terminal (frame-terminal owned-frame))") + .unwrap(); + eval.eval_str("(delete-frame owned-frame t)").unwrap(); + assert!( + eval.eval_str("(terminal-live-p owned-terminal)") + .unwrap() + .is_truthy(), + "the native connection must retain its terminal after its last frame closes" + ); + assert!(eval.shutdown_request().is_none()); + assert!( + eval.frames + .get(crate::window::FrameId(initial.as_frame_id().unwrap())) + .is_some() + ); + assert_eq!( + builtin_terminal_name(&mut eval, vec![initial_terminal]) + .unwrap() + .as_lisp_string() + .unwrap() + .as_utf8_str(), + Some("initial_terminal") + ); + let next = eval.eval_str("(x-create-frame nil)").unwrap(); + assert_ne!(next, frame); + eval.set_variable("recreated-frame", next); + assert!( + eval.eval_str("(and (eq (frame-terminal recreated-frame) owned-terminal) (terminal-live-p (frame-terminal recreated-frame)))") + .unwrap() + .is_truthy() + ); + assert_eq!( + eval.frames + .get(crate::window::FrameId(next.as_frame_id().unwrap())) + .unwrap() + .terminal_id, + gui_terminal + ); + assert!( + eval.eval_str("(equal (x-display-list) '(\"wayland-owned\"))") + .unwrap() + .is_truthy() + ); +} + +#[test] +fn deferred_gui_failed_surface_completion_rolls_back_frame() { + reset_terminal_thread_locals(); + let mut eval = Context::new(); + eval.eval_str("(selected-frame)").unwrap(); + let before = eval.frames.frame_list(); + let mut host = deferred_host(false); + host.fail_completion = true; + eval.set_display_host(Box::new(host)); + assert!(eval.eval_str("(x-create-frame nil)").is_err()); + assert_eq!(eval.frames.frame_list(), before); + assert!(eval.shutdown_request().is_none()); +} + +#[test] +fn deferred_gui_failed_native_frame_does_not_publish_a_lisp_frame() { + reset_terminal_thread_locals(); + let mut eval = Context::new(); + eval.eval_str("(selected-frame)").unwrap(); + let before = eval.frames.frame_list(); + let host = deferred_host(true); + let native_frames = host.native_frames.clone(); + eval.set_display_host(Box::new(host)); + assert!(eval.eval_str("(x-create-frame nil)").is_err()); + assert_eq!(eval.frames.frame_list(), before); + assert!( + native_frames.borrow().is_empty(), + "partial native admission must be rolled back" + ); + assert!(eval.shutdown_request().is_none()); +} diff --git a/crates/neovm-core/src/emacs_core/display/display/tests/mod.rs b/crates/neovm-core/src/emacs_core/display/display/tests/mod.rs index ff7ac8be22..82c2ed2eac 100644 --- a/crates/neovm-core/src/emacs_core/display/display/tests/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/display/tests/mod.rs @@ -1,4 +1,5 @@ use crate::emacs_core::error::{FlowKind, FlowResultExt as _}; +mod deferred_gui_test; mod menu_buttons_test; mod menu_semantics_test; mod menu_submenu_test; diff --git a/crates/neovm-core/src/emacs_core/display/display_host/mod.rs b/crates/neovm-core/src/emacs_core/display/display_host/mod.rs index 1c3bc3ae5e..68d86b5bfe 100644 --- a/crates/neovm-core/src/emacs_core/display/display_host/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/display_host/mod.rs @@ -299,6 +299,20 @@ impl GuiResourceQuery { } pub trait DisplayHost { + /// A deferred GUI connection owns a terminal independently of the initial + /// daemon terminal and any attached TTYs. + fn gui_terminal(&self) -> Option<(u64, neomacs_display_protocol::GraphicalDisplayIdentity)> { + None + } + + /// Font-owned geometry for frames opened from a non-graphical selection. + fn gui_frame_metrics(&self) -> Option<(f32, f32, f32, f64)> { + None + } + + fn default_gui_font(&self) -> Option<&str> { + None + } #[cfg(target_os = "macos")] fn ns_resource(&self, _name: &str) -> Option { None @@ -321,6 +335,14 @@ pub trait DisplayHost { } fn realize_gui_frame(&mut self, request: GuiFrameHostRequest) -> Result<(), String>; + /// Pending until the native window and render surface exist. Legacy hosts + /// realize synchronously; asynchronous native hosts must report completion. + fn poll_gui_frame_ready( + &mut self, + _frame: crate::window::FrameId, + ) -> Option> { + Some(Ok(())) + } fn resize_gui_frame(&mut self, request: GuiFrameHostRequest) -> Result<(), String>; /// Whether this concrete graphical backend can represent ATTRIBUTE. /// diff --git a/crates/neovm-core/src/emacs_core/display/terminal/pure.rs b/crates/neovm-core/src/emacs_core/display/terminal/pure.rs index 57cbbf3d2c..5fc7024ab0 100644 --- a/crates/neovm-core/src/emacs_core/display/terminal/pure.rs +++ b/crates/neovm-core/src/emacs_core/display/terminal/pure.rs @@ -597,6 +597,20 @@ pub(crate) fn next_terminal_id() -> u64 { }) } +/// Register a native graphical connection without changing the daemon's +/// initial terminal or any existing TTY frame. +pub fn register_graphical_terminal( + identity: neomacs_display_protocol::GraphicalDisplayIdentity, +) -> u64 { + let id = next_terminal_id(); + ensure_terminal_runtime_owner( + id, + identity.terminal_name().to_owned(), + TerminalRuntimeConfig::window_system(identity), + ); + id +} + /// GNU `get_named_terminal`: find an active termcap terminal already owning /// DEVICE so a second frame shares its renderer, input source, and kboard /// instead of opening the same tty twice. diff --git a/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs b/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs index e690f5e37a..14ec396e42 100644 --- a/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs @@ -7048,12 +7048,31 @@ pub(crate) fn builtin_x_create_frame( mut args: Vec, ) -> EvalResult { expect_args("x-create-frame", &args, 1)?; - if eval.daemon.is_some() && eval.display_host.is_none() { + if eval.gui_display_initializer.is_some() { + let display = parse_gui_frame_params(args.first()) + .all + .get(&intern("display")) + .copied(); + let display = display + .filter(|value| !value.is_nil()) + .map(|value| { + value + .as_lisp_string() + .and_then(|text| text.as_utf8_str()) + .map(str::to_owned) + .ok_or_else(|| { + signal( + LispCondition::WrongTypeArgument, + vec![Value::symbol("stringp"), value], + ) + }) + }) + .transpose()?; + eval.initialize_gui_display(display.as_deref())?; + } else if eval.daemon.is_some() && eval.display_host.is_none() { return Err(signal( "error", - vec![Value::string( - "Graphical frames are not yet supported by the headless Neomacs daemon; use a TTY client", - )], + vec![Value::string("Graphical display host unavailable")], )); } // GNU gui_display_get_arg resolves frame alist, default-frame-alist, @@ -7081,6 +7100,15 @@ pub(crate) fn builtin_x_create_frame( }; if let Some(font) = font { args[0] = Value::cons(Value::cons(Value::symbol("font"), font), args[0]); + } else if let Some(font) = eval + .display_host + .as_ref() + .and_then(|host| host.default_gui_font()) + { + args[0] = Value::cons( + Value::cons(Value::symbol("font"), Value::string(font)), + args[0], + ); } } tracing::debug!( @@ -7097,6 +7125,40 @@ pub(crate) fn builtin_x_create_frame( &mut eval.display_host, args, ); + let result = result.and_then(|frame| { + let fid = FrameId(frame.as_frame_id().expect("x-create-frame returns a frame")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + let ready = loop { + if let Err(flow) = eval.maybe_quit() { + break Err(flow); + } + match eval + .display_host + .as_mut() + .map(|host| host.poll_gui_frame_ready(fid)) + .unwrap_or(Some(Ok(()))) + { + Some(Ok(())) => break Ok(frame), + Some(Err(message)) => break Err(signal("error", vec![Value::string(message)])), + None if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(5)) + } + None => { + break Err(signal( + "error", + vec![Value::string("Native frame creation timed out")], + )); + } + } + }; + if ready.is_err() { + if let Some(host) = eval.display_host.as_mut() { + let _ = host.destroy_gui_frame(fid); + } + let _ = eval.frames.delete_frame(fid); + } + ready + }); eval.sync_keyboard_terminal_owner(); result } @@ -7136,10 +7198,17 @@ pub(crate) fn x_create_frame_impl( Some((public_font, font_parameter)) }) }; + let gui_terminal = display_host.as_ref().and_then(|host| host.gui_terminal()); let inherited_display_identity = parent_id .and_then(|parent_id| frames.get(parent_id)) .or_else(|| frames.selected_frame()) + .filter(|frame| frame.effective_window_system().is_some()) .map(|frame| frame.display_identity().clone()) + .or_else(|| { + gui_terminal.as_ref().map(|(_, identity)| { + crate::window::FrameDisplayIdentity::Graphical(identity.clone()) + }) + }) .unwrap_or_default(); let metrics = parent_id .and_then(|parent_id| frames.get(parent_id)) @@ -7158,6 +7227,24 @@ pub(crate) fn x_create_frame_impl( // `make-frame`); see current_gui_frame_metrics_in_state. .unwrap_or_else(|| parent.char_height.max(1.0)), }) + .or_else(|| { + display_host + .as_ref() + .and_then(|host| host.gui_frame_metrics()) + .map( + |(char_width, char_height, font_pixel_size, device_scale_factor)| { + GuiFrameMetrics { + width_px: 80 * char_width as u32, + height_px: 40 * char_height as u32, + char_width, + char_height, + font_pixel_size, + device_scale_factor, + minibuffer_height: char_height, + } + }, + ) + }) .unwrap_or_else(|| current_gui_frame_metrics_in_state(frames)); let host_size = current_primary_window_size(&*display_host); let opening_frame_adoption = display_host @@ -7230,7 +7317,17 @@ pub(crate) fn x_create_frame_impl( } else { buffers.find_buffer_by_name(" *Minibuf-0*") }; - let fid = frames.create_frame_value(name, width_px, height_px, current_buffer_id); + let fid = if let Some((terminal_id, _)) = gui_terminal { + frames.create_frame_value_on_terminal( + name, + terminal_id, + width_px, + height_px, + current_buffer_id, + ) + } else { + frames.create_frame_value(name, width_px, height_px, current_buffer_id) + }; { let frame = frames .get_mut(fid) @@ -7354,15 +7451,21 @@ pub(crate) fn x_create_frame_impl( .get(fid) .map(|frame| frame.gui_geometry_hints()) .ok_or_else(|| signal("error", vec![Value::string("Frame not found")]))?; - host.realize_gui_frame(super::eval::GuiFrameHostRequest { + let realized = host.realize_gui_frame(super::eval::GuiFrameHostRequest { frame_id: fid, width: width_px, height: height_px, title: host_title, geometry_hints, fullscreen: parsed.fullscreen, - }) - .map_err(|message| signal("error", vec![Value::string(message)]))?; + }); + if let Err(message) = realized { + // Realization can admit native work before a later step fails. + // Retire that exact frame even when there is no readiness receiver. + let _ = host.destroy_gui_frame(fid); + let _ = frames.delete_frame(fid); + return Err(signal("error", vec![Value::string(message)])); + } } if is_child_frame { tracing::info!( @@ -7585,7 +7688,15 @@ pub(crate) fn delete_frame_owned( .get(frame_id) .is_none_or(|frame| frame.terminal_id != terminal_id) }); + // A live display connection owns its terminal independently of its frames. + // Closing the last GUI frame must not invalidate the next make-frame. + let terminal_owned_by_display = eval + .display_host + .as_ref() + .and_then(|host| host.gui_terminal()) + .is_some_and(|(id, _)| id == terminal_id); if mode.allows_terminal_cascade() + && !terminal_owned_by_display && terminal_is_empty && !eval.frames.frame_list().is_empty() && let Some(terminal) = diff --git a/crates/neovm-core/src/emacs_core/lisp/native/builtins/subrs/mod.rs b/crates/neovm-core/src/emacs_core/lisp/native/builtins/subrs/mod.rs index 3bbf66efcd..c6c25d1922 100644 --- a/crates/neovm-core/src/emacs_core/lisp/native/builtins/subrs/mod.rs +++ b/crates/neovm-core/src/emacs_core/lisp/native/builtins/subrs/mod.rs @@ -7954,7 +7954,9 @@ pub(crate) fn register_subrs(ctx: &mut crate::emacs_core::eval::Context) { )); ctx.register_subr(SubrSpec::new( "x-display-list", - NativeFn::ContextVec(|_ctx, args| crate::emacs_core::display::builtin_x_display_list(args)), + NativeFn::ContextVec(|ctx, args| { + crate::emacs_core::display::builtin_x_display_list(ctx, args) + }), SubrArity::new(0, Some(0)), )); ctx.register_subr(SubrSpec::new( diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/construct.rs b/crates/neovm-core/src/emacs_core/runtime/eval/construct.rs index b380947a9a..b4635955c4 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/construct.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/construct.rs @@ -2263,6 +2263,7 @@ impl Context { scroll_goal: None, display_host: None, tty_frame_host_factory: None, + gui_display_initializer: None, visual_config: neomacs_display_protocol::VisualConfig::default(), pending_menu_bar_popup_anchor: None, coding_systems: CodingSystemManager::new(), diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/gc_pacing.rs b/crates/neovm-core/src/emacs_core/runtime/eval/gc_pacing.rs index 9750dcde8a..4b3f828825 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/gc_pacing.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/gc_pacing.rs @@ -765,4 +765,29 @@ impl Context { pub fn set_tty_frame_host_factory(&mut self, factory: Box) { self.tty_frame_host_factory = Some(factory); } + + /// Supply the native display boundary without coupling VM startup to a + /// display connection. The callback runs synchronously on the Lisp thread. + pub fn set_gui_display_initializer(&mut self, initializer: super::GuiDisplayInitializer) { + self.gui_display_initializer = Some(initializer); + } + + pub(crate) fn initialize_gui_display(&mut self, display: Option<&str>) -> Result<(), Flow> { + let Some(mut initializer) = self.gui_display_initializer.take() else { + return Err(crate::emacs_core::error::signal( + "error", + vec![Value::string("Graphical display host unavailable")], + )); + }; + let result = initializer(self, display); + self.gui_display_initializer = Some(initializer); + result.map_err(crate::emacs_core::error::flow_from_eval_error) + } + + /// Frontend-owned synchronous waits retain GNU quit/supervisor signal + /// handling on the evaluator thread, never in a native callback. + pub fn poll_host_wait(&mut self) -> Result<(), EvalError> { + self.maybe_quit() + .map_err(crate::emacs_core::error::map_flow) + } } diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs b/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs index e842211b86..4d245aa057 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs @@ -3030,6 +3030,10 @@ pub(crate) struct DaemonState { pub(crate) notify: Option, } +/// Display opener invoked only on the owning evaluator thread. +pub type GuiDisplayInitializer = + Box) -> Result<(), EvalError>>; + pub struct Context { pub(crate) owned_roots: crate::emacs_core::owned_roots::OwnedRootRegistry, /// Tagged pointer heap — sole GC and allocator. @@ -3400,6 +3404,9 @@ pub struct Context { /// `make-terminal-frame`. The VM owns identities; platform code owns the /// device, raw-mode, input, renderer, and lifecycle resources. pub(crate) tty_frame_host_factory: Option>, + /// Installed by a display-free frontend. Invoked on this evaluator's + /// owning thread; neither the Context nor loaded modules migrate. + pub(crate) gui_display_initializer: Option, /// Desired visual configuration. Lisp updates this snapshot atomically; /// attaching or rebuilding a display replays it as authoritative state. pub(crate) visual_config: neomacs_display_protocol::VisualConfig, diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/pdump_reconstruct.rs b/crates/neovm-core/src/emacs_core/runtime/eval/pdump_reconstruct.rs index 9ccc458e27..f26efb1333 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/pdump_reconstruct.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/pdump_reconstruct.rs @@ -159,6 +159,7 @@ impl Context { scroll_goal: None, display_host: None, tty_frame_host_factory: None, + gui_display_initializer: None, visual_config: neomacs_display_protocol::VisualConfig::default(), pending_menu_bar_popup_anchor: None, coding_systems, diff --git a/crates/neovm-core/src/keyboard.rs b/crates/neovm-core/src/keyboard.rs index 59824ed7e7..e72be3cf67 100644 --- a/crates/neovm-core/src/keyboard.rs +++ b/crates/neovm-core/src/keyboard.rs @@ -4360,11 +4360,28 @@ impl crate::emacs_core::eval::Context { emacs_frame_id: u64, ) -> Result<(), crate::emacs_core::error::Flow> { self.timer_resume_idle(); + // A late event for a retired daemon GUI frame cannot terminate the root. + if self.daemon.is_some() + && emacs_frame_id != 0 + && self + .frames + .get(crate::window::FrameId(emacs_frame_id)) + .is_none() + { + return Ok(()); + } if let Some(event) = self.make_lispy_delete_frame_event(emacs_frame_id) && self.execute_special_event_if_bound(event)? { return Ok(()); } + if self.daemon.is_some() && emacs_frame_id != 0 { + crate::emacs_core::frame::builtin_delete_frame( + self, + vec![Value::make_frame(emacs_frame_id)], + )?; + return Ok(()); + } self.command_loop.running = false; Err(crate::emacs_core::error::signal( LispCondition::Quit, diff --git a/crates/neovm-core/src/keyboard/tests/mod.rs b/crates/neovm-core/src/keyboard/tests/mod.rs index a424a59d62..e46b221e64 100644 --- a/crates/neovm-core/src/keyboard/tests/mod.rs +++ b/crates/neovm-core/src/keyboard/tests/mod.rs @@ -21,6 +21,34 @@ fn settled_point( use super::*; +#[test] +fn deferred_gui_daemon_close_and_stale_close_do_not_stop_root() { + let mut eval = crate::emacs_core::Context::new(); + eval.configure_daemon(Some("close-test".into()), None); + eval.command_loop.running = true; + let initial = eval + .eval_str("(selected-frame)") + .unwrap() + .as_frame_id() + .unwrap(); + let buffer = eval.buffer_manager_mut().create_buffer("owned-gui"); + let gui = eval + .frame_manager_mut() + .create_frame("owned-gui", 320, 200, buffer); + eval.handle_window_close_input_event(gui.0).unwrap(); + assert!(eval.frame_manager().get(gui).is_none()); + assert!( + eval.frame_manager() + .get(crate::window::FrameId(initial)) + .is_some() + ); + assert!(eval.command_loop.running); + assert!(eval.shutdown_request().is_none()); + eval.handle_window_close_input_event(gui.0).unwrap(); + assert!(eval.command_loop.running); + assert!(eval.shutdown_request().is_none()); +} + #[test] fn discrete_scroll_preserves_horizontal_direction_and_multiple_steps() { let mut eval = crate::emacs_core::Context::new(); From c45868a89d24bd3d2bca78e2b3a71d8316113216 Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Tue, 6 Oct 2026 09:03:18 +0300 Subject: [PATCH 4/6] feat(daemon): attach native GUI frames to a display-free daemon Run every daemon in two parts: the evaluator runs on a persistent worker thread, and the OS main thread owns a native display that starts out empty. The first graphical request, from make-frame, x-open-connection or a client -c, connects on main. Startup does not need DISPLAY or WAYLAND_DISPLAY to be set. After connecting, frames are realized on the same evaluator, so Lisp and dynamic-module state carry over. Closing the last GUI frame leaves the daemon and its connection running, and a later frame request opens a new one. Attaching is limited to a single Wayland connection. X11 and a second display are rejected with an error. Native attach and frame readiness are bounded and can be cancelled, and a failed attach leaves the daemon as it was. neo-win.el now reads WAYLAND_DISPLAY before DISPLAY when it picks the display name. --- crates/neomacs/src/daemon.rs | 8 +- crates/neomacs/src/deferred_gui.rs | 629 +++++++++++++++++++++ crates/neomacs/src/deferred_gui_control.rs | 140 +++++ crates/neomacs/src/main.rs | 232 +++++++- crates/neomacs/src/tests/main_test.rs | 25 + crates/neomacs/tests/daemon_lifecycle.rs | 38 ++ docs/daemon.md | 17 +- lisp/term/neo-win.el | 3 +- 8 files changed, 1071 insertions(+), 21 deletions(-) create mode 100644 crates/neomacs/src/deferred_gui.rs create mode 100644 crates/neomacs/src/deferred_gui_control.rs diff --git a/crates/neomacs/src/daemon.rs b/crates/neomacs/src/daemon.rs index 05c96448fb..8e53930ace 100644 --- a/crates/neomacs/src/daemon.rs +++ b/crates/neomacs/src/daemon.rs @@ -211,12 +211,15 @@ pub(super) fn prepare(options: Option<&Options>) -> Result ! { +pub(super) fn restart(args: &[OsString], bypass_finalizers: bool) -> ! { #[cfg(unix)] { use std::os::unix::process::CommandExt; let executable = std::env::current_exe().unwrap_or_else(|error| { eprintln!("neomacs: cannot restart: {error}"); + if bypass_finalizers { + super::exit_cancelled_gui_startup(1); + } std::process::exit(1); }); let mut command = std::process::Command::new(executable); @@ -249,6 +252,9 @@ pub(super) fn restart(args: &[OsString]) -> ! { let error = command.exec(); eprintln!("neomacs: cannot restart: {error}"); } + if bypass_finalizers { + super::exit_cancelled_gui_startup(1); + } std::process::exit(1); } diff --git a/crates/neomacs/src/deferred_gui.rs b/crates/neomacs/src/deferred_gui.rs new file mode 100644 index 0000000000..9f18e2aff4 --- /dev/null +++ b/crates/neomacs/src/deferred_gui.rs @@ -0,0 +1,629 @@ +//! Display-free daemon startup with native GUI ownership on the OS main thread. +//! The evaluator (including dynamic-module TLS) is created once on its worker. + +use super::*; +use crossbeam_channel::{Receiver, Sender}; +use neomacs_display_protocol::GraphicalDisplayIdentity; +use neomacs_display_runtime::render_thread::{InitialWindowLifetime, InitialWindowReply}; +use std::cell::RefCell; +use std::sync::atomic::AtomicU64; +#[path = "deferred_gui_control.rs"] +mod control; + +pub(super) struct FrameDefaults { + pub terminal_id: u64, + pub identity: GraphicalDisplayIdentity, + pub metrics: BootstrapFrameMetrics, + pub font: String, + pub frame_id: Arc, + pub initial: Rc>>, + pub ready: HashMap>>, + pub leases: HashMap>, + pub visual: RefCell>, +} + +pub(super) enum InitialWindow { + Pending(InitialWindowReply), + Ready { _lifetime: InitialWindowLifetime }, +} + +#[derive(Clone)] +pub(super) struct DisplaySender { + open: Sender, + stop: Sender<()>, + control: Arc, +} +#[derive(Debug, PartialEq, Eq)] +struct DisplaySelection { + name: Option, + runtime: Option, +} + +impl DisplaySelection { + #[cfg(target_os = "linux")] + fn socket(&self) -> Result { + let name = self.name.as_deref().unwrap_or("wayland-0"); + if name.is_empty() || name.starts_with(':') { + return Err( + "Deferred explicit X11 displays are not supported; request a Wayland socket".into(), + ); + } + let path = std::path::PathBuf::from(name); + if path.is_absolute() { + return Ok(path); + } + if path.components().count() != 1 { + return Err("A relative Wayland display must be a socket name".into()); + } + let runtime = self + .runtime + .as_deref() + .ok_or("Lisp XDG_RUNTIME_DIR is required for a relative Wayland display")?; + let runtime = std::path::Path::new(runtime); + if !runtime.is_absolute() { + return Err("Lisp XDG_RUNTIME_DIR must be absolute".into()); + } + Ok(runtime.join(path)) + } +} + +fn lisp_environment( + eval: &mut Context, + name: &str, +) -> Result, neovm_core::emacs_core::error::EvalError> { + // Fixed, internal names only. Read the evaluator's dynamically bound Lisp + // environment on its owning worker, never the OS-main native environment. + let value = eval.eval_str(&format!("(getenv-internal \"{name}\")"))?; + Ok(value.as_str_owned()) +} + +struct Request { + display: DisplaySelection, + reply: Sender>, + attempt: Arc, +} + +pub(super) struct Opened { + display: BootstrapDisplayConfig, + observer: neomacs_display_runtime::font_defaults::FontDefaultsObserver, + comms: EmacsComms, + images: SharedImageRenderState, + reply: InitialWindowReply, + waker: GuiEventLoopWaker, + alive: Arc, + #[cfg(feature = "neo-term")] + terminals: SharedTerminals, +} + +struct Attached { + native: Rc>>, + _fonts: neomacs_display_runtime::font_defaults::FontDefaultsObserver, +} + +pub(super) struct Lifetime(Rc>>); +impl Drop for Lifetime { + fn drop(&mut self) { + self.0.borrow_mut().take(); + } +} + +impl Drop for Attached { + fn drop(&mut self) { + self.native.borrow_mut().take(); + } +} + +struct StopOnExit(DisplaySender); +impl Drop for StopOnExit { + fn drop(&mut self) { + self.0.control.stopped.store(true, Ordering::Release); + self.0.control.wake(); + let _ = self.0.stop.try_send(()); + } +} + +pub(super) fn run_daemon( + mode: RuntimeMode, + startup: StartupOptions, + bootstrap: BootstrapDisplayConfig, + notifier: Option, + started: Instant, + args: Vec, +) { + let (open, rx) = crossbeam_channel::bounded(1); + let (stop, stopped) = crossbeam_channel::bounded(1); + let control = Arc::new(control::Control::default()); + let controller = control + .start() + .expect("Failed to spawn native cancellation owner"); + let tx = DisplaySender { + open, + stop, + control: control.clone(), + }; + let restart_args = args.clone(); + let worker = std::thread::Builder::new() + .name("neomacs-evaluator".into()) + .stack_size(GUI_EVALUATOR_THREAD_STACK_SIZE) + .spawn(move || { + let _stop = StopOnExit(tx.clone()); + run_tty_evaluator(mode, startup, bootstrap, notifier, started, args, Some(tx)) + }) + .expect("Failed to spawn daemon evaluator"); + let mut disposition = run_native(rx, stopped, &control); + tracing::info!("daemon native root retired before evaluator join/re-exec"); + control.stopped.store(true, Ordering::Release); + controller + .join() + .expect("Native cancellation owner panicked"); + // Native owner has released the event loop before any exit or re-exec. + let exit = match worker.join() { + Ok(exit) => exit, + Err(payload) => { + if disposition.bypass_finalizers { + exit_cancelled_gui_startup(101); + } + std::panic::resume_unwind(payload); + } + }; + disposition.bypass_finalizers |= control.foreign_pending.load(Ordering::Acquire); + if exit.restart { + daemon::restart(&restart_args, disposition.bypass_finalizers); + } + if disposition.bypass_finalizers { + exit_cancelled_gui_startup(exit.exit_code); + } + if exit.exit_code != 0 { + std::process::exit(exit.exit_code); + } +} + +#[derive(Default)] +struct NativeDisposition { + bypass_finalizers: bool, +} +impl NativeDisposition { + #[cfg(test)] + fn observe( + &mut self, + result: &Result< + neomacs_display_runtime::render_thread::RenderLoopExit, + neomacs_display_runtime::render_thread::RenderLoopError, + >, + ) { + use neomacs_display_runtime::render_thread::{RenderLoopError, RenderLoopExit}; + self.bypass_finalizers |= matches!( + result, + Ok(RenderLoopExit::GpuStartupCancelled) | Err(RenderLoopError::StartupInterrupted(_)) + ); + } +} + +fn run_native( + rx: Receiver, + stopped: Receiver<()>, + control: &Arc, +) -> NativeDisposition { + use neomacs_display_runtime::render_thread::DaemonRenderRoot; + let mut root: Option<(DisplaySelection, DaemonRenderRoot)> = None; + let mut alive: Option> = None; + let mut connection_closed = false; + loop { + if control.stopped.load(Ordering::Acquire) || stopped.try_recv().is_ok() { + break; + } + let request = if let Some((_, native)) = root.as_mut() { + if !connection_closed && (control.window_waits.terminal() || !native.pump()) { + connection_closed = true; + native.retire(); + if let Some(alive) = &alive { + alive.store(false, Ordering::Release); + } + } + match rx.try_recv() { + Ok(request) => request, + Err(crossbeam_channel::TryRecvError::Empty) => { + if connection_closed { + std::thread::sleep(Duration::from_millis(20)); + } + continue; + } + Err(_) => break, + } + } else { + crossbeam_channel::select_biased! { + recv(stopped) -> _ => break, + recv(rx) -> request => match request { Ok(request) => request, Err(_) => break }, + } + }; + let Request { + display: selection, + reply, + attempt, + } = request; + if control.cancelled(&attempt) { + let _ = reply.send(Err("Native display preparation cancelled".into())); + continue; + } + if root + .as_ref() + .is_some_and(|(display, _)| display != &selection) + || connection_closed + { + let _ = reply.send(Err( + "The retained native connection supports only its original live display".into(), + )); + continue; + } + if root.is_some() { + control.constructed(&attempt); + } + control.begin(attempt.clone()); + #[cfg(target_os = "linux")] + let observer = neomacs_display_runtime::font_defaults::observe_font_defaults_controlled( + &|| control.cancelled(&attempt), + control.foreign_pending.clone(), + ); + + #[cfg(target_os = "linux")] + let observer = match observer { + Ok(observer) => observer, + Err(error) => { + let _ = reply.send(Err(error.to_string())); + continue; + } + }; + if root.is_none() { + #[cfg(target_os = "linux")] + let event_loop = selection.socket().and_then(|socket| { + let stream = control.connect(&socket, &attempt)?; + neomacs_display_runtime::render_thread::build_render_event_loop_wayland_stream( + stream, &socket, + ) + }); + #[cfg(not(target_os = "linux"))] + let event_loop = if selection.name.is_some() { + Err("Explicit deferred displays are supported only on Linux Wayland".into()) + } else { + build_render_event_loop() + }; + match event_loop { + Ok(event_loop) => { + // Root ownership precedes every remaining fallible phase. + root = Some((selection, DaemonRenderRoot::new(event_loop))); + control.constructed(&attempt); + control.install_proxy(root.as_ref().unwrap().1.proxy()); + } + Err(error) => { + let _ = reply.send(Err(error)); + continue; + } + } + } + let (selection, native) = root.as_mut().unwrap(); + if control.cancelled(&attempt) { + let _ = reply.send(Err("Native display preparation cancelled".into())); + continue; + } + let resolver = + neomacs_display_runtime::display_identity::DisplayIdentityResolver::explicit_wayland( + selection.name.clone().unwrap_or_else(|| "wayland-0".into()), + ); + let observation = observe_event_loop_display(native.event_loop()); + let system_name = hostname::get() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "localhost".into()); + let identity = match resolver.resolve(native.event_loop(), &system_name) { + Ok(identity) => identity, + Err(error) => { + let _ = reply.send(Err(format!("Cannot resolve display: {error:?}"))); + continue; + } + }; + #[cfg(not(target_os = "linux"))] + let observer = + match neomacs_display_runtime::font_defaults::observe_font_defaults(identity.backend()) + { + Ok(observer) => observer, + Err(error) => { + let _ = reply.send(Err(error.to_string())); + continue; + } + }; + let mut display = bootstrap_gui_display_config( + Interactivity::from_noninteractive(false), + gui_frame_font_scale_from_observation(observation), + identity, + ); + display.font_defaults = observer.initial().clone(); + let (initial_reply, initial_rx) = InitialWindowReply::channel(native.proxy()); + let (emacs, mut render) = ThreadComms::new().split(); + render.keep_alive_without_frames = true; + render.native_window_waits = Some(control.window_waits.clone()); + let images = Arc::new(neomacs_display_runtime::render_thread::ImageRenderState::default()); + let monitors = Arc::new((Mutex::new(Vec::new()), Condvar::new())); + let current_alive = Arc::new(AtomicBool::new(true)); + #[cfg(feature = "neo-term")] + let terminals = new_shared_terminals(); + native.install( + render, + initial_rx, + images.clone(), + monitors, + #[cfg(feature = "neo-term")] + terminals.clone(), + ); + alive = Some(current_alive.clone()); + let opened = Opened { + display, + observer, + comms: emacs, + images, + reply: initial_reply, + waker: GuiEventLoopWaker::new(native.proxy()), + alive: current_alive, + #[cfg(feature = "neo-term")] + terminals, + }; + // A queued success later abandoned drops only this attempt. Pumping + // observes its geometry/lifetime disconnect, never consumes the root. + let _ = reply.send(Ok(opened)); + } + let mut disposition = NativeDisposition::default(); + if let Some((_, native)) = root.as_mut() { + native.retire(); + disposition.bypass_finalizers |= native.bypass_finalizers(); + } + if let Some(alive) = alive { + alive.store(false, Ordering::Release); + } + disposition.bypass_finalizers |= control.foreign_pending.load(Ordering::Acquire); + disposition +} + +pub(super) fn install( + eval: &mut Context, + sender: DisplaySender, + input: Sender, + ttys: secondary_tty::SecondaryTtyRegistry, + startup: &StartupOptions, +) -> Lifetime { + let lifetime = Rc::new(RefCell::new(None)); + let keep = lifetime.clone(); + let gui = startup.gui.clone(); + let mut connected: Option<(GraphicalDisplayIdentity, Arc)> = None; + load_neomacs_gui_term_layer(eval); + eval.set_gui_display_initializer(Box::new(move |eval, display| { + if let Some((identity, alive)) = &connected { + if !alive.load(Ordering::Acquire) { return Err(display_error("Graphical display connection has closed")); } + if display.is_some_and(|name| name != identity.terminal_name()) { + return Err(display_error("Multiple graphical display connections are not supported by winit")); + } + return Ok(()); + } + let (tx, rx) = crossbeam_channel::bounded(1); + let selection = DisplaySelection { name: display.map(str::to_owned).or(lisp_environment(eval, "WAYLAND_DISPLAY")?), runtime: lisp_environment(eval, "XDG_RUNTIME_DIR")? }; + let attempt = control::Attempt::new(); + let _cancel = control::CancelOnDrop(attempt.clone()); + sender.open.try_send(Request { display: selection, reply: tx, attempt: attempt.clone() }).map_err(|error| display_error(format!("Native display owner unavailable: {error}")))?; + sender.control.wake(); + let deadline = attempt.deadline; + let mut opened = loop { + eval.poll_host_wait()?; + match rx.recv_timeout(Duration::from_millis(20)) { + Ok(result) => break result.map_err(display_error)?, + Err(crossbeam_channel::RecvTimeoutError::Timeout) if Instant::now() < deadline => {}, + Err(error) => return Err(display_error(format!("Native display initialization failed: {error}"))), + } + }; + let BootstrapDisplayKind::Gui { identity, .. } = &opened.display.kind else { unreachable!() }; + let identity = identity.clone(); + let (resources, preferred) = gui.prepare(&invocation_name()); + let font = startup_font::StartupFont::select(&opened.display, preferred.as_deref()).ok_or_else(|| display_error("No usable GUI font"))?; + let metrics = font.metrics(); + let selected = font.into_selected(); + let font_name = format!("-*-{}-{}-{}-*-*-{}-*-*-*-*-*-*-*", + selected.resolved.family, + startup_font_weight_symbol(FontWeight::from_css_weight(selected.resolved.weight)), + selected.slant.symbol_name(), + selected.metrics.pixel_size); + let (width, height) = startup_dimensions(FrontendKind::Gui, metrics, false); + let native = Rc::new(RefCell::new(Some(InitialWindow::Pending(opened.reply)))); + let primary_size = Arc::new(Mutex::new(PrimaryWindowSize { width, height })); + let frame_id = Arc::new(AtomicU64::new(0)); + + let notifier = eval.wait_notifier(); + let quit_requested = eval.quit_requested.clone(); + let display_input = opened.comms.input_rx; + let bridge_input = input.clone(); + let bridge_frame_id = frame_id.clone(); + let bridge_size = primary_size.clone(); + let mut fonts = opened.observer.take_changes(); + let font_identity = identity.clone(); + let terminal_id = admit_input_bridge(identity.clone(), || std::thread::Builder::new().name("daemon-gui-input".into()).spawn(move || { + loop { + let event = crossbeam_channel::select! { + recv(display_input) -> event => match event { Ok(event) => event, Err(_) => break }, + recv(fonts) -> change => { + match change { + Ok(fonts) => { let _ = bridge_input.send(neovm_core::keyboard::InputEvent::SystemFontsChanged { fonts, display: font_identity.clone() }); if let Some(notifier) = ¬ifier { let _ = notifier.notify(); } }, + Err(_) => fonts = crossbeam_channel::never(), + } + continue; + } + }; + // Connection exit is not the initial-terminal root shutdown. + if matches!(event, DisplayInputEvent::WindowClose { emacs_frame_id: 0 }) { continue; } + record_primary_window_resize(&bridge_size, &event); + for mut event in input_bridge::convert_display_event(&event) { + if let neovm_core::keyboard::InputEvent::Resize { emacs_frame_id, .. } = &mut event { + if *emacs_frame_id == 0 { *emacs_frame_id = bridge_frame_id.load(Ordering::Acquire); } + if *emacs_frame_id == 0 { continue; } + } + if event.requests_default_quit() { quit_requested.request(); } + if bridge_input.send(event).is_err() { return; } + } + if let Some(notifier) = ¬ifier { let _ = notifier.notify(); } + } + }))?; + let redisplay_waker = RedisplayWaker::new(input.clone(), eval.wait_notifier()); + eval.set_display_host(Box::new(PrimaryWindowDisplayHost { + deferred_frame: Some(FrameDefaults { terminal_id, identity: identity.clone(), metrics, font: font_name, frame_id: frame_id.clone(), initial: native.clone(), ready: HashMap::new(), leases: HashMap::new(), visual: RefCell::new(None) }), + resources, system_fonts: opened.display.font_defaults.system_fonts(), + tooltip_client: neomacs_display_protocol::tooltip::TooltipClient::new(opened.comms.tooltip_context.clone()), + cmd_tx: opened.comms.cmd_tx.clone(), render_waker: Some(opened.waker.clone()), + font_sizing: opened.display.font_sizing(), primary_window_adopted: false, primary_frame_id: None, + last_window_titles: Mutex::new(HashMap::new()), font_metrics: None, primary_window_size: primary_size.clone(), + image_catalog: Rc::new(AsyncImageCatalog::new(opened.comms.cmd_tx.clone(), Some(opened.waker.clone()), opened.images.clone(), Some(redisplay_waker))), + #[cfg(feature = "video")] + resolved_videos: Mutex::new(ResolvedVideoRegistry::default()), + resolved_webkits: Mutex::new(HashMap::new()), resolved_surfaces: Mutex::new(ResolvedSurfaceMemo::default()), + render_capabilities: opened.comms.capabilities.clone(), requested_frame_shader: Mutex::new(None), + #[cfg(feature = "neo-term")] + terminal_state: TerminalHostState::new(opened.terminals), + })); + frame_layout::REDISPLAY_RUNTIME.with(|runtime| { + runtime.enable_cosmic_metrics(); + runtime.set_font_sizing(opened.display.font_sizing()); + }); + let frame_tx = opened.comms.frame_tx; + let waker = opened.waker.clone(); + let ttys = ttys.clone(); + eval.redisplay_fn = Some(Box::new(move |eval| { + if !ttys.render_selected(eval) && eval.frame_manager().selected_frame().is_some_and(|frame| frame.effective_window_system().is_some()) { + publish_gui_frame(eval, &frame_tx, Some(&waker)); + } + })); + frame_layout::install_frame_snapshot_fn(eval); + frame_layout::install_window_layout_query_fn(eval); + frame_layout::install_font_shape_driver(eval); + let preview_tx = opened.comms.cmd_tx.clone(); + let preview_waker = opened.waker.clone(); + eval.scroll_preview_fn = Some(Box::new(move |eval, frame, window, inputs| { + let intent = frame_layout::REDISPLAY_RUNTIME.with(|runtime| runtime.resolved_scroll_preview(eval, frame, window, inputs)); + if let Some(intent) = intent + && preview_tx.try_send(RenderCommand::Window(WindowCommand::ScrollPreview(intent))).is_ok() { preview_waker.wake(); } + })); + connected = Some((identity, opened.alive)); + *keep.borrow_mut() = Some(Attached { native, _fonts: opened.observer }); + Ok(()) + })); + Lifetime(lifetime) +} + +// Publish terminal ownership only after the last fallible preparation phase. +// A failed thread spawn must not leave a live connection without a host. +fn admit_input_bridge( + identity: GraphicalDisplayIdentity, + start: impl FnOnce() -> std::io::Result>, +) -> Result { + start().map_err(display_error)?; + Ok(neovm_core::emacs_core::terminal::pure::register_graphical_terminal(identity)) +} + +fn display_error(error: impl std::fmt::Display) -> neovm_core::emacs_core::error::EvalError { + neovm_core::emacs_core::error::EvalError::signal( + neovm_core::emacs_core::intern::intern("error"), + vec![Value::string(error.to_string())], + None, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use neomacs_display_protocol::GraphicalBackend; + + #[test] + fn deferred_gui_selection_uses_lisp_runtime_and_absolute_socket() { + let mut eval = Context::new(); + eval.set_variable( + "process-environment", + Value::list(vec![Value::string("XDG_RUNTIME_DIR=/chosen/lisp/runtime")]), + ); + let runtime = lisp_environment(&mut eval, "XDG_RUNTIME_DIR").unwrap(); + assert_eq!(runtime.as_deref(), Some("/chosen/lisp/runtime")); + #[cfg(target_os = "linux")] + { + assert_eq!( + DisplaySelection { + name: Some("selected".into()), + runtime + } + .socket() + .unwrap(), + std::path::PathBuf::from("/chosen/lisp/runtime/selected") + ); + assert_eq!( + DisplaySelection { + name: Some("/absolute/socket".into()), + runtime: None + } + .socket() + .unwrap(), + std::path::PathBuf::from("/absolute/socket") + ); + assert!( + DisplaySelection { + name: Some("selected".into()), + runtime: None + } + .socket() + .is_err() + ); + } + } + + #[test] + fn deferred_gui_cancelled_disposition_survives_later_success() { + use neomacs_display_runtime::render_thread::{RenderLoopError, RenderLoopExit}; + let mut disposition = NativeDisposition::default(); + disposition.observe(&Ok(RenderLoopExit::Finished)); + assert!(!disposition.bypass_finalizers); + disposition.observe(&Ok(RenderLoopExit::GpuStartupCancelled)); + disposition.observe(&Ok(RenderLoopExit::Finished)); + assert!(disposition.bypass_finalizers); + let mut error = NativeDisposition::default(); + error.observe(&Err(RenderLoopError::StartupInterrupted( + "controlled startup failure".into(), + ))); + assert!(error.bypass_finalizers); + } + + #[test] + fn deferred_gui_failed_input_spawn_does_not_publish_terminal() { + let mut eval = Context::new(); + let before = eval.eval_str("(terminal-list)").unwrap(); + let identity = + GraphicalDisplayIdentity::named(GraphicalBackend::Wayland, "failed-bridge").unwrap(); + assert!( + admit_input_bridge(identity, || Err(std::io::Error::other( + "injected spawn failure" + ))) + .is_err() + ); + let after = eval.eval_str("(terminal-list)").unwrap(); + eval.set_variable("terminals-before", before); + eval.set_variable("terminals-after", after); + assert!( + eval.eval_str("(equal terminals-before terminals-after)") + .unwrap() + .is_truthy() + ); + } + + #[test] + fn deferred_gui_successful_input_spawn_publishes_terminal() { + let mut eval = Context::new(); + let identity = + GraphicalDisplayIdentity::named(GraphicalBackend::Wayland, "ready-bridge").unwrap(); + let terminal = + admit_input_bridge(identity, || std::thread::Builder::new().spawn(|| {})).unwrap(); + assert!(terminal > 0); + assert!( + eval.eval_str("(let ((terminals (terminal-list)) found) (while terminals (if (equal (terminal-name (car terminals)) \"ready-bridge\") (setq found (terminal-live-p (car terminals)))) (setq terminals (cdr terminals))) found)") + .unwrap() + .is_truthy() + ); + } +} diff --git a/crates/neomacs/src/deferred_gui_control.rs b/crates/neomacs/src/deferred_gui_control.rs new file mode 100644 index 0000000000..762881abc9 --- /dev/null +++ b/crates/neomacs/src/deferred_gui_control.rs @@ -0,0 +1,140 @@ +//! Exact socket interruption; never dispatches or frees native Wayland objects. +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; +use std::time::{Duration, Instant}; + +pub(super) struct Attempt { + pub deadline: Instant, + cancelled: AtomicBool, + constructing: AtomicBool, +} +impl Attempt { + pub fn new() -> Arc { + Arc::new(Self { + deadline: Instant::now() + Duration::from_secs(15), + cancelled: AtomicBool::new(false), + constructing: AtomicBool::new(true), + }) + } + pub fn cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) || Instant::now() >= self.deadline + } + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + } +} +pub(super) struct CancelOnDrop(pub Arc); +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.cancel(); + } +} + +#[derive(Default)] +struct State { + attempt: Option>, + #[cfg(target_os = "linux")] + socket: Option, +} +#[derive(Default)] +pub(super) struct Control { + pub stopped: AtomicBool, + pub foreign_pending: Arc, + pub window_waits: Arc, + state: Mutex, + proxy: Mutex>, +} +impl Control { + pub fn install_proxy( + &self, + proxy: neomacs_display_runtime::render_thread::RenderEventLoopProxy, + ) { + *self.proxy.lock().unwrap() = Some(proxy); + } + pub fn wake(&self) { + if let Some(proxy) = &*self.proxy.lock().unwrap() { + proxy.wake_up(); + } + } + + pub fn start(self: &Arc) -> std::io::Result> { + let control = self.clone(); + std::thread::Builder::new() + .name("native-attach-control".into()) + .spawn(move || { + loop { + let stop = control.stopped.load(Ordering::Acquire); + let state = control.state.lock().unwrap(); + let cancelled = stop + || control.window_waits.interrupt_required() + || state.attempt.as_ref().is_some_and(|attempt| { + attempt.constructing.load(Ordering::Acquire) && attempt.cancelled() + }); + #[cfg(target_os = "linux")] + if cancelled && let Some(socket) = &state.socket { + let _ = socket.shutdown(std::net::Shutdown::Both); + } + drop(state); + if stop { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + }) + } + pub fn cancelled(&self, attempt: &Attempt) -> bool { + self.stopped.load(Ordering::Acquire) || attempt.cancelled() + } + pub fn begin(&self, attempt: Arc) { + let mut state = self.state.lock().unwrap(); + state.attempt = Some(attempt); + } + /// Serialized with cancellation: the attempt deadline loses authority over + /// the root socket once native construction has successfully completed. + pub fn constructed(&self, attempt: &Attempt) { + let _state = self.state.lock().unwrap(); + attempt.constructing.store(false, Ordering::Release); + } + #[cfg(target_os = "linux")] + pub fn connect( + &self, + path: &std::path::Path, + attempt: &Attempt, + ) -> Result { + use socket2::{Domain, SockAddr, Socket, Type}; + let socket = + Socket::new(Domain::UNIX, Type::STREAM, None).map_err(|error| error.to_string())?; + let addr = SockAddr::unix(path).map_err(|error| error.to_string())?; + loop { + if self.cancelled(attempt) { + return Err("Native display preparation cancelled".into()); + } + match socket.connect_timeout(&addr, Duration::from_millis(20)) { + Ok(()) => break, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + std::thread::sleep(Duration::from_millis(10)) + } + Err(error) => { + return Err(format!( + "Cannot connect to Wayland socket {}: {error}", + path.display() + )); + } + } + } + let stream: std::os::unix::net::UnixStream = socket.into(); + let mut state = self.state.lock().unwrap(); + if self.cancelled(attempt) { + return Err("Native display preparation cancelled".into()); + } + state.socket = Some(stream.try_clone().map_err(|error| error.to_string())?); + Ok(stream) + } +} diff --git a/crates/neomacs/src/main.rs b/crates/neomacs/src/main.rs index a4a55a42d3..0b5076ea4a 100644 --- a/crates/neomacs/src/main.rs +++ b/crates/neomacs/src/main.rs @@ -110,6 +110,7 @@ cfg_select! { mod args; mod build_info; mod daemon; +mod deferred_gui; pub(crate) mod frame_layout; #[cfg(feature = "gc-memory-telemetry")] mod gc_memory_allocator; @@ -1169,6 +1170,7 @@ impl ResolvedSurfaceMemo { } struct PrimaryWindowDisplayHost { + deferred_frame: Option, resources: neomacs_display_runtime::gui_resources::GuiResources, system_fonts: neovm_core::emacs_core::display_host::SystemFonts, tooltip_client: neomacs_display_protocol::tooltip::TooltipClient, @@ -1451,9 +1453,27 @@ impl PrimaryWindowDisplayHost { command: RenderCommand, error_context: &str, ) -> Result<(), String> { - self.cmd_tx - .send(command) - .map_err(|err| format!("{error_context}: {err}"))?; + if let Some(defaults) = &self.deferred_frame + && matches!( + &*defaults.initial.borrow(), + Some(deferred_gui::InitialWindow::Pending(_)) + ) + && let RenderCommand::Config(ConfigCommand::SetVisualConfig(config)) = &command + { + // Effect updates are complete snapshots. Keep the latest requested + // state at connection-only admission instead of consuming queue64. + *defaults.visual.borrow_mut() = Some(config.clone()); + return Ok(()); + } + if self.deferred_frame.is_some() { + self.cmd_tx + .try_send(command) + .map_err(|err| format!("{error_context}: {err}"))?; + } else { + self.cmd_tx + .send(command) + .map_err(|err| format!("{error_context}: {err}"))?; + } if let Some(waker) = &self.render_waker { waker.wake(); } @@ -1484,7 +1504,101 @@ fn render_fullscreen_mode(fullscreen: FrameFullscreen) -> WindowFullscreenMode { } } +impl Drop for PrimaryWindowDisplayHost { + fn drop(&mut self) { + if let Some(defaults) = &self.deferred_frame { + for live in defaults.leases.values() { + live.store(false, Ordering::Release); + } + if let Some(waker) = &self.render_waker { + waker.wake(); + } + } + } +} + +impl PrimaryWindowDisplayHost { + fn realize_deferred_gui_frame( + &mut self, + request: GuiFrameHostRequest, + title: String, + ) -> Result<(), String> { + let (reply, receive) = crossbeam_channel::bounded(1); + let live = Arc::new(AtomicBool::new(true)); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + let defaults = self.deferred_frame.as_ref().unwrap(); + let adopt_primary = !self.primary_window_adopted; + let visual = defaults.visual.borrow().clone(); + // Nothing capable of creating a native frame is sent separately. + self.send_render_command( + RenderCommand::Window(WindowCommand::RealizeFrame { + frame: FrameRef::Frame(request.frame_id.0), + width: request.width, + height: request.height, + title, + geometry_hints: request.geometry_hints, + fullscreen: request.fullscreen.map(render_fullscreen_mode), + visual, + adopt_primary, + reply, + live: live.clone(), + deadline, + }), + "failed to admit native frame transaction", + )?; + let defaults = self.deferred_frame.as_mut().unwrap(); + defaults.ready.insert(request.frame_id, receive); + defaults.leases.insert(request.frame_id, live); + defaults.visual.borrow_mut().take(); + if adopt_primary { + self.primary_window_adopted = true; + self.primary_frame_id = Some(request.frame_id); + } + let mut initial = defaults.initial.borrow_mut(); + if matches!(&*initial, Some(deferred_gui::InitialWindow::Pending(_))) { + let Some(deferred_gui::InitialWindow::Pending(reply)) = initial.take() else { + unreachable!() + }; + defaults + .frame_id + .store(request.frame_id.0, Ordering::Release); + let lifetime = reply + .ready(neomacs_display_runtime::render_thread::InitialWindowSize { + width: request.width, + height: request.height, + }) + .ok_or("Native display closed before creating its first frame")?; + *initial = Some(deferred_gui::InitialWindow::Ready { + _lifetime: lifetime, + }); + } + Ok(()) + } +} + impl DisplayHost for PrimaryWindowDisplayHost { + fn gui_terminal(&self) -> Option<(u64, neomacs_display_protocol::GraphicalDisplayIdentity)> { + self.deferred_frame + .as_ref() + .map(|defaults| (defaults.terminal_id, defaults.identity.clone())) + } + + fn gui_frame_metrics(&self) -> Option<(f32, f32, f32, f64)> { + self.deferred_frame.as_ref().map(|defaults| { + ( + defaults.metrics.char_width, + defaults.metrics.char_height, + defaults.metrics.font_pixel_size, + 1.0, + ) + }) + } + + fn default_gui_font(&self) -> Option<&str> { + self.deferred_frame + .as_ref() + .map(|defaults| defaults.font.as_str()) + } #[cfg(target_os = "macos")] fn ns_resource(&self, name: &str) -> Option { self.resources.ns_resource(name) @@ -1531,6 +1645,9 @@ impl DisplayHost for PrimaryWindowDisplayHost { request.height, title_string ); + if self.deferred_frame.is_some() { + return self.realize_deferred_gui_frame(request, title_string); + } if !self.primary_window_adopted { let fullscreen_frame = FrameRef::Primary; self.send_render_command( @@ -1596,6 +1713,26 @@ impl DisplayHost for PrimaryWindowDisplayHost { Ok(()) } + fn poll_gui_frame_ready(&mut self, frame: FrameId) -> Option> { + let Some(defaults) = self.deferred_frame.as_mut() else { + return Some(Ok(())); + }; + let Some(receive) = defaults.ready.get(&frame) else { + // Child frames belong to their parent's native window, so their + // realization does not enqueue a top-level window admission. + return Some(Ok(())); + }; + let result = match receive.try_recv() { + Ok(result) => result, + Err(crossbeam_channel::TryRecvError::Empty) => return None, + Err(crossbeam_channel::TryRecvError::Disconnected) => { + Err("Native display closed during frame realization".into()) + } + }; + defaults.ready.remove(&frame); + Some(result) + } + fn list_font_families( &mut self, _frame_id: FrameId, @@ -1676,7 +1813,7 @@ impl DisplayHost for PrimaryWindowDisplayHost { } fn opening_gui_frame_pending(&self) -> bool { - !self.primary_window_adopted + self.deferred_frame.is_none() && !self.primary_window_adopted } fn remove_gui_child_frame( @@ -1712,6 +1849,24 @@ impl DisplayHost for PrimaryWindowDisplayHost { } fn destroy_gui_frame(&mut self, frame_id: neovm_core::window::FrameId) -> Result<(), String> { + if let Some(defaults) = self.deferred_frame.as_mut() { + defaults.ready.remove(&frame_id); + if let Some(live) = defaults.leases.remove(&frame_id) { + live.store(false, Ordering::Release); + if let Some(waker) = &self.render_waker { + waker.wake(); + } + } + // Rollback is independent of the bounded command queue. + if self.primary_frame_id == Some(frame_id) { + self.primary_frame_id = None; + } + self.last_window_titles + .lock() + .map_err(|err| err.to_string())? + .remove(&frame_id); + return Ok(()); + } let frame = if self.primary_frame_id == Some(frame_id) { self.primary_frame_id = None; FrameRef::Primary @@ -3738,6 +3893,7 @@ fn run_gui_evaluator_worker( let (input_tx, input_rx) = crossbeam_channel::unbounded(); let image_redisplay_waker = RedisplayWaker::new(input_tx.clone(), evaluator.wait_notifier()); evaluator.set_display_host(Box::new(PrimaryWindowDisplayHost { + deferred_frame: None, resources, system_fonts: bootstrap_display.font_defaults.system_fonts(), tooltip_client: neomacs_display_protocol::tooltip::TooltipClient::new( @@ -4377,6 +4533,43 @@ pub fn run(mode: RuntimeMode) { return; } + if startup.daemon.is_some() { + deferred_gui::run_daemon( + mode, + startup, + bootstrap_display, + daemon_notifier, + process_started_at, + process_args, + ); + } else { + let exit = run_tty_evaluator( + mode, + startup, + bootstrap_display, + daemon_notifier, + process_started_at, + process_args, + None, + ); + if exit.restart { + tracing::warn!("restart requested via kill-emacs, but restart is not implemented yet"); + } + if exit.exit_code != 0 { + std::process::exit(exit.exit_code); + } + } +} + +fn run_tty_evaluator( + mode: RuntimeMode, + startup: StartupOptions, + bootstrap_display: BootstrapDisplayConfig, + daemon_notifier: Option, + process_started_at: Instant, + process_args: Vec, + deferred_gui: Option, +) -> EvaluatorExit { // TTY geometry is in character cells and performs no native font lookup. // GUI geometry is prepared once on the evaluator thread instead. let frame_metrics = BootstrapFrameMetrics::TTY; @@ -4446,6 +4639,7 @@ pub fn run(mode: RuntimeMode) { Arc::new(Mutex::new(PrimaryWindowSize { width, height })); let tty_popup_force_full_redraw = Arc::new(AtomicBool::new(false)); let secondary_ttys = secondary_tty::SecondaryTtyRegistry::default(); + let mut gui_lifetime = None; if tty_init::should_enable_live_tty_io(&startup) { set_terminal_host(Box::new(tty_frontend::TtyTerminalHost { cmd_tx: emacs_comms.cmd_tx.clone(), @@ -4563,6 +4757,16 @@ pub fn run(mode: RuntimeMode) { .expect("Failed to spawn input bridge thread"); } + if let Some(sender) = deferred_gui { + gui_lifetime = Some(deferred_gui::install( + &mut evaluator, + sender, + secondary_input_tx.clone(), + secondary_ttys.clone(), + &startup, + )); + } + // 7. Connect evaluator to input system evaluator.init_input_system(input_rx); evaluator.set_tty_frame_host_factory(Box::new(secondary_tty::SecondaryTtyFactory::new( @@ -4612,6 +4816,7 @@ pub fn run(mode: RuntimeMode) { neomacs_display_runtime::thread_comm::RenderCommand::Lifecycle(LifecycleCommand::Shutdown), ); frontend.join(); + drop(gui_lifetime); if tty_init::should_enable_live_tty_io(&startup) { tty_init::tty_shutdown_terminal(); } @@ -4630,23 +4835,20 @@ pub fn run(mode: RuntimeMode) { maybe_drain_aot_pgo(mode, &evaluator); } - if let Some(request) = evaluator.shutdown_request() { + let exit = if let Some(request) = evaluator.shutdown_request() { if startup.daemon.is_some() { daemon_tty_cleanup.close_all(); evaluator.close_processes_for_exit(); } - if request.restart { - if startup.daemon.is_some() { - daemon::restart(&process_args); - } - tracing::warn!("restart requested via kill-emacs, but restart is not implemented yet"); - } - if request.exit_code != 0 { - std::process::exit(request.exit_code); + EvaluatorExit { + exit_code: request.exit_code, + restart: request.restart, } - } - + } else { + EvaluatorExit::OK + }; leak_evaluator_for_process_exit(evaluator); + exit } fn leak_evaluator_for_process_exit(evaluator: Context) { diff --git a/crates/neomacs/src/tests/main_test.rs b/crates/neomacs/src/tests/main_test.rs index 0fb1c4a51c..be4ffe1ec4 100644 --- a/crates/neomacs/src/tests/main_test.rs +++ b/crates/neomacs/src/tests/main_test.rs @@ -2319,6 +2319,7 @@ fn assert_selected_frame_matches_materialized_default_metrics(eval: &Context) { fn opening_gui_frame_adoption_does_not_push_stale_window_size() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let mut host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2341,6 +2342,7 @@ fn opening_gui_frame_adoption_does_not_push_stale_window_size() { terminal_state: super::super::TerminalHostState::new(new_shared_terminals()), }; + assert!(neovm_core::emacs_core::DisplayHost::opening_gui_frame_pending(&host)); neovm_core::emacs_core::DisplayHost::realize_gui_frame( &mut host, GuiFrameHostRequest { @@ -2397,6 +2399,7 @@ fn opening_gui_frame_adoption_does_not_push_stale_window_size() { fn opening_gui_frame_adoption_applies_fullscreen_mode() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let mut host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2419,6 +2422,7 @@ fn opening_gui_frame_adoption_applies_fullscreen_mode() { terminal_state: super::super::TerminalHostState::new(new_shared_terminals()), }; + assert!(neovm_core::emacs_core::DisplayHost::opening_gui_frame_pending(&host)); neovm_core::emacs_core::DisplayHost::realize_gui_frame( &mut host, GuiFrameHostRequest { @@ -2453,6 +2457,7 @@ fn opening_gui_frame_adoption_applies_fullscreen_mode() { fn primary_display_host_destroy_gui_frame_routes_primary_and_secondary_windows() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let mut host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2506,6 +2511,7 @@ fn primary_display_host_destroy_gui_frame_routes_primary_and_secondary_windows() fn primary_display_host_popup_menu_routes_primary_and_secondary_frames() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let mut host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2590,6 +2596,7 @@ fn primary_image_catalog_lookup_returns_pending_without_waiting_for_render_threa let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let image_metadata = Arc::new(ImageRenderState::default()); let host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2758,6 +2765,7 @@ fn primary_image_catalog_does_not_block_on_render_command_backpressure() { let worker_cmd_tx = cmd_tx.clone(); let worker = std::thread::spawn(move || { let host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2820,6 +2828,7 @@ fn primary_image_catalog_does_not_wait_for_renderer_metadata_lock() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let image_metadata = Arc::new(ImageRenderState::default()); let host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2886,6 +2895,7 @@ fn primary_image_catalog_does_not_wait_for_renderer_metadata_lock() { fn primary_display_host_expands_tilde_in_image_file_before_render_command() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2976,6 +2986,7 @@ fn primary_display_host_resolve_image_sync_returns_cached_decode_failure_promptl let (cmd_tx, _cmd_rx) = crossbeam_channel::unbounded(); let image_metadata: SharedImageRenderState = Arc::new(ImageRenderState::default()); let host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3068,6 +3079,7 @@ fn primary_display_host_resolve_image_sync_returns_cached_decode_failure_promptl fn primary_display_host_request_video_queues_create_once_with_stable_id() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3154,6 +3166,7 @@ fn resolved_video_registry_never_evicts_a_still_referenceable_identity() { fn primary_display_host_request_video_preserves_uri_source() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3203,6 +3216,7 @@ fn primary_display_host_request_video_preserves_uri_source() { fn primary_display_host_routes_one_typed_video_session_lifecycle() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3262,6 +3276,7 @@ fn primary_display_host_routes_one_typed_video_session_lifecycle() { fn primary_display_host_request_webkit_queues_create_and_load_once_with_stable_id() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3316,6 +3331,7 @@ fn primary_display_host_request_webkit_queues_create_and_load_once_with_stable_i fn primary_display_host_preserves_file_navigation_as_a_typed_path() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3360,6 +3376,7 @@ fn primary_display_host_preserves_file_navigation_as_a_typed_path() { fn primary_display_host_xwidget_lifecycle_uses_explicit_xwidget_id() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3434,6 +3451,7 @@ fn bootstrap_gui_frame_adoption_routes_future_resizes_to_primary_window() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); eval.set_display_host(Box::new(PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3502,6 +3520,7 @@ fn primary_window_resize_does_not_wait_for_host_acknowledgement() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let shared = shared_primary_window_size(843, 489); let mut host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3569,6 +3588,7 @@ fn primary_window_resize_does_not_wait_for_host_acknowledgement() { fn primary_window_display_host_forwards_visual_config_to_renderer() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let mut host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3643,6 +3663,7 @@ fn primary_window_display_host_round_trips_clipboard_requests_through_renderer() reply.send(Ok(SelectionOwner::OtherProcess)).unwrap(); }); let mut host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3715,6 +3736,7 @@ fn redisplay_title_sync_formats_frame_title_format_for_primary_window() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); eval.set_display_host(Box::new(PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3764,6 +3786,7 @@ fn frame_host_title_formats_the_restored_runtime_system_name() { let _bootstrap = bootstrap_buffers(&mut eval, 843, 489, gui_display()); let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); eval.set_display_host(Box::new(PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -6717,6 +6740,7 @@ fn frame_snapshot_subr_end_to_end_json_and_text() { fn primary_display_host_reports_quality_policy_frame_shader_suppression() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let mut host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -6798,6 +6822,7 @@ fn primary_display_host_routes_typed_terminal_requests_to_the_renderer() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let shared_terminals = new_shared_terminals(); let host = PrimaryWindowDisplayHost { + deferred_frame: None, resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), diff --git a/crates/neomacs/tests/daemon_lifecycle.rs b/crates/neomacs/tests/daemon_lifecycle.rs index 5c4e248369..04658c1eac 100644 --- a/crates/neomacs/tests/daemon_lifecycle.rs +++ b/crates/neomacs/tests/daemon_lifecycle.rs @@ -1888,6 +1888,44 @@ fn daemon_graphical_request_fails_without_creating_a_phantom_frame() { assert_eq!(fixture.eval("graphical", "(+ 3 4)"), "7"); } +#[test] +fn deferred_gui_failed_attach_preserves_daemon_state_and_shutdown() { + let mut fixture = Fixture::new(); + fixture.foreground("deferred", &["-Q"]); + let pid = fixture.eval("deferred", "(emacs-pid)"); + assert_eq!(fixture.eval("deferred", "(progn (setq gui-preserved (list 7 11)) (setq gui-preserved-alias gui-preserved) (get-buffer-create \"gui-preserved\") (with-current-buffer \"gui-preserved\" (insert \"before-attach\")) t)"), "t"); + for expression in [ + "(x-open-connection \"wayland-no-such-display\")", + "(make-frame '((window-system . neo) (display . \"wayland-no-such-display\")))", + "(x-open-connection 42)", + ] { + assert_eq!( + fixture.eval( + "deferred", + &format!("(condition-case nil (progn {expression} nil) (error t))") + ), + "t" + ); + assert_eq!(fixture.eval("deferred", "(length (frame-list))"), "1"); + assert_eq!(fixture.eval("deferred", "(emacs-pid)"), pid); + assert_eq!(fixture.eval("deferred", "(progn (garbage-collect) (list (eq gui-preserved gui-preserved-alias) gui-preserved (with-current-buffer \"gui-preserved\" (buffer-string)) (x-display-list)))"), "(t (7 11) \"before-attach\" nil)"); + } + fixture.eval("deferred", "(kill-emacs)"); + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + if let Some(status) = fixture.daemon.as_mut().unwrap().try_wait().unwrap() { + break status; + } + assert!( + Instant::now() < deadline, + "deferred display owner prevented root shutdown" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + assert!(status.success()); + assert!(!fixture.socket("deferred").exists()); +} + #[test] fn full_socket_backlog_has_a_bounded_client_wait_without_duplicate_startup() { let fixture = Fixture::new(); diff --git a/docs/daemon.md b/docs/daemon.md index 5b3d18ef7b..33ec63d2d6 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -164,10 +164,19 @@ startup locks are rejected. ## Current limitations -- Headless daemons support ordinary evaluation and client TTY frames. Attaching - a native graphical frame is not implemented: it requires a separately hosted - GUI event loop. Graphical frame requests fail rather than creating an invisible - stand-in window. Start ordinary GUI Neomacs for graphical editing. +- On Linux a display-free daemon can later attach native frames to one explicitly + selected Wayland socket. Lisp `make-frame` and native/GNU client `-c -d SOCKET` + use the original evaluator and an OS-main-owned native loop. Deleting the last + graphical frame retains that connection and evaluator for recreation. Explicit + X11 and multiple independent display connections are rejected. Native attach + and frame readiness have a 15-second budget. If cancellation interrupts a + synchronous Wayland registry/configure wait, the exact owned connection is + closed and cannot be reused after a successful native-loop construction; + the daemon evaluator remains available, and restart establishes a fresh loop. + Ordinary GPU-start and evaluator/font/admission errors do not discard a healthy + native loop. Foreign driver, font and loader calls are not claimed to finish + within a deadline; cancelled foreign workers require bounded process exit + without library finalizers. - Automatic startup is for local Unix sockets. TCP clients continue to use the existing server-file/authentication path, but do not automatically start a local daemon for a missing or unreachable TCP endpoint. diff --git a/lisp/term/neo-win.el b/lisp/term/neo-win.el index e5f5316306..d512a8fbaa 100644 --- a/lisp/term/neo-win.el +++ b/lisp/term/neo-win.el @@ -263,7 +263,8 @@ DISPLAY is the name of the display Emacs should connect to." ;; Open the display connection (x-open-connection (or display - (setq x-display-name (or (getenv "DISPLAY" (selected-frame)) + (setq x-display-name (or (getenv "WAYLAND_DISPLAY") + (getenv "DISPLAY" (selected-frame)) (getenv "DISPLAY")))) x-command-line-resources ;; Exit Emacs with fatal error if this fails and we From 1cd96f8563a278cb3155890cc7131efbcd425f68 Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Tue, 6 Oct 2026 09:03:35 +0300 Subject: [PATCH 5/6] fix(terminal): classify a window-system terminal by its output method GNU Fterminal_live_p (terminal.c) reports a display terminal's type from the terminal itself, regardless of its frames or the selected frame. Neomacs looked at the terminal's frames, so a retained daemon display connection with no frames left was reported as t. server-delete-client (server.el) then tried to delete that connection while finishing a graphical client whose last frame had closed. A terminal registered as a window-system terminal now always returns the window-system symbol. Other terminals keep the frame-based classification. Explicit delete-terminal on the retained daemon display connection is rejected before any hooks run, because that connection cannot be retired and reopened on its own. Internal teardown and other terminals are not affected. --- .../display/tests/deferred_gui_test.rs | 119 +++++++++++++++++- .../src/emacs_core/display/terminal/pure.rs | 27 +++- .../emacs_core/display/terminal/tests/mod.rs | 52 ++++++++ docs/daemon.md | 6 +- 4 files changed, 197 insertions(+), 7 deletions(-) diff --git a/crates/neovm-core/src/emacs_core/display/display/tests/deferred_gui_test.rs b/crates/neovm-core/src/emacs_core/display/display/tests/deferred_gui_test.rs index 49f25623da..e7b97874fc 100644 --- a/crates/neovm-core/src/emacs_core/display/display/tests/deferred_gui_test.rs +++ b/crates/neovm-core/src/emacs_core/display/display/tests/deferred_gui_test.rs @@ -148,11 +148,12 @@ fn deferred_gui_delete_recreate_retains_separate_initial_terminal() { eval.eval_str("(setq owned-terminal (frame-terminal owned-frame))") .unwrap(); eval.eval_str("(delete-frame owned-frame t)").unwrap(); - assert!( - eval.eval_str("(terminal-live-p owned-terminal)") - .unwrap() - .is_truthy(), - "the native connection must retain its terminal after its last frame closes" + eval.set_variable("initial-frame", initial); + eval.eval_str("(select-frame initial-frame)").unwrap(); + assert_eq!( + eval.eval_str("(terminal-live-p owned-terminal)").unwrap(), + Value::symbol("neo"), + "the native connection must retain its type after its last frame closes" ); assert!(eval.shutdown_request().is_none()); assert!( @@ -190,6 +191,114 @@ fn deferred_gui_delete_recreate_retains_separate_initial_terminal() { ); } +#[test] +fn deferred_gui_explicit_terminal_deletion_is_rejected_before_hooks() { + reset_terminal_thread_locals(); + let mut eval = Context::new(); + eval.eval_str("(selected-frame)").unwrap(); + let host = deferred_host(false); + let native_frames = host.native_frames.clone(); + eval.set_display_host(Box::new(host)); + let frame = eval.eval_str("(x-create-frame nil)").unwrap(); + eval.set_variable("owned-frame", frame); + eval.eval_str( + "(setq owned-terminal (frame-terminal owned-frame) deletion-hooks nil) + (select-frame owned-frame) + (setq delete-terminal-functions + (list (lambda (term) (setq deletion-hooks t) (delete-terminal term t))) + delete-frame-functions + (list (lambda (_frame) (setq deletion-hooks t))))", + ) + .unwrap(); + // Another active terminal allows the unforced deletion past its usual + // sole-terminal check; the retained graphical owner must still reject it. + crate::emacs_core::terminal::pure::ensure_terminal_runtime_owner( + 99, + "other-active-tty", + crate::emacs_core::terminal::pure::TerminalRuntimeConfig::interactive( + None, + neomacs_display_protocol::tty_capabilities::TtyAttributeCapabilities::full_with_color_cells(8), + ), + ); + let before = eval.frames.frame_list(); + let selected = eval.frames.selected_frame().unwrap().id; + let native_before = native_frames.borrow().clone(); + for expression in [ + "(delete-terminal owned-terminal t)", + "(delete-terminal owned-frame t)", + "(delete-terminal nil t)", + "(delete-terminal owned-terminal)", + ] { + let error = eval.eval_str(expression).unwrap_err(); + match error { + crate::emacs_core::error::EvalError::Signal { data, .. } => assert_eq!( + data, + vec![Value::string( + "Deleting a retained graphical display terminal is not supported", + )], + "{expression}" + ), + other => panic!("expected deletion rejection, got {other:?}"), + } + eval.flush_pending_safe_funcalls(); + assert_eq!(eval.frames.frame_list(), before); + assert_eq!(eval.frames.selected_frame().unwrap().id, selected); + assert_eq!(*native_frames.borrow(), native_before); + assert!(eval.eval_str("deletion-hooks").unwrap().is_nil()); + assert!( + eval.eval_str("(terminal-live-p owned-terminal)") + .unwrap() + .is_truthy() + ); + } + eval.eval_str("(delete-frame owned-frame t)").unwrap(); + eval.eval_str("(setq deletion-hooks nil)").unwrap(); + assert!(eval.eval_str("(delete-terminal owned-terminal t)").is_err()); + eval.flush_pending_safe_funcalls(); + assert!(eval.eval_str("deletion-hooks").unwrap().is_nil()); + let recreated = eval.eval_str("(x-create-frame nil)").unwrap(); + eval.set_variable("recreated", recreated); + assert!( + eval.eval_str( + "(and (eq (frame-terminal recreated) owned-terminal) (terminal-live-p owned-terminal))" + ) + .unwrap() + .is_truthy() + ); + assert!(eval.shutdown_request().is_none()); +} + +#[test] +fn deferred_gui_retained_terminal_does_not_block_noelisp_or_other_terminal_cleanup() { + reset_terminal_thread_locals(); + let mut eval = Context::new(); + let initial = eval.eval_str("(selected-frame)").unwrap(); + let initial_terminal = builtin_frame_terminal(&mut eval, vec![initial]).unwrap(); + let host = deferred_host(false); + let terminal = host.terminal; + let native_frames = host.native_frames.clone(); + eval.set_display_host(Box::new(host)); + let frame = eval.eval_str("(x-create-frame nil)").unwrap(); + // Display-host presence must not prohibit deletion of a different owner. + crate::emacs_core::terminal::pure::builtin_delete_terminal( + &mut eval, + vec![initial_terminal, Value::T], + ) + .unwrap(); + assert!( + eval.frames + .get(crate::window::FrameId(frame.as_frame_id().unwrap())) + .is_some() + ); + eval.eval_str("(setq cleanup-hook nil delete-terminal-functions (list (lambda (_terminal) (setq cleanup-hook t))))").unwrap(); + crate::emacs_core::terminal::pure::delete_terminal_noelisp_owned(&mut eval, terminal).unwrap(); + assert!(eval.frames.frame_list().is_empty()); + assert!(native_frames.borrow().is_empty()); + assert!(eval.eval_str("cleanup-hook").unwrap().is_nil()); + eval.flush_pending_safe_funcalls(); + assert!(eval.eval_str("cleanup-hook").unwrap().is_truthy()); +} + #[test] fn deferred_gui_failed_surface_completion_rolls_back_frame() { reset_terminal_thread_locals(); diff --git a/crates/neovm-core/src/emacs_core/display/terminal/pure.rs b/crates/neovm-core/src/emacs_core/display/terminal/pure.rs index 5fc7024ab0..b9e8a15efe 100644 --- a/crates/neovm-core/src/emacs_core/display/terminal/pure.rs +++ b/crates/neovm-core/src/emacs_core/display/terminal/pure.rs @@ -1290,7 +1290,7 @@ pub(crate) fn builtin_frame_terminal( Ok(terminal_handle_value_for_id(terminal_id).unwrap_or_else(terminal_handle_value)) } -/// (terminal-live-p TERMINAL) -> t +/// (terminal-live-p TERMINAL) -> output type or nil /// /// In GNU Emacs, terminal-live-p returns the terminal type symbol /// (e.g. 'x, 'w32) for GUI terminals, or t for TTY. This is used @@ -1303,6 +1303,14 @@ pub(crate) fn builtin_terminal_live_p( let Some(terminal_id) = decode_terminal_id_eval(eval, &args[0]) else { return Ok(Value::NIL); }; + // GNU Fterminal_live_p classifies a window-system terminal by its output + // method, even when its last frame is gone or another terminal is + // selected. A daemon's retained display connection relies on this. + if terminal_output_method_for_id(terminal_id) == Some(TerminalOutputMethod::WindowSystem) { + return Ok(Value::symbol( + crate::emacs_core::display::gui_window_system_symbol(), + )); + } let runtime = terminal_runtime_for_id(terminal_id); let mut terminal_has_frame = false; let window_system = eval @@ -1614,6 +1622,23 @@ pub(crate) fn delete_terminal_owned( )], )); } + // The native display host retains this connection across frame deletion, + // but cannot retire/reconnect it independently. Reject public deletion + // before any Lisp hooks or ownership mutation; internal teardown is exempt. + if matches!(mode, DeleteTerminalMode::Public { .. }) + && eval + .display_host + .as_ref() + .and_then(|host| host.gui_terminal()) + .is_some_and(|(id, _)| id == terminal_id) + { + return Err(signal( + "error", + vec![Value::string( + "Deleting a retained graphical display terminal is not supported", + )], + )); + } let terminal = terminal_handle_value_for_id(terminal_id).unwrap_or_else(terminal_handle_value); if mode.runs_hooks_immediately() { let hook_sym = diff --git a/crates/neovm-core/src/emacs_core/display/terminal/tests/mod.rs b/crates/neovm-core/src/emacs_core/display/terminal/tests/mod.rs index b461680fe1..76ff07cb27 100644 --- a/crates/neovm-core/src/emacs_core/display/terminal/tests/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/terminal/tests/mod.rs @@ -153,6 +153,58 @@ fn terminal_live_p_reports_frame_terminal_type_not_selected_global_type() { ); } +#[test] +fn terminal_live_p_uses_registered_type_without_frames_or_gui_globals() { + reset_terminal_thread_locals(); + let mut eval = Context::new(); + let initial = eval.eval_str("(selected-frame)").unwrap(); + let initial_terminal = builtin_frame_terminal(&mut eval, vec![initial]).unwrap(); + let gui_id = register_graphical_terminal( + neomacs_display_protocol::GraphicalDisplayIdentity::named( + neomacs_display_protocol::GraphicalBackend::Wayland, + "wayland-frame-free", + ) + .unwrap(), + ); + let gui_terminal = terminal_handle_value_for_id(gui_id).unwrap(); + let tty = ensure_terminal_runtime_owner( + 99, + "test-tty", + TerminalRuntimeConfig::interactive( + None, + neomacs_display_protocol::tty_capabilities::TtyAttributeCapabilities::full_with_color_cells(8), + ), + ); + // Explicit owners keep their type whether GUI globals are absent or set. + for global in [Value::NIL, Value::symbol("neo")] { + eval.set_variable("window-system", global); + eval.set_variable("initial-window-system", global); + assert_eq!( + builtin_terminal_live_p(&mut eval, vec![gui_terminal]).unwrap(), + Value::symbol("neo") + ); + assert_eq!( + builtin_terminal_live_p(&mut eval, vec![initial_terminal]).unwrap(), + Value::T + ); + assert_eq!( + builtin_terminal_live_p(&mut eval, vec![tty]).unwrap(), + Value::T + ); + assert!( + builtin_frame_initial_p(&mut eval, vec![initial_terminal]) + .unwrap() + .is_truthy() + ); + assert!( + builtin_frame_initial_p(&mut eval, vec![tty]) + .unwrap() + .is_nil() + ); + } + assert_eq!(eval.frames.frame_list().len(), 1); +} + #[test] fn terminal_live_p_int_is_not_live() { crate::test_utils::init_test_tracing(); diff --git a/docs/daemon.md b/docs/daemon.md index 33ec63d2d6..1c964ba083 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -167,7 +167,11 @@ startup locks are rejected. - On Linux a display-free daemon can later attach native frames to one explicitly selected Wayland socket. Lisp `make-frame` and native/GNU client `-c -d SOCKET` use the original evaluator and an OS-main-owned native loop. Deleting the last - graphical frame retains that connection and evaluator for recreation. Explicit + graphical frame retains that connection and evaluator for recreation. Unlike + GNU Emacs, explicit `delete-terminal` on this retained graphical connection is + rejected, even with FORCE, before hooks or frame/terminal changes: independent + connection retirement/reconnection is not supported. Ordinary frame deletion, + TTY terminal deletion and daemon shutdown remain available. Explicit X11 and multiple independent display connections are rejected. Native attach and frame readiness have a 15-second budget. If cancellation interrupts a synchronous Wayland registry/configure wait, the exact owned connection is From 8c5aafdc65dc6ad6c022aaea0f96c076f4e17f0a Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Wed, 7 Oct 2026 14:09:49 +0300 Subject: [PATCH 6/6] fix(daemon): size later deferred frames like the first one Once the primary window is adopted, x-create-frame on a display-free daemon fell back to 80x40 cells with the cell size truncated first and no room for the scroll bar, fringes, menu bar or tool bar. Move the startup GUI sizing into neovm-core and use it for both paths. --- crates/neomacs/src/main.rs | 38 +++---------------- .../display/tests/deferred_gui_test.rs | 29 +++++++++++++- .../src/emacs_core/display/window_cmds/mod.rs | 10 ++++- crates/neovm-core/src/window/mod.rs | 37 ++++++++++++++++++ 4 files changed, 78 insertions(+), 36 deletions(-) diff --git a/crates/neomacs/src/main.rs b/crates/neomacs/src/main.rs index 0b5076ea4a..6f3bdd5495 100644 --- a/crates/neomacs/src/main.rs +++ b/crates/neomacs/src/main.rs @@ -986,39 +986,11 @@ fn startup_dimensions( noninteractive: bool, ) -> (u32, u32) { match frontend { - FrontendKind::Gui => { - // GNU gui_figure_window_size (frame.c) seeds the first GUI frame from - // an 80x36 text grid, then adds the scroll bar, fringes, menu bar and - // tool bar *outside* that text area. The window we request here is later - // divided into chrome + text, so we must reserve BOTH the side and top - // chrome up front — otherwise the scroll bar/fringes eat into the columns - // (frame 78 wide) and the menu/tool bars eat into the lines (frame 33). - // - // Observed GNU `(frame-height)` is one LESS than its nominal geometry - // rows — deterministic, not a WM trim: `-g 80x35`->34, `-g 80x36`->35, - // `-g 80x40`->39, and the default (== -g 80x36) nets 35. GNU's default - // GUI frame is therefore 80x35 of *counted* text; match that observable. - let cols = 80u32; - let text_rows = 35u32; - // Side chrome the layout reserves outside the text columns: a default - // vertical scroll bar (one char wide) plus the two 8px fringes. - const DEFAULT_FRINGE_PX: f32 = 8.0; - let side_chrome = frame_metrics.char_width + 2.0 * DEFAULT_FRINGE_PX; - // Top chrome reserved above the text lines for a default GUI frame: a - // one-line menu bar (char_height) plus the icon-height tool bar. Both - // default on under -Q; if a user disables either this slightly - // over-reserves — the same default-configuration assumption the side - // chrome makes for the scroll bar. The tool-bar height mirrors GNU's - // image + margin + relief model (window::default_gui_tool_bar_line_height). - let menu_bar = frame_metrics.char_height; - let tool_bar = - neovm_core::window::default_gui_tool_bar_line_height(frame_metrics.font_pixel_size) - as f32; - let top_chrome = menu_bar + tool_bar; - let width = (cols as f32 * frame_metrics.char_width + side_chrome).round() as u32; - let height = (text_rows as f32 * frame_metrics.char_height + top_chrome).round() as u32; - (width.max(200), height.max(100)) - } + FrontendKind::Gui => neovm_core::window::default_gui_frame_pixel_size( + frame_metrics.char_width, + frame_metrics.char_height, + frame_metrics.font_pixel_size, + ), FrontendKind::Tty => { if noninteractive { // GNU `make_frame` seeds the initial non-window frame with diff --git a/crates/neovm-core/src/emacs_core/display/display/tests/deferred_gui_test.rs b/crates/neovm-core/src/emacs_core/display/display/tests/deferred_gui_test.rs index e7b97874fc..31e9421bbd 100644 --- a/crates/neovm-core/src/emacs_core/display/display/tests/deferred_gui_test.rs +++ b/crates/neovm-core/src/emacs_core/display/display/tests/deferred_gui_test.rs @@ -9,6 +9,7 @@ struct DeferredHost { fail_frame: bool, fail_completion: bool, native_frames: Rc>>, + metrics: (f32, f32, f32, f64), } impl DisplayHost for DeferredHost { @@ -16,7 +17,7 @@ impl DisplayHost for DeferredHost { Some((self.terminal, self.identity.clone())) } fn gui_frame_metrics(&self) -> Option<(f32, f32, f32, f64)> { - Some((8.0, 16.0, 14.0, 1.0)) + Some(self.metrics) } fn realize_gui_frame(&mut self, request: GuiFrameHostRequest) -> Result<(), String> { // Native command admission can precede a later realization failure. @@ -53,6 +54,7 @@ fn deferred_host(fail_frame: bool) -> DeferredHost { fail_frame, fail_completion: false, native_frames: Rc::new(RefCell::new(Vec::new())), + metrics: (8.0, 16.0, 14.0, 1.0), } } @@ -330,3 +332,28 @@ fn deferred_gui_failed_native_frame_does_not_publish_a_lisp_frame() { ); assert!(eval.shutdown_request().is_none()); } + +#[test] +fn deferred_gui_later_frames_use_the_startup_frame_size() { + reset_terminal_thread_locals(); + let mut eval = Context::new(); + eval.eval_str("(selected-frame)").unwrap(); + let mut host = deferred_host(false); + // A fractional cell width exposes truncation before multiplying. + host.metrics = (8.4, 17.0, 14.0, 1.0); + // The primary window is already adopted, so the host reports no window + // size and later frames fall back to the font metrics. + eval.set_display_host(Box::new(host)); + // The first deferred frame is 80x35 text cells plus a scroll bar, two 8px + // fringes, a menu bar and a 34px tool bar: + // 80*8.4 + 8.4 + 16 = 696.4 and 35*17 + 17 + 34 = 646. + let expected = (696, 646); + for _ in 0..2 { + let frame = eval.eval_str("(x-create-frame nil)").unwrap(); + let frame = eval + .frames + .get(crate::window::FrameId(frame.as_frame_id().unwrap())) + .unwrap(); + assert_eq!((frame.width, frame.height), expected); + } +} diff --git a/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs b/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs index 14ec396e42..93941f69ca 100644 --- a/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs @@ -7233,9 +7233,15 @@ pub(crate) fn x_create_frame_impl( .and_then(|host| host.gui_frame_metrics()) .map( |(char_width, char_height, font_pixel_size, device_scale_factor)| { + // Size every deferred frame like the first one. + let (width_px, height_px) = crate::window::default_gui_frame_pixel_size( + char_width, + char_height, + font_pixel_size, + ); GuiFrameMetrics { - width_px: 80 * char_width as u32, - height_px: 40 * char_height as u32, + width_px, + height_px, char_width, char_height, font_pixel_size, diff --git a/crates/neovm-core/src/window/mod.rs b/crates/neovm-core/src/window/mod.rs index 1f993bf355..ed213dcdd1 100644 --- a/crates/neovm-core/src/window/mod.rs +++ b/crates/neovm-core/src/window/mod.rs @@ -227,6 +227,43 @@ pub fn default_gui_tool_bar_line_height(font_pixel_size: f32) -> u32 { .max(1.0) as u32 } +/// Default pixel size of a top-level GUI frame for the given font metrics. +/// +/// GNU gui_figure_window_size (frame.c) seeds a GUI frame from an 80x36 text +/// grid, then adds the scroll bar, fringes, menu bar and tool bar *outside* +/// that text area. The window requested here is later divided into chrome + +/// text, so both the side and top chrome are reserved up front; otherwise the +/// scroll bar/fringes eat into the columns (frame 78 wide) and the menu/tool +/// bars eat into the lines (frame 33). +/// +/// Observed GNU `(frame-height)` is one LESS than its nominal geometry rows, +/// deterministically: `-g 80x35`->34, `-g 80x36`->35, `-g 80x40`->39, and the +/// default (== -g 80x36) nets 35. GNU's default GUI frame is therefore 80x35 +/// of *counted* text; match that observable. +pub fn default_gui_frame_pixel_size( + char_width: f32, + char_height: f32, + font_pixel_size: f32, +) -> (u32, u32) { + let cols = 80u32; + let text_rows = 35u32; + // Side chrome the layout reserves outside the text columns: a default + // vertical scroll bar (one char wide) plus the two 8px fringes. + const DEFAULT_FRINGE_PX: f32 = 8.0; + let side_chrome = char_width + 2.0 * DEFAULT_FRINGE_PX; + // Top chrome reserved above the text lines: a one-line menu bar + // (char_height) plus the icon-height tool bar. Both default on under -Q; + // if a user disables either this slightly over-reserves, the same + // default-configuration assumption the side chrome makes for the scroll + // bar. The tool-bar height mirrors GNU's image + margin + relief model. + let menu_bar = char_height; + let tool_bar = default_gui_tool_bar_line_height(font_pixel_size) as f32; + let top_chrome = menu_bar + tool_bar; + let width = (cols as f32 * char_width + side_chrome).round() as u32; + let height = (text_rows as f32 * char_height + top_chrome).round() as u32; + (width.max(200), height.max(100)) +} + // --------------------------------------------------------------------------- // Window geometry // ---------------------------------------------------------------------------