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" } 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() { 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..6f3bdd5495 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; @@ -985,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 @@ -1169,6 +1142,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 +1425,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 +1476,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 +1617,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 +1685,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 +1785,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 +1821,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 +3865,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 +4505,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 +4611,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 +4729,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 +4788,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 +4807,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/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..31e9421bbd --- /dev/null +++ b/crates/neovm-core/src/emacs_core/display/display/tests/deferred_gui_test.rs @@ -0,0 +1,359 @@ +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>>, + metrics: (f32, f32, f32, f64), +} + +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(self.metrics) + } + 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())), + metrics: (8.0, 16.0, 14.0, 1.0), + } +} + +#[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(); + 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!( + 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_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(); + 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()); +} + +#[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/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..b9e8a15efe 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. @@ -1276,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 @@ -1289,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 @@ -1600,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/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..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 @@ -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,30 @@ 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)| { + // 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, + height_px, + 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 +7323,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 +7457,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 +7694,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(); 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 // --------------------------------------------------------------------------- diff --git a/docs/daemon.md b/docs/daemon.md index 5b3d18ef7b..1c964ba083 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -164,10 +164,23 @@ 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. 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 + 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