diff --git a/Cargo.lock b/Cargo.lock index 28c3bd6..566fc12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1308,7 +1308,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags", + "block2", "dispatch2", + "libc", "objc2", ] @@ -1325,6 +1327,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags", + "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -1822,6 +1826,7 @@ dependencies = [ "ruin-runtime", "ruin_reactivity", "ruin_ui", + "ruin_ui_platform_macos", "ruin_ui_platform_wayland", "tracing", "tracing-subscriber", @@ -1850,6 +1855,21 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "ruin_ui_platform_macos" +version = "0.1.0" +dependencies = [ + "libc", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "ruin-runtime", + "ruin_ui", + "ruin_ui_renderer_wgpu", + "tracing", +] + [[package]] name = "ruin_ui_platform_wayland" version = "0.1.0" diff --git a/lib/ruin_app/Cargo.toml b/lib/ruin_app/Cargo.toml index 6ab3496..38b33bd 100644 --- a/lib/ruin_app/Cargo.toml +++ b/lib/ruin_app/Cargo.toml @@ -8,9 +8,14 @@ ruin_reactivity = { path = "../reactivity" } ruin_runtime = { package = "ruin-runtime", path = "../runtime" } ruin_app_proc_macros = { package = "ruin-app-proc-macros", path = "../ruin_app_proc_macros" } ruin_ui = { path = "../ui" } -ruin_ui_platform_wayland = { path = "../ui_platform_wayland" } tracing = "0.1" +[target.'cfg(target_os = "linux")'.dependencies] +ruin_ui_platform_wayland = { path = "../ui_platform_wayland" } + +[target.'cfg(target_os = "macos")'.dependencies] +ruin_ui_platform_macos = { path = "../ui_platform_macos" } + [dev-dependencies] tracing-subscriber = { version = "0.3", default-features = false, features = [ "env-filter", diff --git a/lib/ruin_app/example/03_fine_grained_list.rs b/lib/ruin_app/example/03_fine_grained_list.rs index 1cfa8e9..6b18733 100644 --- a/lib/ruin_app/example/03_fine_grained_list.rs +++ b/lib/ruin_app/example/03_fine_grained_list.rs @@ -4,8 +4,7 @@ use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{EnvFilter, fmt}; fn install_tracing() { - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("warn")); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")); let fmt_layer = fmt::layer() .with_target(true) .with_thread_ids(true) diff --git a/lib/ruin_app/example/07_children_and_slots.rs b/lib/ruin_app/example/07_children_and_slots.rs index 54eaf8c..14de5ef 100644 --- a/lib/ruin_app/example/07_children_and_slots.rs +++ b/lib/ruin_app/example/07_children_and_slots.rs @@ -259,7 +259,12 @@ fn CardFrame(title: View, toolbar: ChildViews, children: ChildViews) -> impl Int } #[component] -fn InlineDialog(open: bool, title: View, actions: ChildViews, children: ChildViews) -> impl IntoView { +fn InlineDialog( + open: bool, + title: View, + actions: ChildViews, + children: ChildViews, +) -> impl IntoView { if open { let actions_row = view! { row(gap = 10.0) { diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index f52af66..aaed846 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -11,9 +11,9 @@ use std::collections::HashMap; use std::error::Error; use std::future::Future; use std::iter; -use std::time::Instant; use std::marker::PhantomData; use std::rc::Rc; +use std::time::Instant; use ruin_reactivity::effect; use ruin_runtime::queue_future; @@ -25,6 +25,9 @@ use ruin_ui::{ TextSystem, TextWrap, UiSize, WindowController, WindowSpec, WindowUpdate, layout_snapshot_with_cache, }; +#[cfg(target_os = "macos")] +use ruin_ui_platform_macos::start_macos_ui; +#[cfg(target_os = "linux")] use ruin_ui_platform_wayland::start_wayland_ui; pub use ResourceState::{Pending, Ready}; @@ -32,6 +35,16 @@ pub use ruin_app_proc_macros::{component, context_provider, view}; pub type Result = std::result::Result>; +#[cfg(target_os = "linux")] +fn start_platform_ui() -> ruin_ui::UiRuntime { + start_wayland_ui() +} + +#[cfg(target_os = "macos")] +fn start_platform_ui() -> ruin_ui::UiRuntime { + start_macos_ui() +} + #[derive(Clone, Debug)] pub struct Window { spec: WindowSpec, @@ -149,7 +162,7 @@ impl MountedApp { render_state, } = self; - let mut ui = start_wayland_ui(); + let mut ui = start_platform_ui(); let window = ui.create_window(app_window.spec.clone())?; let initial_viewport = app_window .spec @@ -378,7 +391,7 @@ impl MountedApp { } fn handle_text_selection_event( - window: &WindowController, + _window: &WindowController, interaction_tree: &InteractionTree, event: &RoutedPointerEvent, text_selection: &TextSelectionState, @@ -439,7 +452,8 @@ impl MountedApp { if selection_changed { text_selection.version.update(|value| *value += 1); - sync_primary_selection(window, interaction_tree, *text_selection.selection.borrow())?; + #[cfg(target_os = "linux")] + sync_primary_selection(_window, interaction_tree, *text_selection.selection.borrow())?; } Ok(()) @@ -1848,6 +1862,7 @@ fn apply_text_selection_overlay( scene.items = next_items; } +#[cfg(target_os = "linux")] fn sync_primary_selection( window: &WindowController, interaction_tree: &InteractionTree, @@ -1983,8 +1998,9 @@ pub mod prelude { Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View, WidgetRef, Window, block, button, colors, column, component, context_provider, provide, - row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource, use_shortcut, - use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, view, + row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource, + use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, + view, }; pub use ruin_ui::{ Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton, diff --git a/lib/ruin_app_proc_macros/src/lib.rs b/lib/ruin_app_proc_macros/src/lib.rs index e007c1b..99b39b9 100644 --- a/lib/ruin_app_proc_macros/src/lib.rs +++ b/lib/ruin_app_proc_macros/src/lib.rs @@ -60,10 +60,7 @@ fn expand_component(mut function: ItemFn) -> Result { Some(prop) if prop.ident == "children" => { let prop = inputs.pop().expect("last input should exist"); let kind = parse_children_contract_kind(&prop.ty)?; - Some(ChildContract { - ty: prop.ty, - kind, - }) + Some(ChildContract { ty: prop.ty, kind }) } _ => None, }; @@ -87,7 +84,8 @@ fn expand_component(mut function: ItemFn) -> Result { } Some(contract) => { let child_arg_ty = child_builder_arg_type_tokens(contract.kind); - let child_value = wrap_special_value_tokens(contract.kind, &format_ident!("children")); + let child_value = + wrap_special_value_tokens(contract.kind, &format_ident!("children")); let child_field_ident = child_field_ident .as_ref() .expect("child field ident should exist"); @@ -574,10 +572,7 @@ fn child_builder_arg_type_tokens(kind: SpecialValueKind) -> proc_macro2::TokenSt } } -fn wrap_special_value_tokens( - kind: SpecialValueKind, - ident: &Ident, -) -> proc_macro2::TokenStream { +fn wrap_special_value_tokens(kind: SpecialValueKind, ident: &Ident) -> proc_macro2::TokenStream { match kind { SpecialValueKind::Plain => quote! { #ident }, SpecialValueKind::ChildViews => quote! { ::ruin_app::ChildViews::from_children(#ident) }, diff --git a/lib/runtime/src/channel/mpsc.rs b/lib/runtime/src/channel/mpsc.rs index d6a2e08..28128ae 100644 --- a/lib/runtime/src/channel/mpsc.rs +++ b/lib/runtime/src/channel/mpsc.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; use crate::op::completion::{CompletionFuture, CompletionHandle}; -use crate::sys::linux::channel::runtime_waiter; +use crate::sys::current::channel::runtime_waiter; /// Creates a bounded channel with room for at most `capacity` queued messages. /// diff --git a/lib/runtime/src/channel/oneshot.rs b/lib/runtime/src/channel/oneshot.rs index 631d7d9..50fc008 100644 --- a/lib/runtime/src/channel/oneshot.rs +++ b/lib/runtime/src/channel/oneshot.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; use crate::op::completion::{CompletionFuture, CompletionHandle}; -use crate::sys::linux::channel::runtime_waiter; +use crate::sys::current::channel::runtime_waiter; /// Creates a single-use channel for transferring one value from a [`Sender`] to a [`Receiver`]. /// diff --git a/lib/runtime/src/fd.rs b/lib/runtime/src/fd.rs index 119df28..9e9948e 100644 --- a/lib/runtime/src/fd.rs +++ b/lib/runtime/src/fd.rs @@ -3,44 +3,9 @@ use std::io; use std::os::fd::RawFd; -use crate::op::completion::completion_for_current_thread; -use crate::platform::linux_x86_64::runtime::with_current_driver; -use crate::platform::linux_x86_64::uring::{IORING_OP_POLL_ADD, IoUringCqe}; - /// Waits until `fd` becomes readable or reports an error/hangup condition. pub async fn wait_readable(fd: RawFd) -> io::Result<()> { - submit_poll(fd, libc::POLLIN | libc::POLLERR | libc::POLLHUP).await -} - -async fn submit_poll(fd: RawFd, mask: i16) -> io::Result<()> { - let (future, handle) = completion_for_current_thread::>(); - let callback_handle = handle.clone(); - let token = with_current_driver(|driver| { - driver.submit_operation( - move |sqe| { - sqe.opcode = IORING_OP_POLL_ADD; - sqe.fd = fd; - sqe.len = 0; - sqe.op_flags = mask as u32; - }, - move |cqe| { - callback_handle.complete(cqe_to_result(cqe)); - }, - ) - })?; - - handle.set_cancel(move || { - let _ = with_current_driver(|driver| driver.cancel_operation(token)); - }); - - future.await -} - -fn cqe_to_result(cqe: IoUringCqe) -> io::Result<()> { - if cqe.res < 0 { - return Err(io::Error::from_raw_os_error(-cqe.res)); - } - Ok(()) + crate::sys::current::fd::wait_readable(fd).await } #[cfg(test)] @@ -53,8 +18,8 @@ mod tests { #[test] fn wait_readable_resolves_for_pipe() { let mut fds = [0; 2]; - let result = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) }; - assert_eq!(result, 0, "pipe2 should succeed"); + let result = unsafe { libc::pipe(fds.as_mut_ptr()) }; + assert_eq!(result, 0, "pipe should succeed"); let read_fd = fds[0]; let write_fd = fds[1]; diff --git a/lib/runtime/src/fs.rs b/lib/runtime/src/fs.rs index 8b8514d..96204f8 100644 --- a/lib/runtime/src/fs.rs +++ b/lib/runtime/src/fs.rs @@ -18,7 +18,7 @@ use crate::op::fs::{ FileType as RawFileType, FsOp, MetadataTarget, OpenOptions as OpOpenOptions, RawDirEntry as OpDirEntry, RawMetadata, }; -use crate::sys::linux::fs as sys_fs; +use crate::sys::current::fs as sys_fs; struct FileInner { fd: OwnedFd, diff --git a/lib/runtime/src/lib.rs b/lib/runtime/src/lib.rs index 2417077..c2aaf8d 100644 --- a/lib/runtime/src/lib.rs +++ b/lib/runtime/src/lib.rs @@ -1,7 +1,7 @@ //! Runtime, driver, async I/O, and channel primitives for RUIN. //! //! The crate is centered around a single-threaded event loop with explicit worker threads, -//! JavaScript-style microtask/macrotask scheduling, and Linux `io_uring`-backed I/O. +//! JavaScript-style microtask/macrotask scheduling and platform-specific async I/O backends. //! //! Most users will start with: //! @@ -11,17 +11,22 @@ //! //! # Platform support //! -//! `ruin-runtime` currently targets Linux on `x86_64`. +//! `ruin-runtime` currently targets: +//! - Linux `x86_64` +//! - macOS `aarch64` //! //! RUIN runtime foundations. //! -//! This crate provides a Linux x86_64 runtime substrate: the mesh allocator, the driver, and a -//! single-threaded runtime loop with worker-thread task forwarding. +//! This crate provides a platform runtime substrate with a single-threaded runtime loop and +//! worker-thread task forwarding. #![feature(thread_local)] -#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] -compile_error!("ruin-runtime currently supports only Linux x86_64."); +#[cfg(not(any( + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "macos", target_arch = "aarch64") +)))] +compile_error!("ruin-runtime currently supports Linux x86_64 and macOS aarch64."); extern crate alloc; @@ -56,11 +61,24 @@ pub use ruin_runtime_proc_macros::async_main; /// thread before calling [`run`]. pub use ruin_runtime_proc_macros::main; -/// Driver primitives re-exported from the Linux x86_64 backend. -#[cfg(all(target_os = "linux", target_arch = "x86_64"))] -pub use platform::linux_x86_64::driver::{ +/// Driver primitives re-exported from the active backend. +#[cfg(any( + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "macos", target_arch = "aarch64") +))] +pub use platform::current::driver::{ Driver, ReadyEvents, ThreadNotifier, create, create_driver, monotonic_now, }; +/// Runtime/event-loop primitives re-exported from the active backend. +#[cfg(any( + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "macos", target_arch = "aarch64") +))] +pub use platform::current::runtime::{ + IntervalHandle, JoinHandle, ThreadHandle, TimeoutHandle, WorkerHandle, clear_interval, + clear_timeout, current_thread_handle, queue_future, queue_microtask, queue_task, run, + set_interval, set_timeout, spawn_worker, yield_now, +}; /// Public mesh-allocator surface re-exported from the Linux x86_64 backend. #[cfg(all(target_os = "linux", target_arch = "x86_64"))] pub use platform::linux_x86_64::mesh_alloc::{ @@ -77,22 +95,17 @@ pub use platform::linux_x86_64::mesh_alloc::{ /// Additional allocator helpers re-exported from the Linux x86_64 backend. #[cfg(all(target_os = "linux", target_arch = "x86_64"))] pub use platform::linux_x86_64::mesh_alloc::{FreelistId, bitmaps_meshable}; -/// Runtime/event-loop primitives re-exported from the Linux x86_64 backend. -#[cfg(all(target_os = "linux", target_arch = "x86_64"))] -pub use platform::linux_x86_64::runtime::{ - IntervalHandle, JoinHandle, ThreadHandle, TimeoutHandle, WorkerHandle, clear_interval, - clear_timeout, current_thread_handle, queue_future, queue_microtask, queue_task, run, - set_interval, set_timeout, spawn_worker, yield_now, -}; /// Returns the default global mesh allocator configuration for this crate. /// /// This is useful when embedding the allocator in a `#[global_allocator]` static. +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] pub const fn default_global_allocator() -> GlobalMeshAllocator { GlobalMeshAllocator::with_default_config() } #[cfg(test)] +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] mod tests { use super::{MeshAllocator, page_size}; diff --git a/lib/runtime/src/net.rs b/lib/runtime/src/net.rs index 7902f53..c237d41 100644 --- a/lib/runtime/src/net.rs +++ b/lib/runtime/src/net.rs @@ -72,10 +72,10 @@ impl TcpStream { where A: ToSocketAddrs + Send + 'static, { - let addrs = crate::sys::linux::net::resolve_addrs(addr).await?; + let addrs = crate::sys::current::net::resolve_addrs(addr).await?; let mut last_error = None; for addr in addrs { - match crate::sys::linux::net::connect_stream(addr).await { + match crate::sys::current::net::connect_stream(addr).await { Ok(fd) => return Ok(Self::from_owned_fd(fd)), Err(error) => last_error = Some(error), } @@ -92,7 +92,7 @@ impl TcpStream { /// Connects to `addr`, failing if the deadline elapses first. pub async fn connect_timeout(addr: &SocketAddr, timeout: Duration) -> io::Result { validate_timeout(timeout)?; - crate::sys::linux::net::connect_stream_timeout(*addr, timeout) + crate::sys::current::net::connect_stream_timeout(*addr, timeout) .await .map(Self::from_owned_fd) } @@ -101,10 +101,10 @@ impl TcpStream { pub async fn read(&mut self, buf: &mut [u8]) -> io::Result { let data = match self.read_timeout_value() { Some(timeout) => { - crate::sys::linux::net::recv_timeout(self.raw_fd(), buf.len(), 0, timeout).await? + crate::sys::current::net::recv_timeout(self.raw_fd(), buf.len(), 0, timeout).await? } None => { - crate::sys::linux::net::recv(NetOp::Recv { + crate::sys::current::net::recv(NetOp::Recv { fd: self.raw_fd(), len: buf.len(), flags: 0, @@ -136,10 +136,11 @@ impl TcpStream { pub async fn write(&mut self, buf: &[u8]) -> io::Result { match self.write_timeout_value() { Some(timeout) => { - crate::sys::linux::net::send_timeout(self.raw_fd(), buf.to_vec(), 0, timeout).await + crate::sys::current::net::send_timeout(self.raw_fd(), buf.to_vec(), 0, timeout) + .await } None => { - crate::sys::linux::net::send(NetOp::Send { + crate::sys::current::net::send(NetOp::Send { fd: self.raw_fd(), data: buf.to_vec(), flags: 0, @@ -166,7 +167,7 @@ impl TcpStream { /// Shuts down the read, write, or both halves of the connection. pub async fn shutdown(&self, how: Shutdown) -> io::Result<()> { - crate::sys::linux::net::shutdown(NetOp::Shutdown { + crate::sys::current::net::shutdown(NetOp::Shutdown { fd: self.raw_fd(), how, }) @@ -175,39 +176,39 @@ impl TcpStream { /// Duplicates the underlying stream socket. pub async fn try_clone(&self) -> io::Result { - crate::sys::linux::net::duplicate(self.raw_fd()) + crate::sys::current::net::duplicate(self.raw_fd()) .await .map(Self::from_owned_fd) } /// Returns the local socket address of this stream. pub fn local_addr(&self) -> io::Result { - crate::sys::linux::net::local_addr(self.raw_fd()) + crate::sys::current::net::local_addr(self.raw_fd()) } /// Returns the remote peer address of this stream. pub fn peer_addr(&self) -> io::Result { - crate::sys::linux::net::peer_addr(self.raw_fd()) + crate::sys::current::net::peer_addr(self.raw_fd()) } /// Reads the current `TCP_NODELAY` setting. pub fn nodelay(&self) -> io::Result { - crate::sys::linux::net::nodelay(self.raw_fd()) + crate::sys::current::net::nodelay(self.raw_fd()) } /// Enables or disables `TCP_NODELAY`. pub fn set_nodelay(&self, enabled: bool) -> io::Result<()> { - crate::sys::linux::net::set_nodelay(self.raw_fd(), enabled) + crate::sys::current::net::set_nodelay(self.raw_fd(), enabled) } /// Reads the socket's IP time-to-live value. pub fn ttl(&self) -> io::Result { - crate::sys::linux::net::ttl(self.raw_fd()) + crate::sys::current::net::ttl(self.raw_fd()) } /// Sets the socket's IP time-to-live value. pub fn set_ttl(&self, ttl: u32) -> io::Result<()> { - crate::sys::linux::net::set_ttl(self.raw_fd(), ttl) + crate::sys::current::net::set_ttl(self.raw_fd(), ttl) } /// Returns the read timeout used by async read operations on this handle. @@ -269,10 +270,10 @@ impl TcpListener { where A: ToSocketAddrs + Send + 'static, { - let addrs = crate::sys::linux::net::resolve_addrs(addr).await?; + let addrs = crate::sys::current::net::resolve_addrs(addr).await?; let mut last_error = None; for addr in addrs { - match crate::sys::linux::net::bind_listener(addr, None).await { + match crate::sys::current::net::bind_listener(addr, None).await { Ok(fd) => return Ok(Self::from_owned_fd(fd)), Err(error) => last_error = Some(error), } @@ -288,7 +289,8 @@ impl TcpListener { /// Accepts an incoming connection. pub async fn accept(&self) -> io::Result<(TcpStream, SocketAddr)> { - let accepted = crate::sys::linux::net::accept(NetOp::Accept { fd: self.raw_fd() }).await?; + let accepted = + crate::sys::current::net::accept(NetOp::Accept { fd: self.raw_fd() }).await?; let stream = TcpStream::from_owned_fd(unsafe { OwnedFd::from_raw_fd(accepted.fd) }); Ok((stream, accepted.peer_addr)) @@ -296,17 +298,17 @@ impl TcpListener { /// Returns the local socket address of this listener. pub fn local_addr(&self) -> io::Result { - crate::sys::linux::net::local_addr(self.raw_fd()) + crate::sys::current::net::local_addr(self.raw_fd()) } /// Reads the listener socket's IP time-to-live value. pub fn ttl(&self) -> io::Result { - crate::sys::linux::net::ttl(self.raw_fd()) + crate::sys::current::net::ttl(self.raw_fd()) } /// Sets the listener socket's IP time-to-live value. pub fn set_ttl(&self, ttl: u32) -> io::Result<()> { - crate::sys::linux::net::set_ttl(self.raw_fd(), ttl) + crate::sys::current::net::set_ttl(self.raw_fd(), ttl) } fn from_owned_fd(fd: OwnedFd) -> Self { @@ -326,10 +328,10 @@ impl UdpSocket { where A: ToSocketAddrs + Send + 'static, { - let addrs = crate::sys::linux::net::resolve_addrs(addr).await?; + let addrs = crate::sys::current::net::resolve_addrs(addr).await?; let mut last_error = None; for addr in addrs { - match crate::sys::linux::net::bind_datagram(addr).await { + match crate::sys::current::net::bind_datagram(addr).await { Ok(fd) => return Ok(Self::from_owned_fd(fd)), Err(error) => last_error = Some(error), } @@ -351,10 +353,10 @@ impl UdpSocket { where A: ToSocketAddrs + Send + 'static, { - let addrs = crate::sys::linux::net::resolve_addrs(addr).await?; + let addrs = crate::sys::current::net::resolve_addrs(addr).await?; let mut last_error = None; for addr in addrs { - match crate::sys::linux::net::connect(NetOp::Connect { + match crate::sys::current::net::connect(NetOp::Connect { fd: self.raw_fd(), addr, }) @@ -377,10 +379,11 @@ impl UdpSocket { pub async fn send(&self, buf: &[u8]) -> io::Result { match self.write_timeout_value() { Some(timeout) => { - crate::sys::linux::net::send_timeout(self.raw_fd(), buf.to_vec(), 0, timeout).await + crate::sys::current::net::send_timeout(self.raw_fd(), buf.to_vec(), 0, timeout) + .await } None => { - crate::sys::linux::net::send(NetOp::Send { + crate::sys::current::net::send(NetOp::Send { fd: self.raw_fd(), data: buf.to_vec(), flags: 0, @@ -394,10 +397,10 @@ impl UdpSocket { pub async fn recv(&self, buf: &mut [u8]) -> io::Result { let data = match self.read_timeout_value() { Some(timeout) => { - crate::sys::linux::net::recv_timeout(self.raw_fd(), buf.len(), 0, timeout).await? + crate::sys::current::net::recv_timeout(self.raw_fd(), buf.len(), 0, timeout).await? } None => { - crate::sys::linux::net::recv(NetOp::Recv { + crate::sys::current::net::recv(NetOp::Recv { fd: self.raw_fd(), len: buf.len(), flags: 0, @@ -414,7 +417,7 @@ impl UdpSocket { pub async fn peek(&self, buf: &mut [u8]) -> io::Result { let data = match self.read_timeout_value() { Some(timeout) => { - crate::sys::linux::net::recv_timeout( + crate::sys::current::net::recv_timeout( self.raw_fd(), buf.len(), libc::MSG_PEEK, @@ -423,7 +426,7 @@ impl UdpSocket { .await? } None => { - crate::sys::linux::net::recv(NetOp::Recv { + crate::sys::current::net::recv(NetOp::Recv { fd: self.raw_fd(), len: buf.len(), flags: libc::MSG_PEEK, @@ -441,13 +444,13 @@ impl UdpSocket { where A: ToSocketAddrs + Send + 'static, { - let addrs = crate::sys::linux::net::resolve_addrs(addr).await?; + let addrs = crate::sys::current::net::resolve_addrs(addr).await?; let mut last_error = None; let timeout = self.write_timeout_value(); for addr in addrs { let result = match timeout { Some(timeout) => { - crate::sys::linux::net::send_to_timeout( + crate::sys::current::net::send_to_timeout( self.raw_fd(), buf.to_vec(), addr, @@ -457,7 +460,7 @@ impl UdpSocket { .await } None => { - crate::sys::linux::net::send_to(NetOp::SendTo { + crate::sys::current::net::send_to(NetOp::SendTo { fd: self.raw_fd(), target: addr, data: buf.to_vec(), @@ -484,11 +487,11 @@ impl UdpSocket { pub async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> { let datagram = match self.read_timeout_value() { Some(timeout) => { - crate::sys::linux::net::recv_from_timeout(self.raw_fd(), buf.len(), 0, timeout) + crate::sys::current::net::recv_from_timeout(self.raw_fd(), buf.len(), 0, timeout) .await? } None => { - crate::sys::linux::net::recv_from(NetOp::RecvFrom { + crate::sys::current::net::recv_from(NetOp::RecvFrom { fd: self.raw_fd(), len: buf.len(), flags: 0, @@ -505,7 +508,7 @@ impl UdpSocket { pub async fn peek_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> { let datagram = match self.read_timeout_value() { Some(timeout) => { - crate::sys::linux::net::recv_from_timeout( + crate::sys::current::net::recv_from_timeout( self.raw_fd(), buf.len(), libc::MSG_PEEK, @@ -514,7 +517,7 @@ impl UdpSocket { .await? } None => { - crate::sys::linux::net::recv_from(NetOp::RecvFrom { + crate::sys::current::net::recv_from(NetOp::RecvFrom { fd: self.raw_fd(), len: buf.len(), flags: libc::MSG_PEEK, @@ -529,39 +532,39 @@ impl UdpSocket { /// Duplicates the underlying UDP socket. pub async fn try_clone(&self) -> io::Result { - crate::sys::linux::net::duplicate(self.raw_fd()) + crate::sys::current::net::duplicate(self.raw_fd()) .await .map(Self::from_owned_fd) } /// Returns the local socket address of this socket. pub fn local_addr(&self) -> io::Result { - crate::sys::linux::net::local_addr(self.raw_fd()) + crate::sys::current::net::local_addr(self.raw_fd()) } /// Returns the connected peer address, if the socket has been connected. pub fn peer_addr(&self) -> io::Result { - crate::sys::linux::net::peer_addr(self.raw_fd()) + crate::sys::current::net::peer_addr(self.raw_fd()) } /// Reads the `SO_BROADCAST` setting. pub fn broadcast(&self) -> io::Result { - crate::sys::linux::net::broadcast(self.raw_fd()) + crate::sys::current::net::broadcast(self.raw_fd()) } /// Enables or disables `SO_BROADCAST`. pub fn set_broadcast(&self, enabled: bool) -> io::Result<()> { - crate::sys::linux::net::set_broadcast(self.raw_fd(), enabled) + crate::sys::current::net::set_broadcast(self.raw_fd(), enabled) } /// Reads the socket's IP time-to-live value. pub fn ttl(&self) -> io::Result { - crate::sys::linux::net::ttl(self.raw_fd()) + crate::sys::current::net::ttl(self.raw_fd()) } /// Sets the socket's IP time-to-live value. pub fn set_ttl(&self, ttl: u32) -> io::Result<()> { - crate::sys::linux::net::set_ttl(self.raw_fd(), ttl) + crate::sys::current::net::set_ttl(self.raw_fd(), ttl) } /// Returns the read timeout used by async receive operations on this handle. @@ -627,13 +630,13 @@ impl HyperRead for TcpStream { if this.pending_read.is_none() { this.pending_read = Some(match this.read_timeout_value() { - Some(timeout) => Box::pin(crate::sys::linux::net::recv_timeout( + Some(timeout) => Box::pin(crate::sys::current::net::recv_timeout( this.raw_fd(), buf.remaining(), 0, timeout, )), - None => crate::sys::linux::net::recv_future(this.raw_fd(), buf.remaining()), + None => crate::sys::current::net::recv_future(this.raw_fd(), buf.remaining()), }); } @@ -671,13 +674,13 @@ impl HyperWrite for TcpStream { if this.pending_write.is_none() { this.pending_write = Some(match this.write_timeout_value() { - Some(timeout) => Box::pin(crate::sys::linux::net::send_timeout( + Some(timeout) => Box::pin(crate::sys::current::net::send_timeout( this.raw_fd(), buf.to_vec(), 0, timeout, )), - None => crate::sys::linux::net::send_future(this.raw_fd(), buf.to_vec()), + None => crate::sys::current::net::send_future(this.raw_fd(), buf.to_vec()), }); } @@ -707,7 +710,7 @@ impl HyperWrite for TcpStream { fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let this = self.get_mut(); if this.pending_shutdown.is_none() { - this.pending_shutdown = Some(crate::sys::linux::net::shutdown_future( + this.pending_shutdown = Some(crate::sys::current::net::shutdown_future( this.raw_fd(), Shutdown::Write, )); diff --git a/lib/runtime/src/op/completion.rs b/lib/runtime/src/op/completion.rs index be3f663..98ff0ef 100644 --- a/lib/runtime/src/op/completion.rs +++ b/lib/runtime/src/op/completion.rs @@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::task::{Context, Poll, Waker}; -use crate::platform::linux_x86_64::runtime::{ThreadHandle, current_thread_handle}; +use crate::platform::current::runtime::{ThreadHandle, current_thread_handle}; type CancelCallback = Box; diff --git a/lib/runtime/src/platform/linux_x86_64/runtime.rs b/lib/runtime/src/platform/linux_x86_64/runtime.rs index 039a4b5..d92102e 100644 --- a/lib/runtime/src/platform/linux_x86_64/runtime.rs +++ b/lib/runtime/src/platform/linux_x86_64/runtime.rs @@ -1178,7 +1178,11 @@ fn schedule_immediate_interval(owner: *const ThreadState, id: usize) { }; (callback.borrow_mut())(); // Re-enqueue for the next turn if still live. - if current_thread().immediate_intervals.borrow().contains_key(&id) { + if current_thread() + .immediate_intervals + .borrow() + .contains_key(&id) + { schedule_immediate_interval(owner, id); } })); @@ -1207,12 +1211,8 @@ fn dispatch_expired_timers() { .deadline .checked_add(interval) .unwrap_or(Duration::MAX); - let next_timer = TimerNode::interval( - timer.id, - next_deadline, - interval, - Rc::clone(&callback), - ); + let next_timer = + TimerNode::interval(timer.id, next_deadline, interval, Rc::clone(&callback)); current_thread().timers.borrow_mut().insert(next_timer); push_local_macrotask(Box::new(move || { @@ -1394,8 +1394,7 @@ mod tests { // turns by awaiting a sleep between checks. let count = Rc::new(Cell::new(0usize)); let count_clone = Rc::clone(&count); - let handle_slot: Rc>> = - Rc::new(RefCell::new(None)); + let handle_slot: Rc>> = Rc::new(RefCell::new(None)); let handle_slot_clone = Rc::clone(&handle_slot); let handle = set_interval(Duration::ZERO, move || { diff --git a/lib/runtime/src/platform/macos_aarch64/driver.rs b/lib/runtime/src/platform/macos_aarch64/driver.rs new file mode 100644 index 0000000..bdd96e4 --- /dev/null +++ b/lib/runtime/src/platform/macos_aarch64/driver.rs @@ -0,0 +1,340 @@ +//! Public runtime driver primitives for macOS. + +use std::cell::Cell; +use std::io; +use std::os::fd::RawFd; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +#[derive(Clone)] +struct NotifierInner { + write_fd: RawFd, + closed: Arc, +} + +impl NotifierInner { + fn notify(&self) -> io::Result<()> { + if self.closed.load(Ordering::Acquire) { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "target runtime driver is closed", + )); + } + + let byte = 1u8; + let written = unsafe { + libc::write( + self.write_fd, + &byte as *const u8 as *const libc::c_void, + std::mem::size_of::(), + ) + }; + if written < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::WouldBlock { + return Ok(()); + } + return Err(error); + } + + Ok(()) + } +} + +#[derive(Clone)] +/// Cross-thread notifier for a runtime thread's driver. +pub struct ThreadNotifier { + inner: NotifierInner, +} + +impl ThreadNotifier { + /// Sends a wake notification to the target runtime thread. + pub fn notify(&self) -> io::Result<()> { + self.inner.notify() + } +} + +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)] +/// Readiness information returned by [`Driver::poll`]. +pub struct ReadyEvents { + /// One or more timer expirations are pending. + pub timer: bool, + /// One or more cross-thread wake notifications are pending. + pub wake: bool, +} + +/// Low-level macOS runtime driver backed by `kqueue` and a wake pipe. +pub struct Driver { + kqueue_fd: RawFd, + wake_read_fd: RawFd, + wake_write_fd: RawFd, + closed: Arc, + timer_deadline: Cell>, + pending_wakes: Cell, + pending_timers: Cell, +} + +/// Creates a new driver and its paired [`ThreadNotifier`]. +pub fn create() -> io::Result<(Driver, ThreadNotifier)> { + create_driver() +} + +/// Creates a new driver and its paired [`ThreadNotifier`]. +pub fn create_driver() -> io::Result<(Driver, ThreadNotifier)> { + let kqueue_fd = cvt(unsafe { libc::kqueue() })?; + + let mut pipe_fds = [0; 2]; + cvt(unsafe { libc::pipe(pipe_fds.as_mut_ptr()) })?; + let wake_read_fd = pipe_fds[0]; + let wake_write_fd = pipe_fds[1]; + + set_nonblocking(wake_read_fd)?; + set_nonblocking(wake_write_fd)?; + + let event = libc::kevent { + ident: wake_read_fd as usize, + filter: libc::EVFILT_READ, + flags: libc::EV_ADD | libc::EV_ENABLE, + fflags: 0, + data: 0, + udata: std::ptr::null_mut(), + }; + + let submitted = unsafe { + libc::kevent( + kqueue_fd, + &event, + 1, + std::ptr::null_mut(), + 0, + std::ptr::null(), + ) + }; + if submitted < 0 { + let error = io::Error::last_os_error(); + unsafe { + libc::close(wake_read_fd); + libc::close(wake_write_fd); + libc::close(kqueue_fd); + } + return Err(error); + } + + let closed = Arc::new(AtomicBool::new(false)); + let driver = Driver { + kqueue_fd, + wake_read_fd, + wake_write_fd, + closed: Arc::clone(&closed), + timer_deadline: Cell::new(None), + pending_wakes: Cell::new(0), + pending_timers: Cell::new(0), + }; + + let notifier = ThreadNotifier { + inner: NotifierInner { + write_fd: wake_write_fd, + closed, + }, + }; + + Ok((driver, notifier)) +} + +impl Driver { + pub(crate) fn bind_current_thread(&self) {} + + pub(crate) fn unbind_current_thread(&self) {} + + /// Polls the driver without blocking. + pub fn poll(&self) -> io::Result> { + let mut pending = ReadyEvents::default(); + if self.pending_wakes.get() > 0 { + pending.wake = true; + } + if self.pending_timers.get() > 0 { + pending.timer = true; + } + if pending.wake || pending.timer { + return Ok(Some(pending)); + } + self.process(Some(Duration::ZERO)) + } + + /// Blocks until at least one event is available. + pub fn wait(&self) -> io::Result<()> { + let now = monotonic_now()?; + let timeout = self + .timer_deadline + .get() + .map(|deadline| deadline.saturating_sub(now)); + let _ = self.process(timeout)?; + Ok(()) + } + + /// Updates the currently armed timer deadline. + pub fn rearm_timer(&self, deadline: Option) -> io::Result<()> { + self.timer_deadline.set(deadline); + Ok(()) + } + + /// Drains the accumulated wake notification count. + pub fn drain_wake(&self) -> io::Result { + let wakes = self.pending_wakes.replace(0); + if wakes == 0 { + Err(io::Error::new( + io::ErrorKind::WouldBlock, + "no wake events are pending", + )) + } else { + Ok(wakes) + } + } + + /// Drains the accumulated timer-expiration count. + pub fn drain_timer(&self) -> io::Result { + let timers = self.pending_timers.replace(0); + if timers == 0 { + Err(io::Error::new( + io::ErrorKind::WouldBlock, + "no timer events are pending", + )) + } else { + Ok(timers) + } + } + + fn process(&self, timeout: Option) -> io::Result> { + let mut ready = ReadyEvents::default(); + + let mut events = [unsafe { std::mem::zeroed::() }; 16]; + let timeout_spec = timeout_to_timespec(timeout); + let timeout_ptr = timeout_spec + .as_ref() + .map_or(std::ptr::null(), |value| value as *const libc::timespec); + + let result = unsafe { + libc::kevent( + self.kqueue_fd, + std::ptr::null(), + 0, + events.as_mut_ptr(), + events.len() as i32, + timeout_ptr, + ) + }; + if result < 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + return Err(error); + } + } + + let mut saw_any = false; + let count = result.max(0) as usize; + if count > 0 { + saw_any = true; + for event in events.iter().take(count) { + if event.ident as RawFd == self.wake_read_fd { + ready.wake = true; + let wakes = drain_wake_pipe(self.wake_read_fd)?; + self.pending_wakes + .set(self.pending_wakes.get().saturating_add(wakes)); + } + } + } + + if let Some(deadline) = self.timer_deadline.get() + && monotonic_now()? >= deadline + { + ready.timer = true; + saw_any = true; + self.timer_deadline.set(None); + self.pending_timers + .set(self.pending_timers.get().saturating_add(1)); + } + + if saw_any { Ok(Some(ready)) } else { Ok(None) } + } +} + +impl Drop for Driver { + fn drop(&mut self) { + self.closed.store(true, Ordering::Release); + unsafe { + libc::close(self.wake_read_fd); + libc::close(self.wake_write_fd); + libc::close(self.kqueue_fd); + } + } +} + +/// Returns the current monotonic clock reading. +pub fn monotonic_now() -> io::Result { + let mut now = std::mem::MaybeUninit::::uninit(); + let result = unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, now.as_mut_ptr()) }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + + let now = unsafe { now.assume_init() }; + Ok(Duration::new(now.tv_sec as u64, now.tv_nsec as u32)) +} + +fn cvt(value: libc::c_int) -> io::Result { + if value < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(value) + } +} + +fn set_nonblocking(fd: RawFd) -> io::Result<()> { + let flags = cvt(unsafe { libc::fcntl(fd, libc::F_GETFL) })?; + cvt(unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) })?; + Ok(()) +} + +fn timeout_to_timespec(timeout: Option) -> Option { + timeout.map(|value| libc::timespec { + tv_sec: value.as_secs() as libc::time_t, + tv_nsec: value.subsec_nanos() as libc::c_long, + }) +} + +fn drain_wake_pipe(fd: RawFd) -> io::Result { + let mut wakes = 0u64; + let mut buf = [0u8; 256]; + + loop { + let read = unsafe { + libc::read( + fd, + buf.as_mut_ptr() as *mut libc::c_void, + buf.len() as libc::size_t, + ) + }; + + if read > 0 { + wakes = wakes.saturating_add(read as u64); + continue; + } + + if read == 0 { + break; + } + + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::WouldBlock { + break; + } + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + + return Err(error); + } + + Ok(wakes.max(1)) +} diff --git a/lib/runtime/src/platform/macos_aarch64/mod.rs b/lib/runtime/src/platform/macos_aarch64/mod.rs new file mode 100644 index 0000000..a625c1e --- /dev/null +++ b/lib/runtime/src/platform/macos_aarch64/mod.rs @@ -0,0 +1,2 @@ +pub mod driver; +pub mod runtime; diff --git a/lib/runtime/src/platform/macos_aarch64/runtime.rs b/lib/runtime/src/platform/macos_aarch64/runtime.rs new file mode 100644 index 0000000..f182a2d --- /dev/null +++ b/lib/runtime/src/platform/macos_aarch64/runtime.rs @@ -0,0 +1,1416 @@ +//! Public runtime loop and worker-thread primitives. + +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::future::Future; +use std::io; +use std::pin::Pin; +use std::ptr; +use std::rc::Rc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; +use std::time::Duration; + +use super::driver::{Driver, ThreadNotifier, create_driver, monotonic_now}; +use crate::trace_targets; + +type LocalTask = Box; +type SendTask = Box; +type LocalBoxFuture = Pin + 'static>>; +type IntervalCallback = Rc>>; + +/// If the microtask queue runs more than this many tasks in a single turn +/// without yielding to the macrotask queue, a warning is emitted. +const MICROTASK_STARVATION_THRESHOLD: u64 = 1000; + +struct MacroTask { + task: LocalTask, + /// Wall time at which this task entered the local queue. Populated only + /// in debug builds; used to emit a trace event reporting queue-wait time. + #[cfg(debug_assertions)] + queued_at: Duration, +} + +#[thread_local] +static mut CURRENT_THREAD: *mut ThreadState = ptr::null_mut(); + +#[derive(Clone)] +/// Handle for queueing work onto a specific runtime thread. +pub struct ThreadHandle { + shared: Arc, +} + +/// Handle for a worker runtime thread spawned with [`spawn_worker`]. +pub struct WorkerHandle { + thread: ThreadHandle, + completion: Arc, +} + +#[derive(Clone)] +/// Handle returned by [`set_timeout`]. +pub struct TimeoutHandle { + id: usize, + owner: *const ThreadState, + _local: Rc<()>, +} + +#[derive(Clone)] +/// Handle returned by [`set_interval`]. +pub struct IntervalHandle { + id: usize, + owner: *const ThreadState, + _local: Rc<()>, +} + +/// Handle returned by [`queue_future`]. +/// +/// Awaiting a join handle yields the output of the queued future. +pub struct JoinHandle { + state: Rc>, +} + +/// Future returned by [`yield_now`]. +/// +/// Awaiting this future will immediately yield control back to the runtime scheduler, allowing other queued microtasks +/// to run before the current task continues executing. Note that continuation of futures runs as a microtask, so this +/// can only yield to other microtasks and not to macrotasks (driver events such as file or network I/O, timers, or +/// channel messages). +pub struct YieldNow { + yielded: bool, +} + +/// Returns a handle for the current runtime thread. +/// +/// If the current thread has not yet entered the runtime, the runtime state is initialized lazily. +/// +/// # Panics +/// +/// Panics if the runtime cannot initialize its driver for the current thread. +pub fn current_thread_handle() -> ThreadHandle { + current_thread().handle() +} + +pub(crate) fn try_current_thread_handle() -> Option { + unsafe { (!CURRENT_THREAD.is_null()).then(|| (*CURRENT_THREAD).handle()) } +} + +#[allow(dead_code)] +pub(crate) fn with_current_driver(f: impl FnOnce(&Driver) -> T) -> T { + f(¤t_thread().driver) +} + +/// Queues a macrotask on the current runtime thread. +/// +/// The task runs after all currently-queued macrotasks, and after all microtasks. +/// +/// # Panics +/// +/// Panics if the runtime cannot initialize its state for the current thread. +pub fn queue_task(task: F) +where + F: FnOnce() + 'static, +{ + #[cfg(debug_assertions)] + tracing::trace!( + target: trace_targets::SCHEDULER, + event = "queue_task", + queue = "local_macro", + "queueing local macrotask" + ); + push_local_macrotask(Box::new(task)); +} + +/// Queues a microtask on the current runtime thread. +/// +/// Microtasks run before the next macrotask turn, mirroring JavaScript-style event loop +/// semantics. +/// +/// # Panics +/// +/// Panics if the runtime cannot initialize its state for the current thread. +pub fn queue_microtask(task: F) +where + F: FnOnce() + 'static, +{ + #[cfg(debug_assertions)] + tracing::trace!( + target: trace_targets::SCHEDULER, + event = "queue_microtask", + queue = "local_micro", + "queueing local microtask" + ); + current_thread() + .local_microtasks + .borrow_mut() + .push_back(Box::new(task)); +} + +/// Schedules a one-shot timer on the current runtime thread. +/// +/// # Panics +/// +/// Panics if the runtime cannot initialize its state for the current thread. +pub fn set_timeout(delay: Duration, callback: F) -> TimeoutHandle +where + F: FnOnce() + 'static, +{ + let owner = current_thread_ptr(); + let id = allocate_timer_id(); + let deadline = deadline_from_now(delay); + #[cfg(debug_assertions)] + tracing::trace!( + target: trace_targets::TIMER, + event = "set_timeout", + timer_id = id, + delay_ns = delay.as_nanos() as u64, + deadline_ns = deadline.as_nanos() as u64, + "scheduling timeout" + ); + let timer = TimerNode::timeout(id, deadline, Box::new(callback)); + + current_thread().timers.borrow_mut().insert(timer); + rearm_thread_timer(); + + TimeoutHandle { + id, + owner, + _local: Rc::new(()), + } +} + +/// Cancels a timeout previously created by [`set_timeout`]. +/// +/// # Panics +/// +/// Panics if called from a different runtime thread than the one that created `handle`. +pub fn clear_timeout(handle: &TimeoutHandle) { + #[cfg(debug_assertions)] + tracing::trace!( + target: trace_targets::TIMER, + event = "clear_timeout", + timer_id = handle.id, + "clearing timeout" + ); + clear_timer(handle.owner, handle.id); +} + +/// Schedules a repeating timer on the current runtime thread. +/// +/// The callback is invoked once per interval until the handle is cleared. +/// +/// # Panics +/// +/// Panics if the runtime cannot initialize its state for the current thread. +pub fn set_interval(delay: Duration, callback: F) -> IntervalHandle +where + F: FnMut() + 'static, +{ + let owner = current_thread_ptr(); + let id = allocate_timer_id(); + + #[cfg(debug_assertions)] + tracing::trace!( + target: trace_targets::TIMER, + event = "set_interval", + timer_id = id, + delay_ns = delay.as_nanos() as u64, + "scheduling interval" + ); + + if delay.is_zero() { + // Zero-delay intervals never touch the timer heap or arm an io_uring + // timer (a past-deadline kernel timer would fire on every event loop + // iteration, spinning the runtime at 100 % CPU). Instead the callback + // is stored in `immediate_intervals` and self-schedules as a macrotask + // each turn, mirroring JS `setInterval(f, 0)` semantics. + let callback = Rc::new(RefCell::new(Box::new(callback) as Box)); + current_thread() + .immediate_intervals + .borrow_mut() + .insert(id, Rc::clone(&callback)); + schedule_immediate_interval(id); + } else { + let deadline = deadline_from_now(delay); + #[cfg(debug_assertions)] + tracing::trace!( + target: trace_targets::TIMER, + event = "set_interval_deadline", + timer_id = id, + deadline_ns = deadline.as_nanos() as u64, + "interval deadline computed" + ); + let timer = TimerNode::interval( + id, + deadline, + delay, + Rc::new(RefCell::new(Box::new(callback))), + ); + current_thread().timers.borrow_mut().insert(timer); + rearm_thread_timer(); + } + + IntervalHandle { + id, + owner, + _local: Rc::new(()), + } +} + +/// Cancels an interval previously created by [`set_interval`]. +/// +/// # Panics +/// +/// Panics if called from a different runtime thread than the one that created `handle`. +pub fn clear_interval(handle: &IntervalHandle) { + #[cfg(debug_assertions)] + tracing::trace!( + target: trace_targets::TIMER, + event = "clear_interval", + timer_id = handle.id, + "clearing interval" + ); + clear_timer(handle.owner, handle.id); +} + +/// Queues a future on the current runtime thread. +/// +/// The future is scheduled immediately and can be awaited through the returned [`JoinHandle`]. +/// +/// The future will be driven to completion regardless or whether the join handle is polled or dropped, so this function +/// can be used as a convenient way to spawn detached async tasks on the current thread. +/// +/// # Examples +/// +/// ``` +/// # let _ = || { +/// let handle = ruin_runtime::queue_future(async { 42usize }); +/// # }; +/// ``` +/// +/// # Panics +/// +/// Panics if the runtime cannot initialize its state for the current thread. +pub fn queue_future(future: F) -> JoinHandle +where + F: Future + 'static, + F::Output: 'static, +{ + #[cfg(debug_assertions)] + tracing::trace!( + target: trace_targets::ASYNC, + event = "queue_future", + "queueing local future" + ); + let state = Rc::new(JoinState::new()); + let completion = Rc::clone(&state); + let task = Rc::new(FutureTask { + future: RefCell::new(Some(Box::pin(async move { + let output = future.await; + completion.complete(output); + }))), + queued: Cell::new(false), + }); + + task.schedule(); + + JoinHandle { state } +} + +/// Spawns a worker runtime thread. +/// +/// `initial_task` is queued onto the worker as its first macrotask. `on_exit` runs on the parent +/// runtime thread after the worker shuts down. +/// +/// # Panics +/// +/// Panics if the worker thread or its driver cannot be created. +pub fn spawn_worker(initial_task: Init, on_exit: Exit) -> WorkerHandle +where + Init: FnOnce() + Send + 'static, + Exit: FnOnce() + 'static, +{ + tracing::debug!( + target: trace_targets::RUNTIME, + event = "spawn_worker", + "spawning runtime worker thread" + ); + let parent = current_thread(); + let (driver, notifier) = create_driver().expect("worker driver should initialize"); + let shared = Arc::new(ThreadShared::new(notifier)); + let handle = ThreadHandle { + shared: Arc::clone(&shared), + }; + let completion = Arc::new(WorkerCompletion { + finished: AtomicBool::new(false), + parent_event: parent.handle(), + }); + + parent.children.borrow_mut().push(ChildWorker { + completion: Arc::clone(&completion), + on_exit: Some(Box::new(on_exit)), + }); + + let worker_completion = Arc::clone(&completion); + std::thread::Builder::new() + .name("ruin-runtime-worker".into()) + .spawn(move || { + install_thread(shared, driver, Some(worker_completion)); + queue_task(initial_task); + run(); + }) + .expect("worker thread should spawn"); + + WorkerHandle { + thread: handle, + completion, + } +} + +/// Runs the current runtime thread until no work, timers, child workers, or async operations +/// remain. +/// +/// This is the main event loop entry point used by the proc-macro entry attributes. +/// +/// # Panics +/// +/// Panics if runtime initialization fails or if the underlying driver returns an unexpected error. +pub fn run() { + let _span = tracing::debug_span!( + target: trace_targets::RUNTIME, + "runtime.run" + ) + .entered(); + tracing::debug!( + target: trace_targets::RUNTIME, + event = "run_enter", + "entering runtime event loop" + ); + let _ = current_thread(); + + loop { + drain_all(); + + let mut microtasks_run: u64 = 0; + while let Some(task) = pop_microtask() { + task(); + microtasks_run += 1; + drain_all(); + } + if microtasks_run >= MICROTASK_STARVATION_THRESHOLD { + tracing::warn!( + target: trace_targets::SCHEDULER, + event = "microtask_starvation", + count = microtasks_run, + "microtask queue ran {microtasks_run} tasks in a single turn; macrotask handlers may be starved", + ); + } + + if let Some(task) = pop_macrotask() { + task(); + continue; + } + + drain_all(); + + if has_ready_work() { + continue; + } + + let state = current_thread(); + if !state.try_begin_shutdown() { + continue; + } + + drain_all(); + + if has_ready_work() { + state.shared.closing.store(false, Ordering::Release); + continue; + } + + if has_pending_timers() || state.has_live_children() || state.has_live_async_operations() { + state.shared.closing.store(false, Ordering::Release); + #[cfg(debug_assertions)] + tracing::trace!( + target: trace_targets::RUNTIME, + event = "run_wait", + pending_timers = has_pending_timers(), + live_children = state.has_live_children(), + live_async = state.has_live_async_operations(), + "runtime waiting for more work" + ); + state.driver.wait().expect("driver wait should succeed"); + continue; + } + + // Atomically commit to exit: set `closed` while holding the remote + // queue lock. `enqueue_macro` also checks `closed` under this same + // lock, so there is no window in which a task can be accepted after + // we decide to exit. If a task snuck in between the `has_ready_work` + // check above and acquiring the lock, we abort and process it first. + let committed = { + let remote = lock_queue(&state.shared.remote_macrotasks); + if remote.is_empty() { + state.shared.closed.store(true, Ordering::Release); + true + } else { + false + } + }; + + if !committed { + state.shared.closing.store(false, Ordering::Release); + continue; + } + + if let Some(completion) = &state.worker_completion { + completion.finished.store(true, Ordering::Release); + completion.parent_event.shared.notify(); + } + + state.shared.notify(); + tracing::debug!( + target: trace_targets::RUNTIME, + event = "run_exit", + "runtime event loop exiting" + ); + teardown_thread(); + return; + } +} + +fn drain_all() { + drain_driver_events(); + drain_remote_tasks(); + drain_completed_workers(); +} + +/// Returns a future that yields back to the runtime scheduler once. +pub fn yield_now() -> YieldNow { + YieldNow { yielded: false } +} + +impl ThreadHandle { + /// Queues a macrotask onto this runtime thread. + /// + /// Returns `false` if the target thread is already closed. + pub fn queue_task(&self, task: F) -> bool + where + F: FnOnce() + Send + 'static, + { + let queued = self.shared.enqueue_macro(Box::new(task)); + #[cfg(debug_assertions)] + tracing::trace!( + target: trace_targets::SCHEDULER, + event = "queue_remote_task", + queue = "remote_macro", + queued, + "queueing remote macrotask" + ); + queued + } + + /// Returns `true` if the target runtime thread has shut down. + pub fn is_closed(&self) -> bool { + self.shared.closed.load(Ordering::Acquire) + } + + #[allow(dead_code)] + pub(crate) fn begin_async_operation(&self) { + self.shared.pending_ops.fetch_add(1, Ordering::AcqRel); + } + + #[allow(dead_code)] + pub(crate) fn finish_async_operation(&self) { + let previous = self.shared.pending_ops.fetch_sub(1, Ordering::AcqRel); + debug_assert!(previous > 0, "async operation count underflow"); + self.shared.notify(); + } +} + +impl WorkerHandle { + /// Queues a macrotask onto the worker thread. + /// + /// Returns `false` if the worker has already shut down. + pub fn queue_task(&self, task: F) -> bool + where + F: FnOnce() + Send + 'static, + { + self.thread.queue_task(task) + } + + /// Returns `true` once the worker thread has fully exited. + pub fn is_finished(&self) -> bool { + self.completion.finished.load(Ordering::Acquire) + } + + /// Returns a generic [`ThreadHandle`] for the worker thread. + pub fn thread(&self) -> ThreadHandle { + self.thread.clone() + } +} + +impl Future for JoinHandle { + type Output = T; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.state.poll(cx) + } +} + +impl Future for YieldNow { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if self.yielded { + Poll::Ready(()) + } else { + self.yielded = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + } +} + +struct ThreadState { + driver: Driver, + shared: Arc, + worker_completion: Option>, + local_microtasks: RefCell>, + local_macrotasks: RefCell>, + timers: RefCell, + /// Zero-delay intervals bypasses the timer heap entirely. Each entry + /// re-enqueues itself as a macrotask on every turn. + immediate_intervals: RefCell>, + next_timer_id: Cell, + children: RefCell>, +} + +impl ThreadState { + fn new( + shared: Arc, + driver: Driver, + worker_completion: Option>, + ) -> Self { + Self { + driver, + shared, + worker_completion, + local_microtasks: RefCell::new(VecDeque::new()), + local_macrotasks: RefCell::new(VecDeque::new()), + timers: RefCell::new(TimerHeap::new()), + immediate_intervals: RefCell::new(HashMap::new()), + next_timer_id: Cell::new(1), + children: RefCell::new(Vec::new()), + } + } + + fn handle(&self) -> ThreadHandle { + ThreadHandle { + shared: Arc::clone(&self.shared), + } + } + + fn has_live_children(&self) -> bool { + !self.children.borrow().is_empty() + } + + fn has_live_async_operations(&self) -> bool { + self.shared.pending_ops.load(Ordering::Acquire) != 0 + } + + fn try_begin_shutdown(&self) -> bool { + self.shared + .closing + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } +} + +struct ThreadShared { + notifier: ThreadNotifier, + // The microtask queue is strictly thread-local; only macrotasks may be + // enqueued from remote threads, keeping the microtask queue free from + // cross-thread interference. + remote_macrotasks: Mutex>, + pending_ops: AtomicUsize, + closing: AtomicBool, + closed: AtomicBool, +} + +impl ThreadShared { + fn new(notifier: ThreadNotifier) -> Self { + Self { + notifier, + remote_macrotasks: Mutex::new(VecDeque::new()), + pending_ops: AtomicUsize::new(0), + closing: AtomicBool::new(false), + closed: AtomicBool::new(false), + } + } + + fn enqueue_macro(&self, task: SendTask) -> bool { + // Check `closed` under the queue lock so that the exit path can + // atomically set `closed` while holding the same lock, eliminating + // the window where a task is accepted but then stranded at shutdown. + let mut queue = lock_queue(&self.remote_macrotasks); + if self.closed.load(Ordering::Acquire) { + return false; + } + queue.push_back(task); + drop(queue); + // Notify after releasing the lock. By this point `closed` is still + // false (we verified under the lock) and teardown_thread has not run, + // so the ring is guaranteed to be alive. + self.notify(); + true + } + + fn notify(&self) { + if let Err(error) = self.notifier.notify() { + // BrokenPipe is expected during shutdown (ring already closed). + // Any other error is unexpected; log it rather than panicking so + // the runtime continues to make progress. + if error.kind() != io::ErrorKind::BrokenPipe { + tracing::error!( + target: trace_targets::DRIVER, + event = "notify_error", + ?error, + "unexpected error sending thread notification" + ); + } + } + } +} + +struct ChildWorker { + completion: Arc, + on_exit: Option, +} + +struct WorkerCompletion { + finished: AtomicBool, + parent_event: ThreadHandle, +} + +struct TimerNode { + id: usize, + deadline: Duration, + kind: TimerKind, +} + +enum TimerKind { + Timeout(LocalTask), + Interval { + interval: Duration, + callback: Rc>>, + }, +} + +impl TimerNode { + fn timeout(id: usize, deadline: Duration, callback: LocalTask) -> Self { + Self { + id, + deadline, + kind: TimerKind::Timeout(callback), + } + } + + fn interval( + id: usize, + deadline: Duration, + interval: Duration, + callback: Rc>>, + ) -> Self { + Self { + id, + deadline, + kind: TimerKind::Interval { interval, callback }, + } + } +} + +struct TimerHeap { + nodes: Vec, + positions: HashMap, +} + +impl TimerHeap { + fn new() -> Self { + Self { + nodes: Vec::new(), + positions: HashMap::new(), + } + } + + fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + fn peek_deadline(&self) -> Option { + self.nodes.first().map(|node| node.deadline) + } + + fn insert(&mut self, node: TimerNode) { + let index = self.nodes.len(); + self.positions.insert(node.id, index); + self.nodes.push(node); + self.sift_up(index); + } + + fn remove(&mut self, id: usize) -> Option { + let index = *self.positions.get(&id)?; + self.positions.remove(&id); + + let last = self.nodes.pop().expect("heap index should be valid"); + if index == self.nodes.len() { + return Some(last); + } + + let removed = std::mem::replace(&mut self.nodes[index], last); + self.positions.insert(self.nodes[index].id, index); + self.fix(index); + Some(removed) + } + + fn pop_min(&mut self) -> Option { + let id = self.nodes.first()?.id; + self.remove(id) + } + + fn pop_due(&mut self, now: Duration) -> Vec { + let mut due = Vec::new(); + while self.peek_deadline().is_some_and(|deadline| deadline <= now) { + due.push(self.pop_min().expect("timer heap should contain a minimum")); + } + due + } + + fn fix(&mut self, index: usize) { + if index > 0 && self.less(index, parent(index)) { + self.sift_up(index); + } else { + self.sift_down(index); + } + } + + fn sift_up(&mut self, mut index: usize) { + while index > 0 { + let parent = parent(index); + if !self.less(index, parent) { + break; + } + self.swap(index, parent); + index = parent; + } + } + + fn sift_down(&mut self, mut index: usize) { + loop { + let left = index * 2 + 1; + let right = left + 1; + let mut smallest = index; + + if left < self.nodes.len() && self.less(left, smallest) { + smallest = left; + } + if right < self.nodes.len() && self.less(right, smallest) { + smallest = right; + } + if smallest == index { + break; + } + + self.swap(index, smallest); + index = smallest; + } + } + + fn less(&self, lhs: usize, rhs: usize) -> bool { + let left = &self.nodes[lhs]; + let right = &self.nodes[rhs]; + (left.deadline, left.id) < (right.deadline, right.id) + } + + fn swap(&mut self, lhs: usize, rhs: usize) { + self.nodes.swap(lhs, rhs); + self.positions.insert(self.nodes[lhs].id, lhs); + self.positions.insert(self.nodes[rhs].id, rhs); + } +} + +struct FutureTask { + future: RefCell>, + queued: Cell, +} + +impl FutureTask { + fn schedule(self: &Rc) { + if self.queued.replace(true) { + return; + } + + let task = Rc::clone(self); + queue_microtask(move || task.poll()); + } + + fn poll(self: Rc) { + self.queued.set(false); + + let Some(mut future) = self.future.borrow_mut().take() else { + return; + }; + + let waker = self.waker(); + let mut context = Context::from_waker(&waker); + match future.as_mut().poll(&mut context) { + Poll::Ready(()) => {} + Poll::Pending => { + *self.future.borrow_mut() = Some(future); + } + } + } + + fn waker(self: &Rc) -> Waker { + unsafe { + Waker::from_raw(RawWaker::new( + Rc::into_raw(Rc::clone(self)).cast::<()>(), + &FUTURE_TASK_WAKER_VTABLE, + )) + } + } +} + +static FUTURE_TASK_WAKER_VTABLE: RawWakerVTable = RawWakerVTable::new( + future_task_clone, + future_task_wake, + future_task_wake_by_ref, + future_task_drop, +); + +struct JoinState { + result: RefCell>, + waker: RefCell>, + ready: Cell, +} + +impl JoinState { + fn new() -> Self { + Self { + result: RefCell::new(None), + waker: RefCell::new(None), + ready: Cell::new(false), + } + } + + fn complete(&self, value: T) { + *self.result.borrow_mut() = Some(value); + self.ready.set(true); + + if let Some(waker) = self.waker.borrow_mut().take() { + waker.wake(); + } + } + + fn poll(&self, cx: &mut Context<'_>) -> Poll { + if self.ready.get() { + return Poll::Ready( + self.result + .borrow_mut() + .take() + .expect("join handle polled after completion"), + ); + } + + *self.waker.borrow_mut() = Some(cx.waker().clone()); + Poll::Pending + } +} + +unsafe fn future_task_clone(data: *const ()) -> RawWaker { + let task = unsafe { Rc::::from_raw(data.cast::()) }; + let clone = Rc::clone(&task); + let _ = Rc::into_raw(task); + RawWaker::new(Rc::into_raw(clone).cast::<()>(), &FUTURE_TASK_WAKER_VTABLE) +} + +unsafe fn future_task_wake(data: *const ()) { + let task = unsafe { Rc::::from_raw(data.cast::()) }; + task.schedule(); +} + +unsafe fn future_task_wake_by_ref(data: *const ()) { + let task = unsafe { Rc::::from_raw(data.cast::()) }; + task.schedule(); + let _ = Rc::into_raw(task); +} + +unsafe fn future_task_drop(data: *const ()) { + drop(unsafe { Rc::::from_raw(data.cast::()) }); +} + +fn current_thread() -> &'static ThreadState { + unsafe { + if CURRENT_THREAD.is_null() { + let (driver, notifier) = create_driver().expect("runtime driver should initialize"); + let shared = Arc::new(ThreadShared::new(notifier)); + let state = Box::new(ThreadState::new(shared, driver, None)); + let state = Box::into_raw(state); + (*state).driver.bind_current_thread(); + CURRENT_THREAD = state; + } + + &*CURRENT_THREAD + } +} + +fn current_thread_ptr() -> *const ThreadState { + current_thread() as *const ThreadState +} + +fn install_thread( + shared: Arc, + driver: Driver, + worker_completion: Option>, +) { + unsafe { + debug_assert!(CURRENT_THREAD.is_null(), "thread runtime already installed"); + let state = Box::new(ThreadState::new(shared, driver, worker_completion)); + let state = Box::into_raw(state); + (*state).driver.bind_current_thread(); + CURRENT_THREAD = state; + } +} + +fn teardown_thread() { + unsafe { + let state = CURRENT_THREAD; + CURRENT_THREAD = ptr::null_mut(); + + if !state.is_null() { + (*state).driver.unbind_current_thread(); + drop(Box::from_raw(state)); + } + } +} + +fn drain_driver_events() { + loop { + let ready = current_thread() + .driver + .poll() + .expect("driver poll should succeed"); + + let Some(ready) = ready else { + break; + }; + + let state = current_thread(); + if ready.wake { + #[cfg(debug_assertions)] + tracing::trace!( + target: trace_targets::DRIVER, + event = "drain_wake", + "draining driver wake notifications" + ); + let _ = state + .driver + .drain_wake() + .expect("wake drain should succeed"); + } + if ready.timer { + #[cfg(debug_assertions)] + tracing::trace!( + target: trace_targets::TIMER, + event = "drain_timer", + "draining expired runtime timers" + ); + let _ = state + .driver + .drain_timer() + .expect("timer drain should succeed"); + dispatch_expired_timers(); + } + } +} + +fn drain_remote_tasks() { + // Swap the entire remote queue under the lock and release immediately, + // minimizing the time the lock is held and avoiding per-item allocation. + let drained = { + let mut remote = lock_queue(¤t_thread().shared.remote_macrotasks); + std::mem::take(&mut *remote) + }; + + if !drained.is_empty() { + let mut local = current_thread().local_macrotasks.borrow_mut(); + for task in drained { + // SendTask (Box) coerces to LocalTask + // (Box) by dropping the Send bound. + let task: LocalTask = task; + local.push_back(make_macro_task(task)); + } + } +} + +fn drain_completed_workers() { + let state = current_thread(); + let mut exited = Vec::new(); + + { + let mut children = state.children.borrow_mut(); + let mut index = 0; + while index < children.len() { + if children[index].completion.finished.load(Ordering::Acquire) { + let child = children.swap_remove(index); + exited.push(child); + } else { + index += 1; + } + } + } + + if exited.is_empty() { + return; + } + + let mut local = state.local_macrotasks.borrow_mut(); + for mut child in exited { + if let Some(task) = child.on_exit.take() { + local.push_back(make_macro_task(task)); + } + } +} + +fn pop_microtask() -> Option { + current_thread().local_microtasks.borrow_mut().pop_front() +} + +fn pop_macrotask() -> Option { + current_thread() + .local_macrotasks + .borrow_mut() + .pop_front() + .map(|entry| { + #[cfg(debug_assertions)] + { + let now = deadline_from_now(Duration::ZERO); + let wait = now.saturating_sub(entry.queued_at); + tracing::trace!( + target: trace_targets::SCHEDULER, + event = "macrotask_dequeued", + wait_ns = wait.as_nanos() as u64, + "macrotask dequeued after waiting in queue" + ); + } + entry.task + }) +} + +fn push_local_macrotask(task: LocalTask) { + current_thread() + .local_macrotasks + .borrow_mut() + .push_back(make_macro_task(task)); +} + +fn make_macro_task(task: LocalTask) -> MacroTask { + MacroTask { + task, + #[cfg(debug_assertions)] + queued_at: deadline_from_now(Duration::ZERO), + } +} + +fn has_ready_work() -> bool { + let state = current_thread(); + if !state.local_microtasks.borrow().is_empty() || !state.local_macrotasks.borrow().is_empty() { + return true; + } + + if !lock_queue(&state.shared.remote_macrotasks).is_empty() { + return true; + } + + false +} + +fn has_pending_timers() -> bool { + let state = current_thread(); + !state.timers.borrow().is_empty() || !state.immediate_intervals.borrow().is_empty() +} + +fn allocate_timer_id() -> usize { + let state = current_thread(); + let id = state.next_timer_id.get(); + let next = id.checked_add(1).expect("timer ID space exhausted"); + state.next_timer_id.set(next); + id +} + +fn clear_timer(owner: *const ThreadState, id: usize) { + assert!( + ptr::eq(current_thread_ptr(), owner), + "timer handles must be cleared on their originating thread" + ); + + let state = current_thread(); + if state.timers.borrow_mut().remove(id).is_some() { + rearm_thread_timer(); + } else { + // May be a zero-delay immediate interval; removal is a no-op if absent. + state.immediate_intervals.borrow_mut().remove(&id); + } +} + +/// Enqueues one macrotask turn for a zero-delay interval. +/// +/// The macrotask checks whether the interval is still live (not cleared), fires +/// the callback, and re-enqueues itself for the next turn. +fn schedule_immediate_interval(id: usize) { + push_local_macrotask(Box::new(move || { + let callback = current_thread() + .immediate_intervals + .borrow() + .get(&id) + .map(Rc::clone); + let Some(callback) = callback else { + return; // interval was cleared before this turn ran + }; + (callback.borrow_mut())(); + // Re-enqueue for the next turn if still live. + if current_thread() + .immediate_intervals + .borrow() + .contains_key(&id) + { + schedule_immediate_interval(id); + } + })); +} + +fn dispatch_expired_timers() { + let now = deadline_from_now(Duration::ZERO); + let due = current_thread().timers.borrow_mut().pop_due(now); + + if due.is_empty() { + rearm_thread_timer(); + return; + } + + for timer in due { + match timer.kind { + TimerKind::Timeout(callback) => push_local_macrotask(callback), + TimerKind::Interval { interval, callback } => { + // Mirror JavaScript's setInterval semantics: + // 1. Schedule the next tick BEFORE queuing the callback, + // so a slow callback does not shift the schedule. + // 2. Fire at most once per dispatch regardless of how many + // ticks were missed; the interval simply drifts forward + // rather than back-filling a burst of callbacks. + let next_deadline = timer + .deadline + .checked_add(interval) + .unwrap_or(Duration::MAX); + let next_timer = + TimerNode::interval(timer.id, next_deadline, interval, Rc::clone(&callback)); + current_thread().timers.borrow_mut().insert(next_timer); + + push_local_macrotask(Box::new(move || { + (callback.borrow_mut())(); + })); + } + } + } + + rearm_thread_timer(); +} + +fn rearm_thread_timer() { + let deadline = current_thread().timers.borrow().peek_deadline(); + current_thread() + .driver + .rearm_timer(deadline) + .expect("driver timer rearm should succeed"); +} + +fn deadline_from_now(delay: Duration) -> Duration { + monotonic_now() + .expect("monotonic clock should be available") + .checked_add(delay) + .unwrap_or(Duration::MAX) +} + +const fn parent(index: usize) -> usize { + (index - 1) / 2 +} + +fn lock_queue(queue: &Mutex>) -> MutexGuard<'_, VecDeque> { + queue.lock().expect("runtime queue poisoned") +} + +#[cfg(test)] +mod tests { + use crate::op::completion::completion_for_current_thread; + + use super::{ + clear_interval, current_thread_handle, queue_future, queue_microtask, queue_task, run, + set_interval, set_timeout, spawn_worker, yield_now, + }; + use std::cell::{Cell, RefCell}; + use std::rc::Rc; + use std::sync::{Arc, Mutex}; + use std::thread; + use std::time::Duration; + + #[test] + fn runtime_executes_local_and_remote_work() { + let log = Arc::new(Mutex::new(Vec::::new())); + let main_handle = current_thread_handle(); + + { + let log = Arc::clone(&log); + queue_task(move || log.lock().unwrap().push("main task".into())); + } + { + let log = Arc::clone(&log); + queue_microtask(move || log.lock().unwrap().push("main microtask".into())); + } + { + let log = Arc::clone(&log); + queue_future(async move { + log.lock().unwrap().push("main future start".into()); + yield_now().await; + log.lock().unwrap().push("main future end".into()); + }); + } + { + let log = Arc::clone(&log); + set_timeout(Duration::from_millis(5), move || { + log.lock().unwrap().push("main timeout".into()); + }); + } + { + let log = Arc::clone(&log); + let handle_slot = Rc::new(RefCell::new(None)); + let handle_slot_clone = Rc::clone(&handle_slot); + let tick_count = Rc::new(Cell::new(0usize)); + let tick_count_clone = Rc::clone(&tick_count); + let interval = set_interval(Duration::from_millis(3), move || { + let next = tick_count_clone.get() + 1; + tick_count_clone.set(next); + log.lock().unwrap().push(format!("main interval {next}")); + if next == 2 { + let handle = handle_slot_clone.borrow_mut().take().unwrap(); + clear_interval(&handle); + } + }); + *handle_slot.borrow_mut() = Some(interval); + } + + { + let worker_log = Arc::clone(&log); + let exit_log = Arc::clone(&log); + let main_handle_for_worker = main_handle.clone(); + spawn_worker( + move || { + let log = Arc::clone(&worker_log); + queue_task({ + let log = Arc::clone(&log); + move || log.lock().unwrap().push("worker task".into()) + }); + queue_microtask({ + let log = Arc::clone(&log); + move || log.lock().unwrap().push("worker microtask".into()) + }); + queue_future({ + let log = Arc::clone(&log); + async move { + log.lock().unwrap().push("worker future start".into()); + yield_now().await; + log.lock().unwrap().push("worker future end".into()); + } + }); + set_timeout(Duration::from_millis(7), move || { + main_handle_for_worker.queue_task({ + let log = Arc::clone(&log); + move || log.lock().unwrap().push("worker timeout to main".into()) + }); + }); + }, + { + let log = Arc::clone(&exit_log); + move || log.lock().unwrap().push("worker exit".into()) + }, + ); + } + + run(); + + let log = log.lock().unwrap(); + assert!(log.iter().any(|entry| entry == "main task")); + assert!(log.iter().any(|entry| entry == "main microtask")); + assert!(log.iter().any(|entry| entry == "main future start")); + assert!(log.iter().any(|entry| entry == "main future end")); + assert!(log.iter().any(|entry| entry == "main timeout")); + assert!(log.iter().any(|entry| entry == "main interval 1")); + assert!(log.iter().any(|entry| entry == "main interval 2")); + assert!(log.iter().any(|entry| entry == "worker task")); + assert!(log.iter().any(|entry| entry == "worker microtask")); + assert!(log.iter().any(|entry| entry == "worker future start")); + assert!(log.iter().any(|entry| entry == "worker future end")); + assert!(log.iter().any(|entry| entry == "worker timeout to main")); + assert!(log.iter().any(|entry| entry == "worker exit")); + } + + #[test] + fn runtime_waits_for_cross_thread_operation_completion() { + let observed = Arc::new(Mutex::new(None::)); + + { + let observed = Arc::clone(&observed); + queue_task(move || { + let (completion, source) = completion_for_current_thread::(); + + thread::spawn(move || { + source.complete(7); + }); + + queue_future(async move { + let value = completion.await; + *observed.lock().unwrap() = Some(value); + }); + }); + } + + run(); + + assert_eq!(*observed.lock().unwrap(), Some(7)); + } + + #[test] + fn zero_interval_fires_once_per_turn_without_spinning() { + // set_interval(Duration::ZERO, ..) must not busy-spin the event loop. + // Each tick is one macrotask turn; we verify the count advances across + // turns by awaiting a sleep between checks. + let count = Rc::new(Cell::new(0usize)); + let count_clone = Rc::clone(&count); + let handle_slot: Rc>> = Rc::new(RefCell::new(None)); + let handle_slot_clone = Rc::clone(&handle_slot); + + let handle = set_interval(Duration::ZERO, move || { + let next = count_clone.get() + 1; + count_clone.set(next); + if next == 5 { + let handle = handle_slot_clone.borrow_mut().take().unwrap(); + clear_interval(&handle); + } + }); + *handle_slot.borrow_mut() = Some(handle); + + run(); + + assert_eq!(count.get(), 5); + } +} diff --git a/lib/runtime/src/platform/mod.rs b/lib/runtime/src/platform/mod.rs index 2897c2b..2148e0d 100644 --- a/lib/runtime/src/platform/mod.rs +++ b/lib/runtime/src/platform/mod.rs @@ -1,2 +1,9 @@ #[cfg(all(target_os = "linux", target_arch = "x86_64"))] pub mod linux_x86_64; +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +pub mod macos_aarch64; + +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +pub use linux_x86_64 as current; +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +pub use macos_aarch64 as current; diff --git a/lib/runtime/src/sys/linux/channel.rs b/lib/runtime/src/sys/linux/channel.rs index dd9ce81..15d4e75 100644 --- a/lib/runtime/src/sys/linux/channel.rs +++ b/lib/runtime/src/sys/linux/channel.rs @@ -1,7 +1,7 @@ //! Linux channel wake helpers. use crate::op::completion::{CompletionFuture, CompletionHandle, completion}; -use crate::platform::linux_x86_64::runtime::try_current_thread_handle; +use crate::platform::current::runtime::try_current_thread_handle; pub(crate) fn runtime_waiter() -> (CompletionFuture, CompletionHandle) { let owner = try_current_thread_handle() diff --git a/lib/runtime/src/sys/linux/fd.rs b/lib/runtime/src/sys/linux/fd.rs new file mode 100644 index 0000000..dc67722 --- /dev/null +++ b/lib/runtime/src/sys/linux/fd.rs @@ -0,0 +1,44 @@ +//! Linux fd readiness backend. + +use std::io; +use std::os::fd::RawFd; + +use crate::op::completion::completion_for_current_thread; +use crate::platform::current::runtime::with_current_driver; +use crate::platform::linux_x86_64::uring::{IORING_OP_POLL_ADD, IoUringCqe}; + +/// Waits until `fd` becomes readable or reports an error/hangup condition. +pub async fn wait_readable(fd: RawFd) -> io::Result<()> { + submit_poll(fd, libc::POLLIN | libc::POLLERR | libc::POLLHUP).await +} + +async fn submit_poll(fd: RawFd, mask: i16) -> io::Result<()> { + let (future, handle) = completion_for_current_thread::>(); + let callback_handle = handle.clone(); + let token = with_current_driver(|driver| { + driver.submit_operation( + move |sqe| { + sqe.opcode = IORING_OP_POLL_ADD; + sqe.fd = fd; + sqe.len = 0; + sqe.op_flags = mask as u32; + }, + move |cqe| { + callback_handle.complete(cqe_to_result(cqe)); + }, + ) + })?; + + handle.set_cancel(move || { + let _ = with_current_driver(|driver| driver.cancel_operation(token)); + }); + + future.await +} + +fn cqe_to_result(cqe: IoUringCqe) -> io::Result<()> { + if cqe.res < 0 { + return Err(io::Error::from_raw_os_error(-cqe.res)); + } + Ok(()) +} diff --git a/lib/runtime/src/sys/linux/mod.rs b/lib/runtime/src/sys/linux/mod.rs index 8672d2a..b6a833d 100644 --- a/lib/runtime/src/sys/linux/mod.rs +++ b/lib/runtime/src/sys/linux/mod.rs @@ -1,5 +1,6 @@ //! Linux backend modules. pub mod channel; +pub mod fd; pub mod fs; pub mod net; diff --git a/lib/runtime/src/sys/linux/net.rs b/lib/runtime/src/sys/linux/net.rs index 930dca8..3b02372 100644 --- a/lib/runtime/src/sys/linux/net.rs +++ b/lib/runtime/src/sys/linux/net.rs @@ -803,13 +803,9 @@ where let (future, handle) = completion_for_current_thread::>(); let callback_handle = handle.clone(); let token = with_current_driver(|driver| { - driver.submit_operation_with_linked_timeout( - fill, - timeout, - move |cqe| { - callback_handle.complete(map(cqe)); - }, - ) + driver.submit_operation_with_linked_timeout(fill, timeout, move |cqe| { + callback_handle.complete(map(cqe)); + }) })?; handle.set_cancel(move || { @@ -1079,7 +1075,6 @@ fn send_sync(fd: RawFd, data: Vec, flags: i32) -> io::Result { cvt_long(written).map(|written| written as usize) } - fn recv_sync(fd: RawFd, len: usize, flags: i32) -> io::Result> { let mut buffer = vec![0; len]; let read = unsafe { @@ -1127,7 +1122,6 @@ fn close_sync(fd: RawFd) -> io::Result<()> { cvt(unsafe { libc::close(fd) }).map(|_| ()) } - /// Wrapper making `Box` sendable across the async CQE boundary. /// /// Safety: `iov_base` points into a `Vec` that is owned by the same diff --git a/lib/runtime/src/sys/macos/channel.rs b/lib/runtime/src/sys/macos/channel.rs new file mode 100644 index 0000000..79b52f6 --- /dev/null +++ b/lib/runtime/src/sys/macos/channel.rs @@ -0,0 +1,10 @@ +//! macOS channel wake helpers. + +use crate::op::completion::{CompletionFuture, CompletionHandle, completion}; +use crate::platform::current::runtime::try_current_thread_handle; + +pub(crate) fn runtime_waiter() -> (CompletionFuture, CompletionHandle) { + let owner = try_current_thread_handle() + .expect("async channel operations must be polled on a runtime thread"); + completion(owner) +} diff --git a/lib/runtime/src/sys/macos/fd.rs b/lib/runtime/src/sys/macos/fd.rs new file mode 100644 index 0000000..27107c9 --- /dev/null +++ b/lib/runtime/src/sys/macos/fd.rs @@ -0,0 +1,43 @@ +//! macOS fd readiness backend. + +use std::io; +use std::os::fd::RawFd; +use std::time::Duration; + +use crate::op::completion::completion_for_current_thread; + +/// Waits until `fd` becomes readable or reports an error/hangup condition. +pub async fn wait_readable(fd: RawFd) -> io::Result<()> { + let (future, handle) = completion_for_current_thread::>(); + std::thread::spawn(move || { + let mut pollfd = libc::pollfd { + fd, + events: libc::POLLIN | libc::POLLERR | libc::POLLHUP, + revents: 0, + }; + loop { + let result = unsafe { libc::poll(&mut pollfd, 1, -1) }; + if result > 0 { + handle.complete(Ok(())); + return; + } + if result == 0 { + continue; + } + + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + handle.complete(Err(error)); + return; + } + }); + + future.await +} + +#[allow(dead_code)] +fn _duration_to_poll_timeout(duration: Duration) -> i32 { + duration.as_millis().min(i32::MAX as u128) as i32 +} diff --git a/lib/runtime/src/sys/macos/fs.rs b/lib/runtime/src/sys/macos/fs.rs new file mode 100644 index 0000000..2393e28 --- /dev/null +++ b/lib/runtime/src/sys/macos/fs.rs @@ -0,0 +1,427 @@ +//! macOS filesystem backend. + +use std::collections::VecDeque; +use std::ffi::CString; +use std::future::poll_fn; +use std::io; +use std::os::fd::{FromRawFd, OwnedFd}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Waker}; +use std::thread; + +use crate::op::completion::completion_for_current_thread; +use crate::op::fs::{FileType, FsOp, MetadataTarget, RawDirEntry, RawMetadata}; +use crate::platform::current::runtime::{ThreadHandle, current_thread_handle}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExecutionPath { + Offload, +} + +pub fn execution_path(_op: &FsOp) -> ExecutionPath { + ExecutionPath::Offload +} + +pub async fn open(op: FsOp) -> io::Result { + let FsOp::Open { path, options } = op else { + unreachable!("open backend called with non-open op"); + }; + + offload(move || { + let mut open = std::fs::OpenOptions::new(); + open.read(options.read) + .write(options.write) + .append(options.append) + .truncate(options.truncate) + .create(options.create) + .create_new(options.create_new) + .mode(0o666); + let file = open.open(path)?; + Ok(unsafe { OwnedFd::from_raw_fd(std::os::fd::IntoRawFd::into_raw_fd(file)) }) + }) + .await +} + +pub async fn read(op: FsOp) -> io::Result> { + let FsOp::Read { fd, offset, len } = op else { + unreachable!("read backend called with non-read op"); + }; + + offload(move || { + let mut buffer = vec![0; len]; + let read = match offset { + Some(offset) => unsafe { + libc::pread( + fd, + buffer.as_mut_ptr().cast::(), + len, + offset as libc::off_t, + ) + }, + None => unsafe { libc::read(fd, buffer.as_mut_ptr().cast::(), len) }, + }; + if read < 0 { + return Err(io::Error::last_os_error()); + } + buffer.truncate(read as usize); + Ok(buffer) + }) + .await +} + +pub async fn write(op: FsOp) -> io::Result { + let FsOp::Write { fd, offset, data } = op else { + unreachable!("write backend called with non-write op"); + }; + + offload(move || { + let written = match offset { + Some(offset) => unsafe { + libc::pwrite( + fd, + data.as_ptr().cast::(), + data.len(), + offset as libc::off_t, + ) + }, + None => unsafe { libc::write(fd, data.as_ptr().cast::(), data.len()) }, + }; + if written < 0 { + return Err(io::Error::last_os_error()); + } + Ok(written as usize) + }) + .await +} + +pub async fn metadata(op: FsOp) -> io::Result { + let FsOp::Metadata { + target, + follow_symlinks, + } = op + else { + unreachable!("metadata backend called with non-metadata op"); + }; + + offload(move || { + let metadata = match target { + MetadataTarget::Path(path) => { + if follow_symlinks { + std::fs::metadata(path) + } else { + std::fs::symlink_metadata(path) + } + } + MetadataTarget::File(fd) => { + let mut stat = unsafe { std::mem::zeroed::() }; + let result = unsafe { libc::fstat(fd, &mut stat) }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + return Ok(raw_metadata_from_stat(&stat)); + } + }?; + Ok(raw_metadata_from_std(&metadata)) + }) + .await +} + +pub async fn sync_all(op: FsOp) -> io::Result<()> { + let FsOp::SyncAll { fd } = op else { + unreachable!("sync_all backend called with non-sync_all op"); + }; + + offload(move || cvt(unsafe { libc::fsync(fd) }).map(|_| ())).await +} + +pub async fn sync_data(op: FsOp) -> io::Result<()> { + let FsOp::SyncData { fd } = op else { + unreachable!("sync_data backend called with non-sync_data op"); + }; + + offload(move || cvt(unsafe { libc::fcntl(fd, libc::F_FULLFSYNC) }).map(|_| ())).await +} + +pub async fn set_len(op: FsOp) -> io::Result<()> { + let FsOp::SetLen { fd, len } = op else { + unreachable!("set_len backend called with non-set_len op"); + }; + + offload(move || cvt(unsafe { libc::ftruncate(fd, len as libc::off_t) }).map(|_| ())).await +} + +pub async fn try_clone(op: FsOp) -> io::Result { + let FsOp::Duplicate { fd } = op else { + unreachable!("try_clone backend called with non-duplicate op"); + }; + + offload(move || { + let duplicated = cvt(unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) })?; + Ok(unsafe { OwnedFd::from_raw_fd(duplicated) }) + }) + .await +} + +pub async fn create_dir(op: FsOp) -> io::Result<()> { + let FsOp::CreateDir { path, mode } = op else { + unreachable!("create_dir backend called with non-create_dir op"); + }; + + offload(move || { + let c_path = path_to_c_string(path)?; + cvt(unsafe { libc::mkdir(c_path.as_ptr(), mode as libc::mode_t) }).map(|_| ()) + }) + .await +} + +pub async fn remove_file(op: FsOp) -> io::Result<()> { + let FsOp::RemoveFile { path } = op else { + unreachable!("remove_file backend called with non-remove_file op"); + }; + + offload(move || std::fs::remove_file(path)).await +} + +pub async fn remove_dir(op: FsOp) -> io::Result<()> { + let FsOp::RemoveDir { path } = op else { + unreachable!("remove_dir backend called with non-remove_dir op"); + }; + + offload(move || std::fs::remove_dir(path)).await +} + +pub async fn rename(op: FsOp) -> io::Result<()> { + let FsOp::Rename { from, to } = op else { + unreachable!("rename backend called with non-rename op"); + }; + + offload(move || std::fs::rename(from, to)).await +} + +pub async fn close(op: FsOp) -> io::Result<()> { + let FsOp::Close { fd } = op else { + unreachable!("close backend called with non-close op"); + }; + + offload(move || cvt(unsafe { libc::close(fd) }).map(|_| ())).await +} + +pub fn read_dir(op: FsOp) -> io::Result { + let FsOp::ReadDir { path } = op else { + unreachable!("read_dir backend called with non-read_dir op"); + }; + + ReadDirStream::new(path) +} + +pub struct ReadDirStream { + state: Arc, +} + +impl ReadDirStream { + fn new(path: PathBuf) -> io::Result { + let state = Arc::new(ReadDirState::new(current_thread_handle())); + let producer = Arc::clone(&state); + + if let Err(error) = thread::Builder::new() + .name("ruin-runtime-read-dir".into()) + .spawn(move || produce_dir_entries(path, producer)) + { + state.release_pending(); + return Err(io::Error::other(error)); + } + + Ok(Self { state }) + } + + pub async fn next_entry(&mut self) -> io::Result> { + poll_fn(|cx| self.state.poll_next(cx)).await + } +} + +struct ReadDirState { + owner: ThreadHandle, + queue: Mutex>>, + done: AtomicBool, + pending: AtomicBool, + wake_queued: AtomicBool, + waker: Mutex>, +} + +impl ReadDirState { + fn new(owner: ThreadHandle) -> Self { + owner.begin_async_operation(); + Self { + owner, + queue: Mutex::new(VecDeque::new()), + done: AtomicBool::new(false), + pending: AtomicBool::new(true), + wake_queued: AtomicBool::new(false), + waker: Mutex::new(None), + } + } + + fn push(self: &Arc, entry: io::Result) { + self.queue.lock().unwrap().push_back(entry); + self.notify(); + } + + fn finish(self: &Arc) { + self.done.store(true, Ordering::Release); + self.release_pending(); + self.notify(); + } + + fn release_pending(&self) { + if self.pending.swap(false, Ordering::AcqRel) { + self.owner.finish_async_operation(); + } + } + + fn notify(self: &Arc) { + if self.wake_queued.swap(true, Ordering::AcqRel) { + return; + } + + let state = Arc::clone(self); + if !self.owner.queue_task(move || { + state.wake_queued.store(false, Ordering::Release); + if let Some(waker) = state.waker.lock().unwrap().take() { + waker.wake(); + } + }) { + self.wake_queued.store(false, Ordering::Release); + } + } + + fn poll_next(&self, cx: &mut Context<'_>) -> Poll>> { + if let Some(entry) = self.queue.lock().unwrap().pop_front() { + return Poll::Ready(entry.map(Some)); + } + + if self.done.load(Ordering::Acquire) { + return Poll::Ready(Ok(None)); + } + + *self.waker.lock().unwrap() = Some(cx.waker().clone()); + + if let Some(entry) = self.queue.lock().unwrap().pop_front() { + let _ = self.waker.lock().unwrap().take(); + return Poll::Ready(entry.map(Some)); + } + + if self.done.load(Ordering::Acquire) { + let _ = self.waker.lock().unwrap().take(); + return Poll::Ready(Ok(None)); + } + + Poll::Pending + } +} + +impl Drop for ReadDirStream { + fn drop(&mut self) { + self.state.release_pending(); + } +} + +fn produce_dir_entries(path: PathBuf, state: Arc) { + match std::fs::read_dir(path) { + Ok(entries) => { + for entry in entries { + match entry { + Ok(entry) => { + let file_name = entry.file_name(); + state.push(Ok(RawDirEntry { + path: entry.path(), + file_name, + })); + } + Err(error) => state.push(Err(error)), + } + } + state.finish(); + } + Err(error) => { + state.push(Err(error)); + state.finish(); + } + } +} + +async fn offload( + work: impl FnOnce() -> io::Result + Send + 'static, +) -> io::Result { + let (future, handle) = completion_for_current_thread::>(); + thread::spawn(move || { + handle.complete(work()); + }); + future.await +} + +fn path_to_c_string(path: PathBuf) -> io::Result { + CString::new(path.as_os_str().as_bytes()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "path contains interior NUL bytes", + ) + }) +} + +fn raw_metadata_from_std(metadata: &std::fs::Metadata) -> RawMetadata { + let file_type = metadata.file_type(); + let kind = if file_type.is_file() { + FileType::File + } else if file_type.is_dir() { + FileType::Directory + } else if file_type.is_symlink() { + FileType::Symlink + } else if file_type.is_block_device() { + FileType::BlockDevice + } else if file_type.is_char_device() { + FileType::CharacterDevice + } else if file_type.is_fifo() { + FileType::Fifo + } else if file_type.is_socket() { + FileType::Socket + } else { + FileType::Unknown + }; + + RawMetadata { + file_type: kind, + mode: (metadata.mode() & 0o7777) as u16, + len: metadata.len(), + } +} + +fn raw_metadata_from_stat(stat: &libc::stat) -> RawMetadata { + let kind = match stat.st_mode & libc::S_IFMT { + libc::S_IFREG => FileType::File, + libc::S_IFDIR => FileType::Directory, + libc::S_IFLNK => FileType::Symlink, + libc::S_IFBLK => FileType::BlockDevice, + libc::S_IFCHR => FileType::CharacterDevice, + libc::S_IFIFO => FileType::Fifo, + libc::S_IFSOCK => FileType::Socket, + _ => FileType::Unknown, + }; + + RawMetadata { + file_type: kind, + mode: stat.st_mode & 0o7777, + len: stat.st_size as u64, + } +} + +fn cvt(value: libc::c_int) -> io::Result { + if value < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(value) + } +} diff --git a/lib/runtime/src/sys/macos/mod.rs b/lib/runtime/src/sys/macos/mod.rs new file mode 100644 index 0000000..b528ee6 --- /dev/null +++ b/lib/runtime/src/sys/macos/mod.rs @@ -0,0 +1,6 @@ +//! macOS backend modules. + +pub mod channel; +pub mod fd; +pub mod fs; +pub mod net; diff --git a/lib/runtime/src/sys/macos/net.rs b/lib/runtime/src/sys/macos/net.rs new file mode 100644 index 0000000..b76ba81 --- /dev/null +++ b/lib/runtime/src/sys/macos/net.rs @@ -0,0 +1,779 @@ +//! macOS networking backend. + +use std::ffi::c_void; +use std::future::Future; +use std::io; +use std::mem::MaybeUninit; +use std::net::{ + Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr, SocketAddrV4, SocketAddrV6, ToSocketAddrs, +}; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::pin::Pin; +use std::thread; +use std::time::Duration; + +use crate::op::completion::completion_for_current_thread; +use crate::op::net::{AcceptedSocket, NetOp, ReceivedDatagram}; + +const DEFAULT_LISTENER_BACKLOG: i32 = 1024; + +type RecvFuture = Pin>> + 'static>>; +type SendFuture = Pin> + 'static>>; +type ShutdownFuture = Pin> + 'static>>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExecutionPath { + Offload, +} + +pub fn execution_path(_op: &NetOp) -> ExecutionPath { + ExecutionPath::Offload +} + +pub async fn resolve_addrs(addr: A) -> io::Result> +where + A: ToSocketAddrs + Send + 'static, +{ + offload(move || { + let addrs = addr.to_socket_addrs()?.collect::>(); + if addrs.is_empty() { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "address resolved to no socket addresses", + )) + } else { + Ok(addrs) + } + }) + .await +} + +pub async fn socket(op: NetOp) -> io::Result { + let NetOp::Socket { + domain, + socket_type, + protocol, + flags, + } = op + else { + unreachable!("socket backend called with non-socket op"); + }; + + offload(move || socket_sync(domain, socket_type, protocol, flags)).await +} + +pub async fn connect(op: NetOp) -> io::Result<()> { + let NetOp::Connect { fd, addr } = op else { + unreachable!("connect backend called with non-connect op"); + }; + + offload(move || connect_sync(fd, RawSocketAddr::from_socket_addr(addr))).await +} + +pub async fn bind(op: NetOp) -> io::Result<()> { + let NetOp::Bind { fd, addr } = op else { + unreachable!("bind backend called with non-bind op"); + }; + + offload(move || bind_sync(fd, RawSocketAddr::from_socket_addr(addr))).await +} + +pub async fn listen(op: NetOp) -> io::Result<()> { + let NetOp::Listen { fd, backlog } = op else { + unreachable!("listen backend called with non-listen op"); + }; + + offload(move || listen_sync(fd, backlog)).await +} + +pub async fn accept(op: NetOp) -> io::Result { + let NetOp::Accept { fd } = op else { + unreachable!("accept backend called with non-accept op"); + }; + + offload(move || accept_sync(fd)).await +} + +pub async fn send(op: NetOp) -> io::Result { + let NetOp::Send { fd, data, flags } = op else { + unreachable!("send backend called with non-send op"); + }; + + offload(move || send_sync(fd, data, flags)).await +} + +pub async fn send_to(op: NetOp) -> io::Result { + let NetOp::SendTo { + fd, + target, + data, + flags, + } = op + else { + unreachable!("send_to backend called with non-send_to op"); + }; + + offload(move || send_to_sync(fd, target, data, flags)).await +} + +pub async fn recv(op: NetOp) -> io::Result> { + let NetOp::Recv { fd, len, flags } = op else { + unreachable!("recv backend called with non-recv op"); + }; + + offload(move || recv_sync(fd, len, flags)).await +} + +pub async fn recv_from(op: NetOp) -> io::Result { + let NetOp::RecvFrom { fd, len, flags } = op else { + unreachable!("recv_from backend called with non-recv_from op"); + }; + + offload(move || recv_from_sync(fd, len, flags)).await +} + +pub async fn shutdown(op: NetOp) -> io::Result<()> { + let NetOp::Shutdown { fd, how } = op else { + unreachable!("shutdown backend called with non-shutdown op"); + }; + + offload(move || shutdown_sync(fd, how)).await +} + +pub async fn close(op: NetOp) -> io::Result<()> { + let NetOp::Close { fd } = op else { + unreachable!("close backend called with non-close op"); + }; + + offload(move || close_sync(fd)).await +} + +pub async fn connect_stream(addr: SocketAddr) -> io::Result { + match connect_stream_inner(addr).await { + Err(error) if should_try_ipv4_loopback(addr, &error) => { + connect_stream_inner(localhost_v4(addr)).await + } + result => result, + } +} + +pub async fn bind_listener(addr: SocketAddr, backlog: Option) -> io::Result { + match bind_listener_inner(addr, backlog).await { + Err(error) if should_try_ipv4_loopback(addr, &error) => { + bind_listener_inner(localhost_v4(addr), backlog).await + } + result => result, + } +} + +pub async fn bind_datagram(addr: SocketAddr) -> io::Result { + match bind_datagram_inner(addr).await { + Err(error) if should_try_ipv4_loopback(addr, &error) => { + bind_datagram_inner(localhost_v4(addr)).await + } + result => result, + } +} + +async fn connect_stream_inner(addr: SocketAddr) -> io::Result { + let stream = socket(NetOp::Socket { + domain: socket_domain(addr), + socket_type: libc::SOCK_STREAM, + protocol: 0, + flags: 0, + }) + .await?; + + connect(NetOp::Connect { + fd: stream.as_raw_fd(), + addr, + }) + .await?; + Ok(stream) +} + +async fn bind_listener_inner(addr: SocketAddr, backlog: Option) -> io::Result { + let listener = socket(NetOp::Socket { + domain: socket_domain(addr), + socket_type: libc::SOCK_STREAM, + protocol: 0, + flags: 0, + }) + .await?; + + set_reuse_addr(listener.as_raw_fd(), true)?; + + bind(NetOp::Bind { + fd: listener.as_raw_fd(), + addr, + }) + .await?; + listen(NetOp::Listen { + fd: listener.as_raw_fd(), + backlog: backlog.unwrap_or(DEFAULT_LISTENER_BACKLOG), + }) + .await?; + Ok(listener) +} + +async fn bind_datagram_inner(addr: SocketAddr) -> io::Result { + let socket = socket(NetOp::Socket { + domain: socket_domain(addr), + socket_type: libc::SOCK_DGRAM, + protocol: 0, + flags: 0, + }) + .await?; + + bind(NetOp::Bind { + fd: socket.as_raw_fd(), + addr, + }) + .await?; + Ok(socket) +} + +pub async fn duplicate(fd: RawFd) -> io::Result { + offload(move || { + let duplicated = cvt(unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) })?; + Ok(unsafe { OwnedFd::from_raw_fd(duplicated) }) + }) + .await +} + +pub async fn recv_timeout( + fd: RawFd, + len: usize, + flags: i32, + timeout: Duration, +) -> io::Result> { + offload(move || { + wait_for_fd(fd, libc::POLLIN, timeout)?; + recv_sync(fd, len, flags) + }) + .await +} + +pub async fn send_timeout( + fd: RawFd, + data: Vec, + flags: i32, + timeout: Duration, +) -> io::Result { + offload(move || { + wait_for_fd(fd, libc::POLLOUT, timeout)?; + send_sync(fd, data, flags) + }) + .await +} + +pub async fn recv_from_timeout( + fd: RawFd, + len: usize, + flags: i32, + timeout: Duration, +) -> io::Result { + offload(move || { + wait_for_fd(fd, libc::POLLIN, timeout)?; + recv_from_sync(fd, len, flags) + }) + .await +} + +pub async fn send_to_timeout( + fd: RawFd, + data: Vec, + target: SocketAddr, + flags: i32, + timeout: Duration, +) -> io::Result { + offload(move || { + wait_for_fd(fd, libc::POLLOUT, timeout)?; + send_to_sync(fd, target, data, flags) + }) + .await +} + +pub async fn connect_stream_timeout(addr: SocketAddr, timeout: Duration) -> io::Result { + offload(move || connect_stream_timeout_sync(addr, timeout)).await +} + +pub fn local_addr(fd: RawFd) -> io::Result { + socket_addr_with(libc::getsockname, fd) +} + +pub fn peer_addr(fd: RawFd) -> io::Result { + socket_addr_with(libc::getpeername, fd) +} + +pub fn nodelay(fd: RawFd) -> io::Result { + let mut value = 0; + let mut len = std::mem::size_of::() as libc::socklen_t; + cvt(unsafe { + libc::getsockopt( + fd, + libc::IPPROTO_TCP, + libc::TCP_NODELAY, + &mut value as *mut libc::c_int as *mut c_void, + &mut len, + ) + })?; + Ok(value != 0) +} + +pub fn broadcast(fd: RawFd) -> io::Result { + getsockopt_int(fd, libc::SOL_SOCKET, libc::SO_BROADCAST).map(|value| value != 0) +} + +pub fn set_broadcast(fd: RawFd, enabled: bool) -> io::Result<()> { + setsockopt_int(fd, libc::SOL_SOCKET, libc::SO_BROADCAST, enabled.into()) +} + +pub fn ttl(fd: RawFd) -> io::Result { + match socket_family(fd)? { + libc::AF_INET => { + getsockopt_int(fd, libc::IPPROTO_IP, libc::IP_TTL).map(|value| value as u32) + } + libc::AF_INET6 => getsockopt_int(fd, libc::IPPROTO_IPV6, libc::IPV6_UNICAST_HOPS) + .map(|value| value as u32), + family => Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unsupported socket family {family} for TTL"), + )), + } +} + +pub fn set_ttl(fd: RawFd, ttl: u32) -> io::Result<()> { + let ttl = i32::try_from(ttl) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "TTL exceeds i32 range"))?; + match socket_family(fd)? { + libc::AF_INET => setsockopt_int(fd, libc::IPPROTO_IP, libc::IP_TTL, ttl), + libc::AF_INET6 => setsockopt_int(fd, libc::IPPROTO_IPV6, libc::IPV6_UNICAST_HOPS, ttl), + family => Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unsupported socket family {family} for TTL"), + )), + } +} + +pub fn set_nodelay(fd: RawFd, enabled: bool) -> io::Result<()> { + let value: libc::c_int = enabled.into(); + cvt(unsafe { + libc::setsockopt( + fd, + libc::IPPROTO_TCP, + libc::TCP_NODELAY, + &value as *const libc::c_int as *const c_void, + std::mem::size_of_val(&value) as libc::socklen_t, + ) + }) + .map(|_| ()) +} + +pub fn recv_future(fd: RawFd, len: usize) -> RecvFuture { + Box::pin(recv(NetOp::Recv { fd, len, flags: 0 })) +} + +pub fn send_future(fd: RawFd, data: Vec) -> SendFuture { + Box::pin(send(NetOp::Send { fd, data, flags: 0 })) +} + +pub fn shutdown_future(fd: RawFd, how: Shutdown) -> ShutdownFuture { + Box::pin(shutdown(NetOp::Shutdown { fd, how })) +} + +async fn offload( + work: impl FnOnce() -> io::Result + Send + 'static, +) -> io::Result { + let (future, handle) = completion_for_current_thread::>(); + let handle_for_thread = handle.clone(); + if let Err(error) = thread::Builder::new() + .name("ruin-runtime-net-offload".into()) + .spawn(move || handle_for_thread.complete(work())) + { + handle.complete(Err(io::Error::other(error))); + } + future.await +} + +fn socket_domain(addr: SocketAddr) -> i32 { + match addr { + SocketAddr::V4(_) => libc::AF_INET, + SocketAddr::V6(_) => libc::AF_INET6, + } +} + +fn shutdown_how(how: Shutdown) -> i32 { + match how { + Shutdown::Read => libc::SHUT_RD, + Shutdown::Write => libc::SHUT_WR, + Shutdown::Both => libc::SHUT_RDWR, + } +} + +fn socket_addr_with( + op: unsafe extern "C" fn(RawFd, *mut libc::sockaddr, *mut libc::socklen_t) -> libc::c_int, + fd: RawFd, +) -> io::Result { + let mut storage = MaybeUninit::::zeroed(); + let mut len = std::mem::size_of::() as libc::socklen_t; + cvt(unsafe { op(fd, storage.as_mut_ptr().cast::(), &mut len) })?; + let storage = unsafe { storage.assume_init() }; + socket_addr_from_storage(&storage, len) +} + +fn set_reuse_addr(fd: RawFd, enabled: bool) -> io::Result<()> { + setsockopt_int(fd, libc::SOL_SOCKET, libc::SO_REUSEADDR, enabled.into()) +} + +fn socket_family(fd: RawFd) -> io::Result { + let mut storage = MaybeUninit::::zeroed(); + let mut len = std::mem::size_of::() as libc::socklen_t; + cvt(unsafe { libc::getsockname(fd, storage.as_mut_ptr().cast::(), &mut len) })?; + let storage = unsafe { storage.assume_init() }; + Ok(storage.ss_family as i32) +} + +fn getsockopt_int(fd: RawFd, level: i32, name: i32) -> io::Result { + let mut value = 0; + let mut len = std::mem::size_of::() as libc::socklen_t; + cvt(unsafe { + libc::getsockopt( + fd, + level, + name, + &mut value as *mut libc::c_int as *mut c_void, + &mut len, + ) + })?; + Ok(value) +} + +fn setsockopt_int(fd: RawFd, level: i32, name: i32, value: i32) -> io::Result<()> { + cvt(unsafe { + libc::setsockopt( + fd, + level, + name, + &value as *const libc::c_int as *const c_void, + std::mem::size_of_val(&value) as libc::socklen_t, + ) + }) + .map(|_| ()) +} + +fn socket_addr_from_storage( + storage: &libc::sockaddr_storage, + len: libc::socklen_t, +) -> io::Result { + match storage.ss_family as i32 { + libc::AF_INET => { + if len < std::mem::size_of::() as libc::socklen_t { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sockaddr_in length is truncated", + )); + } + let addr = unsafe { *(storage as *const _ as *const libc::sockaddr_in) }; + Ok(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::from(u32::from_be(addr.sin_addr.s_addr)), + u16::from_be(addr.sin_port), + ))) + } + libc::AF_INET6 => { + if len < std::mem::size_of::() as libc::socklen_t { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sockaddr_in6 length is truncated", + )); + } + let addr = unsafe { *(storage as *const _ as *const libc::sockaddr_in6) }; + Ok(SocketAddr::V6(SocketAddrV6::new( + Ipv6Addr::from(addr.sin6_addr.s6_addr), + u16::from_be(addr.sin6_port), + addr.sin6_flowinfo, + addr.sin6_scope_id, + ))) + } + family => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unsupported socket family {family}"), + )), + } +} + +#[derive(Clone, Copy)] +struct RawSocketAddr { + storage: libc::sockaddr_storage, + len: libc::socklen_t, +} + +impl RawSocketAddr { + fn from_socket_addr(addr: SocketAddr) -> Self { + match addr { + SocketAddr::V4(addr) => { + let sockaddr = libc::sockaddr_in { + sin_len: std::mem::size_of::() as u8, + sin_family: libc::AF_INET as libc::sa_family_t, + sin_port: addr.port().to_be(), + sin_addr: libc::in_addr { + s_addr: u32::from_ne_bytes(addr.ip().octets()), + }, + sin_zero: [0; 8], + }; + let mut storage = + unsafe { MaybeUninit::::zeroed().assume_init() }; + unsafe { + std::ptr::write( + &mut storage as *mut libc::sockaddr_storage as *mut libc::sockaddr_in, + sockaddr, + ); + } + Self { + storage, + len: std::mem::size_of::() as libc::socklen_t, + } + } + SocketAddr::V6(addr) => { + let sockaddr = libc::sockaddr_in6 { + sin6_len: std::mem::size_of::() as u8, + sin6_family: libc::AF_INET6 as libc::sa_family_t, + sin6_port: addr.port().to_be(), + sin6_flowinfo: addr.flowinfo(), + sin6_addr: libc::in6_addr { + s6_addr: addr.ip().octets(), + }, + sin6_scope_id: addr.scope_id(), + }; + let mut storage = + unsafe { MaybeUninit::::zeroed().assume_init() }; + unsafe { + std::ptr::write( + &mut storage as *mut libc::sockaddr_storage as *mut libc::sockaddr_in6, + sockaddr, + ); + } + Self { + storage, + len: std::mem::size_of::() as libc::socklen_t, + } + } + } + } + + fn as_ptr(&self) -> *const libc::sockaddr { + &self.storage as *const libc::sockaddr_storage as *const libc::sockaddr + } + + fn len(&self) -> libc::socklen_t { + self.len + } +} + +fn socket_sync(domain: i32, socket_type: i32, protocol: i32, _flags: u32) -> io::Result { + let fd = cvt(unsafe { libc::socket(domain, socket_type, protocol) })?; + set_cloexec(fd)?; + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} + +fn connect_sync(fd: RawFd, addr: RawSocketAddr) -> io::Result<()> { + cvt(unsafe { libc::connect(fd, addr.as_ptr(), addr.len()) }).map(|_| ()) +} + +fn bind_sync(fd: RawFd, addr: RawSocketAddr) -> io::Result<()> { + cvt(unsafe { libc::bind(fd, addr.as_ptr(), addr.len()) }).map(|_| ()) +} + +fn listen_sync(fd: RawFd, backlog: i32) -> io::Result<()> { + cvt(unsafe { libc::listen(fd, backlog) }).map(|_| ()) +} + +fn accept_sync(fd: RawFd) -> io::Result { + let mut storage = MaybeUninit::::zeroed(); + let mut len = std::mem::size_of::() as libc::socklen_t; + let accepted_fd = + cvt(unsafe { libc::accept(fd, storage.as_mut_ptr().cast::(), &mut len) })?; + set_cloexec(accepted_fd)?; + let storage = unsafe { storage.assume_init() }; + let peer_addr = socket_addr_from_storage(&storage, len)?; + Ok(AcceptedSocket { + fd: accepted_fd, + peer_addr, + }) +} + +fn send_sync(fd: RawFd, data: Vec, flags: i32) -> io::Result { + let written = unsafe { libc::send(fd, data.as_ptr().cast::(), data.len(), flags) }; + cvt_long(written).map(|written| written as usize) +} + +fn send_to_sync(fd: RawFd, target: SocketAddr, data: Vec, flags: i32) -> io::Result { + let addr = RawSocketAddr::from_socket_addr(target); + let written = unsafe { + libc::sendto( + fd, + data.as_ptr().cast::(), + data.len(), + flags, + addr.as_ptr(), + addr.len(), + ) + }; + cvt_long(written).map(|written| written as usize) +} + +fn recv_sync(fd: RawFd, len: usize, flags: i32) -> io::Result> { + let mut data = vec![0u8; len]; + let read = unsafe { libc::recv(fd, data.as_mut_ptr().cast::(), len, flags) }; + let read = cvt_long(read)? as usize; + data.truncate(read); + Ok(data) +} + +fn recv_from_sync(fd: RawFd, len: usize, flags: i32) -> io::Result { + let mut data = vec![0u8; len]; + let mut storage = MaybeUninit::::zeroed(); + let mut addr_len = std::mem::size_of::() as libc::socklen_t; + let read = unsafe { + libc::recvfrom( + fd, + data.as_mut_ptr().cast::(), + len, + flags, + storage.as_mut_ptr().cast::(), + &mut addr_len, + ) + }; + let read = cvt_long(read)? as usize; + data.truncate(read); + let storage = unsafe { storage.assume_init() }; + let peer_addr = socket_addr_from_storage(&storage, addr_len)?; + Ok(ReceivedDatagram { data, peer_addr }) +} + +fn shutdown_sync(fd: RawFd, how: Shutdown) -> io::Result<()> { + cvt(unsafe { libc::shutdown(fd, shutdown_how(how)) }).map(|_| ()) +} + +fn close_sync(fd: RawFd) -> io::Result<()> { + cvt(unsafe { libc::close(fd) }).map(|_| ()) +} + +fn connect_stream_timeout_sync(addr: SocketAddr, timeout: Duration) -> io::Result { + let fd = cvt(unsafe { libc::socket(socket_domain(addr), libc::SOCK_STREAM, 0) })?; + set_cloexec(fd)?; + set_nonblocking(fd)?; + + let raw = RawSocketAddr::from_socket_addr(addr); + let connect_result = unsafe { libc::connect(fd, raw.as_ptr(), raw.len()) }; + if connect_result < 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::EINPROGRESS) { + let _ = unsafe { libc::close(fd) }; + return Err(error); + } + } + + let wait = wait_for_fd(fd, libc::POLLOUT, timeout); + if let Err(error) = wait { + let _ = unsafe { libc::close(fd) }; + return Err(error); + } + + let mut so_error: libc::c_int = 0; + let mut len = std::mem::size_of::() as libc::socklen_t; + if unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_ERROR, + &mut so_error as *mut libc::c_int as *mut c_void, + &mut len, + ) + } < 0 + { + let error = io::Error::last_os_error(); + let _ = unsafe { libc::close(fd) }; + return Err(error); + } + + if so_error != 0 { + let _ = unsafe { libc::close(fd) }; + return Err(io::Error::from_raw_os_error(so_error)); + } + + clear_nonblocking(fd)?; + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} + +fn wait_for_fd(fd: RawFd, events: i16, timeout: Duration) -> io::Result<()> { + let timeout_ms = timeout.as_millis().min(i32::MAX as u128) as i32; + let mut pfd = libc::pollfd { + fd, + events, + revents: 0, + }; + loop { + let result = unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; + if result > 0 { + return Ok(()); + } + if result == 0 { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "operation timed out", + )); + } + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(error); + } +} + +fn set_cloexec(fd: RawFd) -> io::Result<()> { + let flags = cvt(unsafe { libc::fcntl(fd, libc::F_GETFD) })?; + cvt(unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) })?; + Ok(()) +} + +fn set_nonblocking(fd: RawFd) -> io::Result<()> { + let flags = cvt(unsafe { libc::fcntl(fd, libc::F_GETFL) })?; + cvt(unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) })?; + Ok(()) +} + +fn clear_nonblocking(fd: RawFd) -> io::Result<()> { + let flags = cvt(unsafe { libc::fcntl(fd, libc::F_GETFL) })?; + cvt(unsafe { libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK) })?; + Ok(()) +} + +fn should_try_ipv4_loopback(addr: SocketAddr, error: &io::Error) -> bool { + matches!(addr, SocketAddr::V6(v6) if v6.ip().is_loopback()) + && matches!( + error.raw_os_error(), + Some(libc::EADDRNOTAVAIL | libc::EAFNOSUPPORT | libc::ENETUNREACH) + ) +} + +fn localhost_v4(addr: SocketAddr) -> SocketAddr { + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, addr.port())) +} + +fn cvt(value: libc::c_int) -> io::Result { + if value < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(value) + } +} + +fn cvt_long(value: libc::ssize_t) -> io::Result { + if value < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(value) + } +} diff --git a/lib/runtime/src/sys/mod.rs b/lib/runtime/src/sys/mod.rs index 0260205..2a5e21f 100644 --- a/lib/runtime/src/sys/mod.rs +++ b/lib/runtime/src/sys/mod.rs @@ -2,3 +2,10 @@ #[cfg(all(target_os = "linux", target_arch = "x86_64"))] pub mod linux; +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +pub mod macos; + +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +pub use linux as current; +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +pub use macos as current; diff --git a/lib/ui/benches/layout_bench.rs b/lib/ui/benches/layout_bench.rs index 6f331e6..358e49a 100644 --- a/lib/ui/benches/layout_bench.rs +++ b/lib/ui/benches/layout_bench.rs @@ -67,7 +67,9 @@ fn bench_scroll_list(c: &mut Criterion) { let viewport_height = 640.0; let n = 500; - let mut scroll_box = Element::scroll_box(0.0).width(400.0).height(viewport_height); + let mut scroll_box = Element::scroll_box(0.0) + .width(400.0) + .height(viewport_height); for i in 0..n { scroll_box = scroll_box.child( Element::new() @@ -90,5 +92,10 @@ fn bench_scroll_list(c: &mut Criterion) { }); } -criterion_group!(benches, bench_static_tree, bench_single_change, bench_scroll_list); +criterion_group!( + benches, + bench_static_tree, + bench_single_change, + bench_scroll_list +); criterion_main!(benches); diff --git a/lib/ui/examples/explicit_ui_prototype.rs b/lib/ui/examples/explicit_ui_prototype.rs index d4d974e..9dfd691 100644 --- a/lib/ui/examples/explicit_ui_prototype.rs +++ b/lib/ui/examples/explicit_ui_prototype.rs @@ -107,6 +107,7 @@ fn log_platform_event(event: &PlatformEvent) { "clipboard text received" ); } + #[cfg(target_os = "linux")] PlatformEvent::PrimarySelectionText { window_id, text } => { tracing::debug!( event = "primary_selection_text", diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index 51289ee..1760e7e 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -128,7 +128,12 @@ impl LayoutNode { /// Return a copy of this node (and all descendants) with all positions shifted by `offset`. pub fn translated(self, offset: Point) -> Self { fn translate_rect(r: Rect, o: Point) -> Rect { - Rect::new(r.origin.x + o.x, r.origin.y + o.y, r.size.width, r.size.height) + Rect::new( + r.origin.x + o.x, + r.origin.y + o.y, + r.size.width, + r.size.height, + ) } let rect = translate_rect(self.rect, offset); let clip_rect = self.clip_rect.map(|r| translate_rect(r, offset)); @@ -143,7 +148,11 @@ impl LayoutNode { rect: translate_rect(img.rect, offset), ..img }); - let children = self.children.into_iter().map(|c| c.translated(offset)).collect(); + let children = self + .children + .into_iter() + .map(|c| c.translated(offset)) + .collect(); LayoutNode { rect, clip_rect, @@ -241,6 +250,7 @@ pub fn layout_snapshot_with_cache( } } +#[allow(clippy::too_many_arguments)] fn layout_element( element: &Element, rect: Rect, @@ -256,24 +266,24 @@ fn layout_element( perf_stats.nodes += 1; // Viewport culling: skip fully off-screen elements inside a scroll box. - if let Some(clip) = clip_rect { - if !rects_overlap(rect, clip) { - perf_stats.viewport_culled += 1; - return LayoutNode { - path, - element_id: element.id, - rect, - corner_radius: 0.0, - clip_rect: None, - pointer_events: element.style.pointer_events, - focusable: element.style.focusable, - cursor: element.style.cursor.unwrap_or(CursorIcon::Default), - scroll_metrics: None, - prepared_image: None, - prepared_text: None, - children: Vec::new(), - }; - } + if let Some(clip) = clip_rect + && !rects_overlap(rect, clip) + { + perf_stats.viewport_culled += 1; + return LayoutNode { + path, + element_id: element.id, + rect, + corner_radius: 0.0, + clip_rect: None, + pointer_events: element.style.pointer_events, + focusable: element.style.focusable, + cursor: element.style.cursor.unwrap_or(CursorIcon::Default), + scroll_metrics: None, + prepared_image: None, + prepared_text: None, + children: Vec::new(), + }; } // Incremental layout cache check. @@ -368,7 +378,13 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } - cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); + cache_layout( + layout_cache, + cache_key, + &interaction, + &scene.items[scene_start..], + rect.origin, + ); return interaction; } @@ -382,7 +398,13 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } - cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); + cache_layout( + layout_cache, + cache_key, + &interaction, + &scene.items[scene_start..], + rect.origin, + ); return interaction; } @@ -508,7 +530,13 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } - cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); + cache_layout( + layout_cache, + cache_key, + &interaction, + &scene.items[scene_start..], + rect.origin, + ); return interaction; } @@ -518,7 +546,13 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } - cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); + cache_layout( + layout_cache, + cache_key, + &interaction, + &scene.items[scene_start..], + rect.origin, + ); return interaction; } @@ -527,7 +561,13 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } - cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); + cache_layout( + layout_cache, + cache_key, + &interaction, + &scene.items[scene_start..], + rect.origin, + ); return interaction; } interaction.children = layout_container_children( @@ -545,7 +585,13 @@ fn layout_element( scene.pop_clip(); } - cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); + cache_layout( + layout_cache, + cache_key, + &interaction, + &scene.items[scene_start..], + rect.origin, + ); interaction } @@ -558,10 +604,13 @@ fn cache_layout( origin: Point, ) { let neg = Point::new(-origin.x, -origin.y); - cache.results.insert(key, CachedLayout { - interaction_node: interaction.clone().translated(neg), - scene_items: scene_items.iter().map(|i| i.translated(neg)).collect(), - }); + cache.results.insert( + key, + CachedLayout { + interaction_node: interaction.clone().translated(neg), + scene_items: scene_items.iter().map(|i| i.translated(neg)).collect(), + }, + ); } fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option> { @@ -735,6 +784,7 @@ struct MeasuredChild { is_flex: bool, } +#[allow(clippy::too_many_arguments)] fn layout_container_children( element: &Element, content: Rect, @@ -986,7 +1036,14 @@ fn intrinsic_size( return cached; } let insets = content_insets(&element.style); - let result = intrinsic_size_inner(element, available_size, insets, text_system, perf_stats, layout_cache); + let result = intrinsic_size_inner( + element, + available_size, + insets, + text_system, + perf_stats, + layout_cache, + ); layout_cache.intrinsic_cache.insert(cache_key, result); result } @@ -1047,8 +1104,13 @@ fn intrinsic_size_inner( ); } - let intrinsic_content = - intrinsic_container_content_size(element, content_size, text_system, perf_stats, layout_cache); + let intrinsic_content = intrinsic_container_content_size( + element, + content_size, + text_system, + perf_stats, + layout_cache, + ); UiSize::new( explicit_width @@ -1539,9 +1601,9 @@ fn child_rect( mod tests { use super::{LayoutCache, layout_scene, layout_snapshot, layout_snapshot_with_cache}; use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize}; + use crate::text::TextSystem; use crate::text::{TextStyle, TextWrap}; use crate::tree::{Edges, Element, ElementId, FlexDirection}; - use crate::text::TextSystem; #[test] fn row_layout_apportions_fixed_and_flex_children() { @@ -2156,10 +2218,12 @@ mod tests { let size = UiSize::new(400.0, 600.0); let snap1 = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache); let snap2 = layout_snapshot_with_cache(2, size, &root, &mut text_system, &mut layout_cache); - assert_eq!(snap1.scene.items, snap2.scene.items, "scene items should be identical"); assert_eq!( - snap1.interaction_tree.root, - snap2.interaction_tree.root, + snap1.scene.items, snap2.scene.items, + "scene items should be identical" + ); + assert_eq!( + snap1.interaction_tree.root, snap2.interaction_tree.root, "interaction trees should be identical" ); } @@ -2198,12 +2262,14 @@ mod tests { let item_height = 40.0; let viewport_height = 200.0; let n = 100; - let mut scroll_box = Element::scroll_box(0.0).width(400.0).height(viewport_height); + let mut scroll_box = Element::scroll_box(0.0) + .width(400.0) + .height(viewport_height); for i in 0..n { scroll_box = scroll_box.child( Element::new() .height(item_height) - .child(Element::text(format!("item {i}"), text_style())) + .child(Element::text(format!("item {i}"), text_style())), ); } let root = Element::new().child(scroll_box); @@ -2214,11 +2280,20 @@ mod tests { let snap = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache); // Only text items within the 200px viewport should appear in the scene. - let text_items = snap.scene.items.iter().filter(|item| { - matches!(item, DisplayItem::Text(_)) - }).count(); + let text_items = snap + .scene + .items + .iter() + .filter(|item| matches!(item, DisplayItem::Text(_))) + .count(); // At most 6 text items should be visible (5 fit + 1 partial). - assert!(text_items <= 6, "expected <= 6 text items in scene, got {text_items} (culling not working)"); - assert!(text_items >= 4, "expected >= 4 text items visible, got {text_items}"); + assert!( + text_items <= 6, + "expected <= 6 text items in scene, got {text_items} (culling not working)" + ); + assert!( + text_items >= 4, + "expected >= 4 text items visible, got {text_items}" + ); } } diff --git a/lib/ui/src/lib.rs b/lib/ui/src/lib.rs index dfb0155..6dcb844 100644 --- a/lib/ui/src/lib.rs +++ b/lib/ui/src/lib.rs @@ -8,6 +8,7 @@ pub(crate) mod trace_targets { pub const PLATFORM: &str = "ruin_ui::platform"; pub const SCENE: &str = "ruin_ui::scene"; + #[allow(dead_code)] pub const TEXT_PERF: &str = "ruin_ui::text_perf"; } diff --git a/lib/ui/src/platform.rs b/lib/ui/src/platform.rs index 65d98f5..632dab2 100644 --- a/lib/ui/src/platform.rs +++ b/lib/ui/src/platform.rs @@ -32,7 +32,7 @@ pub struct PlatformProxy { pub struct PlatformRuntime { proxy: PlatformProxy, events: mpsc::Receiver, - _worker: WorkerHandle, + worker: Option, } pub struct PlatformEndpoint { @@ -73,6 +73,7 @@ pub enum PlatformEvent { window_id: WindowId, text: String, }, + #[cfg(target_os = "linux")] PrimarySelectionText { window_id: WindowId, text: String, @@ -118,10 +119,12 @@ pub enum PlatformRequest { RequestClipboardText { window_id: WindowId, }, + #[cfg(target_os = "linux")] SetPrimarySelectionText { window_id: WindowId, text: String, }, + #[cfg(target_os = "linux")] RequestPrimarySelectionText { window_id: WindowId, }, @@ -203,7 +206,30 @@ impl PlatformRuntime { Self { proxy, events: event_rx, - _worker: worker, + worker: Some(worker), + } + } + + /// Creates a platform runtime hosted by the current runtime thread. + /// + /// This is useful for backends that must run on a specific host thread + /// (for example, AppKit on macOS main thread). + pub fn custom_local(start: impl FnOnce(PlatformEndpoint)) -> Self { + let (command_tx, command_rx) = mpsc::unbounded_channel::(); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + let proxy = PlatformProxy { + command_tx, + next_window_id: Arc::new(AtomicU64::new(1)), + }; + start(PlatformEndpoint { + commands: command_rx, + events: event_tx, + }); + + Self { + proxy, + events: event_rx, + worker: None, } } @@ -216,6 +242,7 @@ impl PlatformRuntime { } pub fn take_pending_events(&mut self) -> Vec { + let _ = self.worker.as_ref(); let mut events = Vec::new(); while let Ok(event) = self.events.try_recv() { events.push(event); @@ -247,6 +274,7 @@ impl PlatformProxy { self.send(PlatformRequest::ReplaceScene { window_id, scene }) } + #[cfg(target_os = "linux")] pub fn set_primary_selection_text( &self, window_id: WindowId, @@ -258,6 +286,7 @@ impl PlatformProxy { }) } + #[cfg(target_os = "linux")] pub fn request_primary_selection_text( &self, window_id: WindowId, @@ -356,7 +385,9 @@ pub fn start_headless() -> PlatformRuntime { } PlatformRequest::SetClipboardText { .. } => {} PlatformRequest::RequestClipboardText { .. } => {} + #[cfg(target_os = "linux")] PlatformRequest::SetPrimarySelectionText { .. } => {} + #[cfg(target_os = "linux")] PlatformRequest::RequestPrimarySelectionText { .. } => {} PlatformRequest::SetCursorIcon { .. } => {} PlatformRequest::EmitCloseRequested { window_id } => { diff --git a/lib/ui/src/runtime.rs b/lib/ui/src/runtime.rs index b823eaf..98e31b0 100644 --- a/lib/ui/src/runtime.rs +++ b/lib/ui/src/runtime.rs @@ -110,6 +110,7 @@ impl WindowController { } /// Copies plain text to the platform primary-selection buffer for this window. + #[cfg(target_os = "linux")] pub fn set_primary_selection_text( &self, text: impl Into, @@ -128,6 +129,7 @@ impl WindowController { } /// Requests the current plain-text primary selection contents from the platform. + #[cfg(target_os = "linux")] pub fn request_primary_selection_text(&self) -> Result<(), PlatformClosed> { self.proxy.request_primary_selection_text(self.id) } diff --git a/lib/ui/src/scene.rs b/lib/ui/src/scene.rs index 0594e04..0a0afcb 100644 --- a/lib/ui/src/scene.rs +++ b/lib/ui/src/scene.rs @@ -219,6 +219,7 @@ impl Deref for PreparedText { impl PreparedText { /// Construct a `PreparedText` from shaped data. Called by `TextSystem::prepare_spans`. + #[allow(clippy::too_many_arguments)] pub(crate) fn from_layout( element_id: Option, text: String, @@ -623,10 +624,18 @@ impl DisplayItem { /// Return a copy of this item with all positions shifted by `offset`. pub fn translated(&self, offset: Point) -> Self { fn translate_rect(r: Rect, o: Point) -> Rect { - Rect::new(r.origin.x + o.x, r.origin.y + o.y, r.size.width, r.size.height) + Rect::new( + r.origin.x + o.x, + r.origin.y + o.y, + r.size.width, + r.size.height, + ) } match self { - Self::Quad(q) => Self::Quad(Quad { rect: translate_rect(q.rect, offset), ..*q }), + Self::Quad(q) => Self::Quad(Quad { + rect: translate_rect(q.rect, offset), + ..*q + }), Self::RoundedRect(r) => Self::RoundedRect(RoundedRect { rect: translate_rect(r.rect, offset), ..*r @@ -838,8 +847,8 @@ mod tests { #[test] fn prepared_text_vertical_offset_moves_between_lines() { - use std::sync::Arc; use super::{PreparedTextLine, TextLayoutData}; + use std::sync::Arc; let mut text = PreparedText::monospace( "abcdwxyz", @@ -873,7 +882,11 @@ mod tests { } } let orig_size = text.layout.size; - text.layout = Arc::new(TextLayoutData { lines, glyphs, size: orig_size }); + text.layout = Arc::new(TextLayoutData { + lines, + glyphs, + size: orig_size, + }); assert_eq!(text.vertical_offset(2, 1), Some(6)); assert_eq!(text.vertical_offset(6, -1), Some(2)); @@ -892,8 +905,12 @@ mod tests { let target_line = &text.lines[1]; // Lines store LOCAL coords; add text.origin to get absolute window coords for the query. let y = text.origin.y + target_line.rect.origin.y + target_line.rect.size.height * 0.5; - let start = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x, y)); - let end = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x + 16.0, y)); + let start = + text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x, y)); + let end = text.byte_offset_for_position(Point::new( + text.origin.x + target_line.rect.origin.x + 16.0, + y, + )); let rects = text.selection_rects(start, end); assert_eq!(rects.len(), 1); diff --git a/lib/ui/src/text.rs b/lib/ui/src/text.rs index 580750f..55bd11b 100644 --- a/lib/ui/src/text.rs +++ b/lib/ui/src/text.rs @@ -10,8 +10,9 @@ use cosmic_text::{ }; use fontconfig::Fontconfig; -use crate::{Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, TextLayoutData, - UiSize}; +use crate::{ + Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, TextLayoutData, UiSize, +}; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum TextAlign { @@ -443,7 +444,11 @@ impl TextSystem { // For no-wrap + start-aligned text, width doesn't affect shaping. // Pass None so cosmic-text doesn't truncate and the result is // consistent with the width-independent cache key. - let effective_width = if width_affects_layout(style) { width } else { None }; + let effective_width = if width_affects_layout(style) { + width + } else { + None + }; borrowed.set_size(effective_width, None); let default_attrs = default_attrs_for_style(style, default_family.as_deref()); if uses_plain_text_fast_path { @@ -495,9 +500,7 @@ impl TextSystem { // is color-independent. Per-span colors (from glyph.color_opt) // are stored directly since they are part of the shape key via // the spans hash. - color: glyph - .color_opt - .map_or(Color::SENTINEL, color_from_cosmic), + color: glyph.color_opt.map_or(Color::SENTINEL, color_from_cosmic), cache_key: Some(physical.cache_key), text_start: line_offset + glyph.start, text_end: line_offset + glyph.end, @@ -696,16 +699,16 @@ fn attrs_for_span<'a>( base }; base.weight(match span.weight { - TextSpanWeight::Normal => CosmicWeight::NORMAL, - TextSpanWeight::Medium => CosmicWeight::MEDIUM, - TextSpanWeight::Semibold => CosmicWeight::SEMIBOLD, - TextSpanWeight::Bold => CosmicWeight::BOLD, - }) - .style(match span.slant { - TextSpanSlant::Normal => CosmicStyle::Normal, - TextSpanSlant::Italic => CosmicStyle::Italic, - TextSpanSlant::Oblique => CosmicStyle::Oblique, - }) + TextSpanWeight::Normal => CosmicWeight::NORMAL, + TextSpanWeight::Medium => CosmicWeight::MEDIUM, + TextSpanWeight::Semibold => CosmicWeight::SEMIBOLD, + TextSpanWeight::Bold => CosmicWeight::BOLD, + }) + .style(match span.slant { + TextSpanSlant::Normal => CosmicStyle::Normal, + TextSpanSlant::Italic => CosmicStyle::Italic, + TextSpanSlant::Oblique => CosmicStyle::Oblique, + }) } fn to_cosmic_color(color: Color) -> CosmicColor { @@ -930,7 +933,9 @@ impl std::hash::Hash for TextStyle { self.line_height.to_bits().hash(state); self.color.hash(state); self.font_family.hash(state); - self.bounds.map(|b| (b.width.to_bits(), b.height.to_bits())).hash(state); + self.bounds + .map(|b| (b.width.to_bits(), b.height.to_bits())) + .hash(state); self.wrap.hash(state); self.align.hash(state); self.max_lines.hash(state); @@ -1038,16 +1043,32 @@ mod tests { let blue = text_system.prepare("hello", origin, &style_blue); // Both PreparedTexts must have identical glyph shapes and sentinel colors — // the second call hits the shape cache and produces the same layout data. - assert_eq!(red.glyphs.len(), blue.glyphs.len(), "glyph count must match"); + assert_eq!( + red.glyphs.len(), + blue.glyphs.len(), + "glyph count must match" + ); for (r, b) in red.glyphs.iter().zip(blue.glyphs.iter()) { assert_eq!(r.position, b.position, "glyph positions must match"); assert_eq!(r.cache_key, b.cache_key, "glyph cache keys must match"); - assert_eq!(r.color, Color::SENTINEL, "default-color glyphs must use SENTINEL"); - assert_eq!(b.color, Color::SENTINEL, "default-color glyphs must use SENTINEL"); + assert_eq!( + r.color, + Color::SENTINEL, + "default-color glyphs must use SENTINEL" + ); + assert_eq!( + b.color, + Color::SENTINEL, + "default-color glyphs must use SENTINEL" + ); } // PreparedText::clone() is an Arc clone — O(1). let cloned = red.clone(); - assert_eq!(cloned.layout_ptr(), red.layout_ptr(), "clone must share the same Arc"); + assert_eq!( + cloned.layout_ptr(), + red.layout_ptr(), + "clone must share the same Arc" + ); // But they should carry different default colors. assert_eq!(red.default_color, Color::rgb(0xFF, 0x00, 0x00)); assert_eq!(blue.default_color, Color::rgb(0x00, 0x00, 0xFF)); diff --git a/lib/ui_platform_macos/Cargo.toml b/lib/ui_platform_macos/Cargo.toml new file mode 100644 index 0000000..358a1d6 --- /dev/null +++ b/lib/ui_platform_macos/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ruin_ui_platform_macos" +version = "0.1.0" +edition = "2024" + +[dependencies] +libc = "0.2" +objc2 = "0.6.4" +objc2-core-foundation = "0.3.2" +objc2-foundation = "0.3.2" +raw-window-handle = "0.6" +ruin_ui = { path = "../ui" } +ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" } +ruin_runtime = { package = "ruin-runtime", path = "../runtime" } +tracing = "0.1" diff --git a/lib/ui_platform_macos/src/lib.rs b/lib/ui_platform_macos/src/lib.rs new file mode 100644 index 0000000..304aa98 --- /dev/null +++ b/lib/ui_platform_macos/src/lib.rs @@ -0,0 +1,1058 @@ +//! macOS platform backend entrypoints. +//! +//! Native backend implemented on top of Cocoa/AppKit runtime messaging. + +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::collections::HashSet; +use std::ffi::{CStr, CString, c_void}; +use std::num::NonZeroU32; +use std::ptr::NonNull; +use std::rc::Rc; +use std::sync::{Mutex, Once, OnceLock}; +use std::time::Duration; + +use objc2::runtime::{AnyClass, AnyObject, Bool, ClassBuilder, Sel}; +use objc2::sel; +use objc2_core_foundation::{CGPoint, CGRect, CGSize}; +use objc2_foundation::{NSString, ns_string}; +use raw_window_handle::{ + AppKitDisplayHandle, AppKitWindowHandle, DisplayHandle, HandleError, HasDisplayHandle, + HasWindowHandle, RawDisplayHandle, RawWindowHandle, WindowHandle, +}; +use ruin_runtime::{queue_future, set_interval}; +use ruin_ui::{ + CursorIcon, KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers, + PlatformEndpoint, PlatformEvent, PlatformRequest, PlatformRuntime, Point, PointerButton, + PointerEvent, PointerEventKind, SceneSnapshot, UiRuntime, UiSize, WindowConfigured, WindowId, + WindowLifecycle, WindowSpec, WindowUpdate, +}; +use ruin_ui_renderer_wgpu::{RenderError, WgpuSceneRenderer}; + +#[link(name = "AppKit", kind = "framework")] +unsafe extern "C" {} + +const NS_APPLICATION_ACTIVATION_POLICY_REGULAR: isize = 0; +const NS_BACKING_STORE_BUFFERED: usize = 2; +const NS_WINDOW_STYLE_TITLED: usize = 1 << 0; +const NS_WINDOW_STYLE_CLOSABLE: usize = 1 << 1; +const NS_WINDOW_STYLE_MINIATURIZABLE: usize = 1 << 2; +const NS_WINDOW_STYLE_RESIZABLE: usize = 1 << 3; +const NS_VIEW_WIDTH_SIZABLE: usize = 1 << 1; +const NS_VIEW_HEIGHT_SIZABLE: usize = 1 << 4; + +const NSEVENT_TYPE_LEFT_MOUSE_DOWN: usize = 1; +const NSEVENT_TYPE_LEFT_MOUSE_UP: usize = 2; +const NSEVENT_TYPE_RIGHT_MOUSE_DOWN: usize = 3; +const NSEVENT_TYPE_RIGHT_MOUSE_UP: usize = 4; +const NSEVENT_TYPE_MOUSE_MOVED: usize = 5; +const NSEVENT_TYPE_LEFT_MOUSE_DRAGGED: usize = 6; +const NSEVENT_TYPE_RIGHT_MOUSE_DRAGGED: usize = 7; +const NSEVENT_TYPE_MOUSE_EXITED: usize = 9; +const NSEVENT_TYPE_KEY_DOWN: usize = 10; +const NSEVENT_TYPE_KEY_UP: usize = 11; +const NSEVENT_TYPE_SCROLL_WHEEL: usize = 22; +const NSEVENT_TYPE_OTHER_MOUSE_DOWN: usize = 25; +const NSEVENT_TYPE_OTHER_MOUSE_UP: usize = 26; +const NSEVENT_TYPE_OTHER_MOUSE_DRAGGED: usize = 27; + +const NSEVENT_MODIFIER_FLAG_SHIFT: usize = 1 << 17; +const NSEVENT_MODIFIER_FLAG_CONTROL: usize = 1 << 18; +const NSEVENT_MODIFIER_FLAG_OPTION: usize = 1 << 19; +const NSEVENT_MODIFIER_FLAG_COMMAND: usize = 1 << 20; + +struct MacosState { + app: *mut AnyObject, + endpoint_events: ruin_runtime::channel::mpsc::UnboundedSender, + windows: BTreeMap, +} + +struct MacosWindow { + spec: WindowSpec, + lifecycle: WindowLifecycle, + configuration: WindowConfigured, + latest_scene: Option, + clipboard_text: Option, + desired_visible: bool, + cursor_icon: CursorIcon, + close_requested_sent: bool, + close_in_progress: bool, + native: Option, +} + +struct NativeWindow { + window: *mut AnyObject, + #[allow(dead_code)] + view: *mut AnyObject, + delegate: *mut AnyObject, + renderer: Option, +} + +#[derive(Default)] +struct DelegateState { + pending_close_requests: Vec, + pending_closed_windows: Vec, + allowed_programmatic_closes: HashSet, +} + +impl MacosWindow { + fn new(spec: WindowSpec) -> Self { + let desired_visible = spec.visible; + let actual_inner_size = spec + .requested_inner_size + .unwrap_or_else(|| UiSize::new(960.0, 640.0)); + Self { + spec, + lifecycle: WindowLifecycle::LogicalClosed, + configuration: WindowConfigured { + actual_inner_size, + scale_factor: 1.0, + visible: false, + maximized: false, + fullscreen: false, + }, + latest_scene: None, + clipboard_text: None, + desired_visible, + cursor_icon: CursorIcon::Default, + close_requested_sent: false, + close_in_progress: false, + native: None, + } + } +} + +#[derive(Clone)] +struct AppKitSurfaceTarget { + view: *mut AnyObject, +} + +// NSView pointer is only used on the owning thread by the renderer; this is +// required by wgpu surface creation bounds. +unsafe impl Send for AppKitSurfaceTarget {} +unsafe impl Sync for AppKitSurfaceTarget {} + +impl HasDisplayHandle for AppKitSurfaceTarget { + fn display_handle(&self) -> Result, HandleError> { + let raw = RawDisplayHandle::AppKit(AppKitDisplayHandle::new()); + Ok(unsafe { DisplayHandle::borrow_raw(raw) }) + } +} + +impl HasWindowHandle for AppKitSurfaceTarget { + fn window_handle(&self) -> Result, HandleError> { + let ptr = NonNull::new(self.view.cast::()).ok_or(HandleError::Unavailable)?; + let raw = RawWindowHandle::AppKit(AppKitWindowHandle::new(ptr)); + Ok(unsafe { WindowHandle::borrow_raw(raw) }) + } +} + +/// Starts the macOS platform runtime. +/// +/// Must be called on the process main thread. +pub fn start_macos_platform() -> PlatformRuntime { + assert_main_thread(); + let app = ensure_ns_application(); + + PlatformRuntime::custom_local(|mut endpoint: PlatformEndpoint| { + let state = Rc::new(RefCell::new(MacosState { + app, + endpoint_events: endpoint.events.clone(), + windows: BTreeMap::new(), + })); + + // Drive AppKit in non-blocking mode from the runtime loop. + let state_for_pump = Rc::clone(&state); + let _pump = set_interval(Duration::from_millis(8), move || { + pump_appkit(&state_for_pump); + }); + + queue_future(async move { + while let Some(command) = endpoint.commands.recv().await { + match command { + PlatformRequest::CreateWindow { window_id, spec } => { + handle_create_window(&state, window_id, spec); + } + PlatformRequest::UpdateWindow { window_id, update } => { + handle_update_window(&state, window_id, update); + } + PlatformRequest::ReplaceScene { window_id, scene } => { + handle_replace_scene(&state, window_id, scene); + } + PlatformRequest::SetClipboardText { window_id, text } => { + if let Some(window) = state.borrow_mut().windows.get_mut(&window_id) { + window.clipboard_text = Some(text); + } + } + PlatformRequest::RequestClipboardText { window_id } => { + let text = state + .borrow() + .windows + .get(&window_id) + .and_then(|window| window.clipboard_text.clone()) + .unwrap_or_default(); + emit(&state, PlatformEvent::ClipboardText { window_id, text }); + } + PlatformRequest::SetCursorIcon { window_id, cursor } => { + if let Some(window) = state.borrow_mut().windows.get_mut(&window_id) { + window.cursor_icon = cursor; + apply_cursor_icon(cursor); + } + } + PlatformRequest::EmitCloseRequested { window_id } => { + emit(&state, PlatformEvent::CloseRequested { window_id }); + } + PlatformRequest::EmitPointerEvent { window_id, event } => { + emit(&state, PlatformEvent::Pointer { window_id, event }); + } + PlatformRequest::EmitKeyboardEvent { window_id, event } => { + emit(&state, PlatformEvent::Keyboard { window_id, event }); + } + PlatformRequest::EmitWake { window_id, token } => { + emit(&state, PlatformEvent::Wake { window_id, token }); + } + #[cfg(target_os = "linux")] + PlatformRequest::SetPrimarySelectionText { .. } => {} + #[cfg(target_os = "linux")] + PlatformRequest::RequestPrimarySelectionText { .. } => {} + PlatformRequest::Shutdown => break, + } + } + }); + }) +} + +/// Starts a UI runtime backed by the macOS platform runtime. +pub fn start_macos_ui() -> UiRuntime { + UiRuntime::from_platform(start_macos_platform()) +} + +fn assert_main_thread() { + let is_main = unsafe { libc::pthread_main_np() == 1 }; + assert!( + is_main, + "ruin_ui_platform_macos must be started on the process main thread" + ); +} + +fn ensure_ns_application() -> *mut AnyObject { + let app: *mut AnyObject = unsafe { objc2::msg_send![objc2::class!(NSApplication), sharedApplication] }; + unsafe { + let _: bool = + objc2::msg_send![app, setActivationPolicy: NS_APPLICATION_ACTIVATION_POLICY_REGULAR]; + let _: () = objc2::msg_send![app, finishLaunching]; + let _: () = objc2::msg_send![app, activateIgnoringOtherApps: true]; + } + app +} + +fn delegate_state() -> &'static Mutex { + static DELEGATE_STATE: OnceLock> = OnceLock::new(); + DELEGATE_STATE.get_or_init(|| Mutex::new(DelegateState::default())) +} + +fn queue_delegate_close_request(window: *mut AnyObject) { + if window.is_null() { + return; + } + if let Ok(mut state) = delegate_state().lock() { + state.pending_close_requests.push(window as usize); + } +} + +fn queue_delegate_window_closed(window: *mut AnyObject) { + if window.is_null() { + return; + } + if let Ok(mut state) = delegate_state().lock() { + state.pending_closed_windows.push(window as usize); + } +} + +fn mark_programmatic_window_close(window: *mut AnyObject) { + if window.is_null() { + return; + } + if let Ok(mut state) = delegate_state().lock() { + state.allowed_programmatic_closes.insert(window as usize); + } +} + +fn consume_programmatic_close(window: *mut AnyObject) -> bool { + if window.is_null() { + return false; + } + if let Ok(mut state) = delegate_state().lock() { + return state.allowed_programmatic_closes.remove(&(window as usize)); + } + false +} + +fn take_delegate_close_requests() -> Vec { + if let Ok(mut state) = delegate_state().lock() { + return std::mem::take(&mut state.pending_close_requests); + } + Vec::new() +} + +fn take_delegate_closed_windows() -> Vec { + if let Ok(mut state) = delegate_state().lock() { + return std::mem::take(&mut state.pending_closed_windows); + } + Vec::new() +} + +fn window_delegate_class() -> &'static AnyClass { + static REGISTER: Once = Once::new(); + static CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); + + REGISTER.call_once(|| { + extern "C-unwind" fn should_close( + _this: &AnyObject, + _cmd: Sel, + sender: *mut AnyObject, + ) -> Bool { + if consume_programmatic_close(sender) { + Bool::YES + } else { + queue_delegate_close_request(sender); + Bool::NO + } + } + extern "C-unwind" fn did_close( + _this: &AnyObject, + _cmd: Sel, + notification: *mut AnyObject, + ) { + let window: *mut AnyObject = unsafe { objc2::msg_send![notification, object] }; + queue_delegate_window_closed(window); + } + + let class_name = CString::new("RuinWindowDelegate").expect("class name is valid"); + if let Some(mut builder) = ClassBuilder::new(&class_name, objc2::class!(NSObject)) { + unsafe { + let method: extern "C-unwind" fn(_, _, _) -> _ = should_close; + builder.add_method::(sel!(windowShouldClose:), method); + let method: extern "C-unwind" fn(_, _, _) = did_close; + builder.add_method::(sel!(windowWillClose:), method); + } + let _ = CLASS.set(builder.register()); + } else { + let existing = AnyClass::get(class_name.as_c_str()) + .expect("RuinWindowDelegate class must be available"); + let _ = CLASS.set(existing); + } + }); + + CLASS + .get() + .expect("RuinWindowDelegate class registration must succeed") +} + +fn pump_appkit(state: &Rc>) { + let mut pending = Vec::new(); + + { + let mut state_ref = state.borrow_mut(); + let app = state_ref.app; + + loop { + let event: *mut AnyObject = unsafe { + objc2::msg_send![ + app, + nextEventMatchingMask: usize::MAX, + untilDate: std::ptr::null_mut::(), + inMode: ns_string!("kCFRunLoopDefaultMode"), + dequeue: true + ] + }; + if event.is_null() { + break; + } + + if let Some(window_id) = window_id_for_event(&state_ref, event) + && let Some(window) = state_ref.windows.get_mut(&window_id) + && let Some(platform_event) = translate_event(window_id, window, event) + { + pending.push(platform_event); + } + + unsafe { + let _: () = objc2::msg_send![app, sendEvent: event]; + } + } + + unsafe { + let _: () = objc2::msg_send![app, updateWindows]; + } + + for (window_id, window) in &mut state_ref.windows { + collect_window_state_events(*window_id, window, &mut pending); + } + + for window_ptr in take_delegate_close_requests() { + let Some((window_id, window)) = state_ref.windows.iter_mut().find(|(_, window)| { + window + .native + .as_ref() + .is_some_and(|native| native.window as usize == window_ptr) + }) else { + continue; + }; + if !window.close_requested_sent { + window.close_requested_sent = true; + pending.push(PlatformEvent::CloseRequested { + window_id: *window_id, + }); + } + } + + for window_ptr in take_delegate_closed_windows() { + let Some((window_id, window)) = state_ref.windows.iter_mut().find(|(_, window)| { + window + .native + .as_ref() + .is_some_and(|native| native.window as usize == window_ptr) + }) else { + continue; + }; + finalize_native_close(*window_id, window, &mut pending); + } + } + + for event in pending { + emit(state, event); + } +} + +fn handle_create_window(state: &Rc>, window_id: WindowId, spec: WindowSpec) { + let open = spec.open; + let visible = spec.visible; + let mut emit_opened = false; + let mut emit_configured = None; + let mut emit_visibility = None; + + { + let mut state_ref = state.borrow_mut(); + state_ref.windows.insert(window_id, MacosWindow::new(spec)); + if open { + let app = state_ref.app; + if let Some(window) = state_ref.windows.get_mut(&window_id) { + ensure_native_window(window, app); + sync_window_metrics(window); + window.lifecycle = if visible { + WindowLifecycle::OpenVisible + } else { + WindowLifecycle::OpenHidden + }; + window.configuration.visible = visible; + window.desired_visible = visible; + window.close_requested_sent = false; + window.close_in_progress = false; + sync_visibility(window); + apply_cursor_icon(window.cursor_icon); + emit_opened = true; + emit_configured = Some(window.configuration); + emit_visibility = Some(visible); + } + } + } + + if emit_opened { + emit(state, PlatformEvent::Opened { window_id }); + } + if let Some(configuration) = emit_configured { + emit( + state, + PlatformEvent::Configured { + window_id, + configuration, + }, + ); + } + if let Some(visible) = emit_visibility { + emit(state, PlatformEvent::VisibilityChanged { window_id, visible }); + } +} + +fn handle_update_window(state: &Rc>, window_id: WindowId, update: WindowUpdate) { + let mut emit_opened = false; + let mut emit_visibility = None; + let mut emit_configured = None; + let mut emit_closed = false; + + { + let mut state_ref = state.borrow_mut(); + let app = state_ref.app; + let Some(window) = state_ref.windows.get_mut(&window_id) else { + return; + }; + + if let Some(title) = update.title { + window.spec.title = title; + if let Some(native) = window.native.as_ref() { + let title = NSString::from_str(&window.spec.title); + unsafe { + let _: () = objc2::msg_send![native.window, setTitle: &*title]; + } + } + } + + if let Some(open) = update.open { + if open { + if window.lifecycle == WindowLifecycle::LogicalClosed { + ensure_native_window(window, app); + sync_window_metrics(window); + window.lifecycle = if window.configuration.visible { + WindowLifecycle::OpenVisible + } else { + WindowLifecycle::OpenHidden + }; + window.close_requested_sent = false; + window.close_in_progress = false; + sync_visibility(window); + apply_cursor_icon(window.cursor_icon); + emit_opened = true; + } + } else if window.lifecycle != WindowLifecycle::LogicalClosed { + window.desired_visible = false; + if window.native.is_some() { + window.close_in_progress = true; + close_native_window(window); + } else { + window.lifecycle = WindowLifecycle::LogicalClosed; + emit_closed = true; + } + } + } + + if let Some(visible) = update.visible + && window.configuration.visible != visible + { + window.configuration.visible = visible; + window.desired_visible = visible; + window.lifecycle = if visible { + WindowLifecycle::OpenVisible + } else { + WindowLifecycle::OpenHidden + }; + if visible { + window.close_requested_sent = false; + } + sync_visibility(window); + emit_visibility = Some(visible); + } + + if let Some(requested) = update.requested_inner_size { + if let Some(size) = requested { + window.configuration.actual_inner_size = size; + if let Some(native) = window.native.as_mut() { + resize_native_window(native, size); + } + } + emit_configured = Some(window.configuration); + } + } + + if emit_opened { + emit(state, PlatformEvent::Opened { window_id }); + } + if let Some(visible) = emit_visibility { + emit(state, PlatformEvent::VisibilityChanged { window_id, visible }); + } + if let Some(configuration) = emit_configured { + emit( + state, + PlatformEvent::Configured { + window_id, + configuration, + }, + ); + } + if emit_closed { + emit(state, PlatformEvent::Closed { window_id }); + } +} + +fn handle_replace_scene(state: &Rc>, window_id: WindowId, scene: SceneSnapshot) { + let (scene_version, item_count, visible) = { + let mut state_ref = state.borrow_mut(); + let app = state_ref.app; + let Some(window) = state_ref.windows.get_mut(&window_id) else { + return; + }; + + ensure_native_window(window, app); + let item_count = scene.items.len(); + let scene_version = scene.version; + + if let Some(native) = window.native.as_mut() + && let Some(renderer) = native.renderer.as_mut() + { + match renderer.render(&scene) { + Ok(()) => {} + Err(RenderError::Lost | RenderError::Outdated) => { + let size = window.configuration.actual_inner_size; + renderer.resize(size.width.max(1.0) as u32, size.height.max(1.0) as u32); + let _ = renderer.render(&scene); + } + Err(RenderError::Timeout | RenderError::Occluded | RenderError::Validation) => {} + } + } + + window.latest_scene = Some(scene); + (scene_version, item_count, window.configuration.visible) + }; + + if visible { + emit( + state, + PlatformEvent::FramePresented { + window_id, + scene_version, + item_count, + }, + ); + } +} + +fn ensure_native_window(window: &mut MacosWindow, app: *mut AnyObject) { + if window.native.is_some() { + return; + } + + if let Some(native) = create_native_window(window, app) { + window.native = Some(native); + } +} + +fn create_native_window(window: &MacosWindow, app: *mut AnyObject) -> Option { + let size = window.configuration.actual_inner_size; + let rect = CGRect::new( + CGPoint::new(0.0, 0.0), + CGSize::new(size.width as f64, size.height as f64), + ); + + let style = NS_WINDOW_STYLE_TITLED + | NS_WINDOW_STYLE_CLOSABLE + | NS_WINDOW_STYLE_MINIATURIZABLE + | NS_WINDOW_STYLE_RESIZABLE; + + let window_obj: *mut AnyObject = unsafe { objc2::msg_send![objc2::class!(NSWindow), alloc] }; + let window_obj: *mut AnyObject = unsafe { + objc2::msg_send![ + window_obj, + initWithContentRect: rect, + styleMask: style, + backing: NS_BACKING_STORE_BUFFERED, + defer: false + ] + }; + if window_obj.is_null() { + return None; + } + + let title = NSString::from_str(&window.spec.title); + unsafe { + let _: () = objc2::msg_send![window_obj, setTitle: &*title]; + let _: () = objc2::msg_send![window_obj, setReleasedWhenClosed: false]; + let _: () = objc2::msg_send![window_obj, setAcceptsMouseMovedEvents: true]; + } + + let view_obj: *mut AnyObject = unsafe { objc2::msg_send![objc2::class!(NSView), alloc] }; + let view_obj: *mut AnyObject = unsafe { objc2::msg_send![view_obj, initWithFrame: rect] }; + if view_obj.is_null() { + return None; + } + + let delegate_class = window_delegate_class(); + let delegate_obj: *mut AnyObject = unsafe { objc2::msg_send![delegate_class, new] }; + if delegate_obj.is_null() { + return None; + } + + unsafe { + let _: () = objc2::msg_send![view_obj, setWantsLayer: true]; + let _: () = objc2::msg_send![ + view_obj, + setAutoresizingMask: (NS_VIEW_WIDTH_SIZABLE | NS_VIEW_HEIGHT_SIZABLE) + ]; + let _: () = objc2::msg_send![window_obj, setContentView: view_obj]; + let _: () = objc2::msg_send![window_obj, setDelegate: delegate_obj]; + let _: () = objc2::msg_send![window_obj, makeKeyAndOrderFront: std::ptr::null_mut::()]; + let _: () = objc2::msg_send![app, activateIgnoringOtherApps: true]; + } + + let scale_factor: f64 = unsafe { objc2::msg_send![window_obj, backingScaleFactor] }; + let scale_factor = (scale_factor as f32).max(1.0); + let target = AppKitSurfaceTarget { view: view_obj }; + let renderer = WgpuSceneRenderer::new( + target, + (size.width * scale_factor).max(1.0) as u32, + (size.height * scale_factor).max(1.0) as u32, + ) + .ok(); + + Some(NativeWindow { + window: window_obj, + view: view_obj, + delegate: delegate_obj, + renderer, + }) +} + +fn resize_native_window(native: &mut NativeWindow, size: UiSize) { + let content_size = CGSize::new(size.width as f64, size.height as f64); + unsafe { + let _: () = objc2::msg_send![native.window, setContentSize: content_size]; + } + + if let Some(renderer) = native.renderer.as_mut() { + let scale_factor: f64 = unsafe { objc2::msg_send![native.window, backingScaleFactor] }; + let scale_factor = (scale_factor as f32).max(1.0); + let width = NonZeroU32::new((size.width * scale_factor).max(1.0) as u32) + .map(NonZeroU32::get) + .unwrap_or(1); + let height = NonZeroU32::new((size.height * scale_factor).max(1.0) as u32) + .map(NonZeroU32::get) + .unwrap_or(1); + renderer.resize(width, height); + } +} + +fn sync_visibility(window: &MacosWindow) { + let Some(native) = window.native.as_ref() else { + return; + }; + + if window.configuration.visible { + unsafe { + let _: () = objc2::msg_send![native.window, makeKeyAndOrderFront: std::ptr::null_mut::()]; + } + } else { + unsafe { + let _: () = objc2::msg_send![native.window, orderOut: std::ptr::null_mut::()]; + } + } +} + +fn close_native_window(window: &mut MacosWindow) { + if let Some(native) = window.native.as_ref() { + mark_programmatic_window_close(native.window); + unsafe { + let _: () = objc2::msg_send![native.window, close]; + } + } +} + +fn emit(state: &Rc>, event: PlatformEvent) { + let sender = state.borrow().endpoint_events.clone(); + let _ = sender.send(event); +} + +fn window_id_for_event(state: &MacosState, event: *mut AnyObject) -> Option { + let event_window: *mut AnyObject = unsafe { objc2::msg_send![event, window] }; + if event_window.is_null() { + return None; + } + + state.windows.iter().find_map(|(window_id, window)| { + let matches = window + .native + .as_ref() + .is_some_and(|native| native.window == event_window); + matches.then_some(*window_id) + }) +} + +fn translate_event( + window_id: WindowId, + window: &mut MacosWindow, + event: *mut AnyObject, +) -> Option { + let event_type: usize = unsafe { objc2::msg_send![event, type] }; + match event_type { + NSEVENT_TYPE_MOUSE_MOVED + | NSEVENT_TYPE_LEFT_MOUSE_DRAGGED + | NSEVENT_TYPE_RIGHT_MOUSE_DRAGGED + | NSEVENT_TYPE_OTHER_MOUSE_DRAGGED => { + let position = pointer_position(event, window.configuration.actual_inner_size); + Some(PlatformEvent::Pointer { + window_id, + event: PointerEvent::new(0, position, PointerEventKind::Move), + }) + } + NSEVENT_TYPE_LEFT_MOUSE_DOWN => { + let position = pointer_position(event, window.configuration.actual_inner_size); + Some(PlatformEvent::Pointer { + window_id, + event: PointerEvent::new( + 0, + position, + PointerEventKind::Down { + button: PointerButton::Primary, + }, + ), + }) + } + NSEVENT_TYPE_LEFT_MOUSE_UP => { + let position = pointer_position(event, window.configuration.actual_inner_size); + Some(PlatformEvent::Pointer { + window_id, + event: PointerEvent::new( + 0, + position, + PointerEventKind::Up { + button: PointerButton::Primary, + }, + ), + }) + } + NSEVENT_TYPE_OTHER_MOUSE_DOWN | NSEVENT_TYPE_OTHER_MOUSE_UP => { + let button_number: usize = unsafe { objc2::msg_send![event, buttonNumber] }; + if button_number != 2 { + return None; + } + let position = pointer_position(event, window.configuration.actual_inner_size); + let kind = if event_type == NSEVENT_TYPE_OTHER_MOUSE_DOWN { + PointerEventKind::Down { + button: PointerButton::Middle, + } + } else { + PointerEventKind::Up { + button: PointerButton::Middle, + } + }; + Some(PlatformEvent::Pointer { + window_id, + event: PointerEvent::new(0, position, kind), + }) + } + NSEVENT_TYPE_SCROLL_WHEEL => { + let position = pointer_position(event, window.configuration.actual_inner_size); + let delta_x: f64 = unsafe { objc2::msg_send![event, scrollingDeltaX] }; + let delta_y: f64 = unsafe { objc2::msg_send![event, scrollingDeltaY] }; + Some(PlatformEvent::Pointer { + window_id, + event: PointerEvent::new( + 0, + position, + PointerEventKind::Scroll { + delta: Point::new(delta_x as f32, -(delta_y as f32)), + }, + ), + }) + } + NSEVENT_TYPE_MOUSE_EXITED => { + let position = pointer_position(event, window.configuration.actual_inner_size); + Some(PlatformEvent::Pointer { + window_id, + event: PointerEvent::new(0, position, PointerEventKind::LeaveWindow), + }) + } + NSEVENT_TYPE_KEY_DOWN | NSEVENT_TYPE_KEY_UP => { + let keycode: u16 = unsafe { objc2::msg_send![event, keyCode] }; + let kind = if event_type == NSEVENT_TYPE_KEY_DOWN { + KeyboardEventKind::Pressed + } else { + KeyboardEventKind::Released + }; + let modifiers = keyboard_modifiers(event); + let text = if kind == KeyboardEventKind::Pressed { + event_characters(event) + } else { + None + }; + let key = keyboard_key(keycode, text.as_deref()); + Some(PlatformEvent::Keyboard { + window_id, + event: KeyboardEvent::new(u32::from(keycode), kind, key, modifiers, text), + }) + } + NSEVENT_TYPE_RIGHT_MOUSE_DOWN | NSEVENT_TYPE_RIGHT_MOUSE_UP => None, + _ => None, + } +} + +fn collect_window_state_events( + window_id: WindowId, + window: &mut MacosWindow, + pending: &mut Vec, +) { + let Some((window_ptr, view_ptr)) = window + .native + .as_ref() + .map(|native| (native.window, native.view)) + else { + return; + }; + if window.lifecycle == WindowLifecycle::LogicalClosed { + return; + } + + let frame: CGRect = unsafe { objc2::msg_send![view_ptr, frame] }; + let scale_factor: f64 = unsafe { objc2::msg_send![window_ptr, backingScaleFactor] }; + let scale_factor = scale_factor as f32; + let content_size = UiSize::new(frame.size.width as f32, frame.size.height as f32); + let resized = content_size.width > 0.0 + && content_size.height > 0.0 + && content_size != window.configuration.actual_inner_size; + let scale_changed = (window.configuration.scale_factor - scale_factor).abs() > f32::EPSILON; + + if resized || scale_changed { + window.configuration.actual_inner_size = content_size; + window.configuration.scale_factor = scale_factor.max(1.0); + if let Some(renderer) = window.native.as_mut().and_then(|native| native.renderer.as_mut()) { + let width = (content_size.width * window.configuration.scale_factor).max(1.0) as u32; + let height = (content_size.height * window.configuration.scale_factor).max(1.0) as u32; + renderer.resize(width, height); + // Avoid AppKit stretch artifacts during live resize while layout catches up. + if let Some(scene) = window.latest_scene.as_ref() { + let _ = renderer.render(scene); + } + } + pending.push(PlatformEvent::Configured { + window_id, + configuration: window.configuration, + }); + } + + let is_visible: bool = unsafe { objc2::msg_send![window_ptr, isVisible] }; + if is_visible != window.configuration.visible { + window.configuration.visible = is_visible; + window.lifecycle = if is_visible { + window.close_requested_sent = false; + WindowLifecycle::OpenVisible + } else { + WindowLifecycle::OpenHidden + }; + pending.push(PlatformEvent::VisibilityChanged { + window_id, + visible: is_visible, + }); + } + + if window.close_in_progress && !is_visible { + finalize_native_close(window_id, window, pending); + return; + } + + if !is_visible && window.desired_visible && !window.close_requested_sent { + window.close_requested_sent = true; + pending.push(PlatformEvent::CloseRequested { window_id }); + } +} + +fn pointer_position(event: *mut AnyObject, size: UiSize) -> Point { + let location: CGPoint = unsafe { objc2::msg_send![event, locationInWindow] }; + Point::new(location.x as f32, size.height - location.y as f32) +} + +fn keyboard_modifiers(event: *mut AnyObject) -> KeyboardModifiers { + let flags: usize = unsafe { objc2::msg_send![event, modifierFlags] }; + KeyboardModifiers { + shift: flags & NSEVENT_MODIFIER_FLAG_SHIFT != 0, + control: flags & NSEVENT_MODIFIER_FLAG_CONTROL != 0, + alt: flags & NSEVENT_MODIFIER_FLAG_OPTION != 0, + super_key: flags & NSEVENT_MODIFIER_FLAG_COMMAND != 0, + } +} + +fn keyboard_key(keycode: u16, text: Option<&str>) -> KeyboardKey { + match keycode { + 36 => KeyboardKey::Enter, + 48 => KeyboardKey::Tab, + 53 => KeyboardKey::Escape, + 51 => KeyboardKey::Backspace, + 117 => KeyboardKey::Delete, + 123 => KeyboardKey::ArrowLeft, + 124 => KeyboardKey::ArrowRight, + 125 => KeyboardKey::ArrowDown, + 126 => KeyboardKey::ArrowUp, + 115 => KeyboardKey::Home, + 119 => KeyboardKey::End, + _ => text + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .map(KeyboardKey::Character) + .unwrap_or(KeyboardKey::Unknown), + } +} + +fn event_characters(event: *mut AnyObject) -> Option { + let string: *mut AnyObject = unsafe { objc2::msg_send![event, characters] }; + nsstring_to_string(string) +} + +fn nsstring_to_string(string: *mut AnyObject) -> Option { + if string.is_null() { + return None; + } + let cstr_ptr: *const i8 = unsafe { objc2::msg_send![string, UTF8String] }; + if cstr_ptr.is_null() { + return None; + } + let text = unsafe { CStr::from_ptr(cstr_ptr) } + .to_string_lossy() + .into_owned(); + (!text.is_empty()).then_some(text) +} + +fn apply_cursor_icon(cursor: CursorIcon) { + let class = objc2::class!(NSCursor); + let cursor_obj: *mut AnyObject = unsafe { + match cursor { + CursorIcon::Default => objc2::msg_send![class, arrowCursor], + CursorIcon::Pointer => objc2::msg_send![class, pointingHandCursor], + CursorIcon::Text => objc2::msg_send![class, IBeamCursor], + } + }; + if cursor_obj.is_null() { + return; + } + unsafe { + let _: () = objc2::msg_send![cursor_obj, set]; + } +} + +fn finalize_native_close( + window_id: WindowId, + window: &mut MacosWindow, + pending: &mut Vec, +) { + if let Some(native) = window.native.take() { + unsafe { + let _: () = objc2::msg_send![native.window, setDelegate: std::ptr::null_mut::()]; + let _: () = objc2::msg_send![native.delegate, release]; + } + } + + window.close_in_progress = false; + window.lifecycle = WindowLifecycle::LogicalClosed; + window.desired_visible = false; + + if window.configuration.visible { + window.configuration.visible = false; + pending.push(PlatformEvent::VisibilityChanged { + window_id, + visible: false, + }); + } + pending.push(PlatformEvent::Closed { window_id }); +} + +fn sync_window_metrics(window: &mut MacosWindow) { + let Some((window_ptr, view_ptr)) = window + .native + .as_ref() + .map(|native| (native.window, native.view)) + else { + return; + }; + let frame: CGRect = unsafe { objc2::msg_send![view_ptr, frame] }; + let scale_factor: f64 = unsafe { objc2::msg_send![window_ptr, backingScaleFactor] }; + window.configuration.actual_inner_size = + UiSize::new(frame.size.width as f32, frame.size.height as f32); + window.configuration.scale_factor = (scale_factor as f32).max(1.0); +} diff --git a/lib/ui_platform_wayland/src/lib.rs b/lib/ui_platform_wayland/src/lib.rs index b6ba4c0..c9dcf3b 100644 --- a/lib/ui_platform_wayland/src/lib.rs +++ b/lib/ui_platform_wayland/src/lib.rs @@ -1309,7 +1309,9 @@ fn spawn_window_worker( "worker observed resized frame" ); state_ref.pending_viewport = Some(current_viewport); - state_ref.pending_viewport_since.get_or_insert_with(Instant::now); + state_ref + .pending_viewport_since + .get_or_insert_with(Instant::now); // Emit configure BEFORE touching the GPU so the app // thread starts layout immediately in parallel. // The actual swapchain recreation is deferred to just @@ -1354,7 +1356,9 @@ fn spawn_window_worker( "scene size does not match current viewport; holding last buffer" ); state_ref.pending_viewport = Some(current_viewport); - state_ref.pending_viewport_since.get_or_insert_with(Instant::now); + state_ref + .pending_viewport_since + .get_or_insert_with(Instant::now); state_ref.window.request_redraw(); } else if !state_ref.window.presentation_ready() { // Correct scene is ready but the compositor @@ -1365,7 +1369,9 @@ fn spawn_window_worker( // before rendering. Deferring from frame.resized means // rapid configures pay one recreation instead of one // per event. - if let Some((w, h)) = state_ref.pending_swapchain_size.take() { + if let Some((w, h)) = + state_ref.pending_swapchain_size.take() + { let t_resize = std::time::Instant::now(); state_ref.renderer.resize(w, h); let resize_gpu_us = t_resize.elapsed().as_micros(); @@ -1382,7 +1388,8 @@ fn spawn_window_worker( let t_render = std::time::Instant::now(); match state_ref.renderer.render(scene) { Ok(()) => { - let render_gpu_us = t_render.elapsed().as_micros(); + let render_gpu_us = + t_render.elapsed().as_micros(); debug!( target: "ruin_ui_platform_wayland::perf", window_id = state_ref.window_id.raw(), @@ -1464,10 +1471,10 @@ fn spawn_window_worker( { let delay = repeat.next_at.saturating_duration_since(Instant::now()); - state_ref.keyboard_repeat_timer = Some(set_timeout( - delay, - move || write_wakeup(pipe_write_fd), - )); + state_ref.keyboard_repeat_timer = + Some(set_timeout(delay, move || { + write_wakeup(pipe_write_fd) + })); } } } diff --git a/lib/ui_renderer_wgpu/src/lib.rs b/lib/ui_renderer_wgpu/src/lib.rs index f58e8bd..3296245 100644 --- a/lib/ui_renderer_wgpu/src/lib.rs +++ b/lib/ui_renderer_wgpu/src/lib.rs @@ -990,7 +990,9 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { bottom: physical.y - image.placement.top + height, }, content: image.content, - color: glyph.color_opt.map_or(text.default_color, color_from_cosmic), + color: glyph + .color_opt + .map_or(text.default_color, color_from_cosmic), data: image.data.clone(), }); } @@ -1470,7 +1472,11 @@ fn create_glyph_atlas( /// Resolve `Color::SENTINEL` to `default_color`; return other colors unchanged. #[inline] fn resolve_glyph_color(color: Color, default_color: Color) -> Color { - if color == Color::SENTINEL { default_color } else { color } + if color == Color::SENTINEL { + default_color + } else { + color + } } fn color_from_cosmic(color: cosmic_text::Color) -> Color { @@ -2297,7 +2303,7 @@ fn clip_textured_rect( /// return only those glyph indices. This turns O(all_glyphs) iteration into /// O(log(lines) + visible_glyphs) — critical for large text nodes in scroll /// boxes where only a few lines are on screen at once. -fn clip_visible_glyphs<'a>(text: &'a PreparedText, clip: Option) -> &'a [GlyphInstance] { +fn clip_visible_glyphs(text: &PreparedText, clip: Option) -> &[GlyphInstance] { let Some(clip) = clip else { return &text.glyphs; }; @@ -2332,7 +2338,12 @@ fn text_texture_key(text: &PreparedText) -> TextTextureKey { .map(|bounds| (bounds.width.to_bits(), bounds.height.to_bits())), font_size_bits: text.font_size.to_bits(), line_height_bits: text.line_height.to_bits(), - color: (text.default_color.r, text.default_color.g, text.default_color.b, text.default_color.a), + color: ( + text.default_color.r, + text.default_color.g, + text.default_color.b, + text.default_color.a, + ), glyphs: text .glyphs .iter()