From 92c2e2ffc0621974aa8d37406c7c950d6e57e87b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 31 Aug 2026 16:20:23 +1000 Subject: [PATCH] fix(desktop): start the Canvas update socket inside the async runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app aborted on every macOS and Linux launch. `ipc::start` runs synchronously from Tauri's `setup` hook, on the main thread with no Tokio runtime in context, and called `tokio::net::UnixListener::from_std` there. That call registers the socket with the reactor, so it panicked with "there is no reactor running" — and because `setup` runs inside tao's `did_finish_launching`, a non-unwinding ObjC callback, the panic escalated to `panic_cannot_unwind` and killed the process before a window ever appeared. Bind the socket synchronously as before (so stale-socket and permission failures still surface as a returned error), then hand the std listener to `spawn_serving`, which converts it inside the spawned task where the reactor is live. Socket cleanup on a failed conversion matches the existing accept error path. Covered by a unit test that calls `spawn_serving` from a plain sync context, exactly as `setup` does, and asserts the socket still accepts a connection; it reproduces the original panic when the conversion moves back to the call site. Verified with `just staging`: the app now boots past `did_finish_launching`, creates `CANVASES/.runtime/agent-updates.sock` with 0600 permissions, and reaches the frontend. Full Tauri test suite (3031), clippy, fmt, and the file-size gate pass. Co-Authored-By: Claude Opus 5 Signed-off-by: Matt Toohey --- .../src/project_canvas_package/ipc.rs | 36 ++++++++++++++++--- .../src/project_canvas_package/tests.rs | 29 +++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/project_canvas_package/ipc.rs b/desktop/src-tauri/src/project_canvas_package/ipc.rs index af897680e6..a81d9c80a7 100644 --- a/desktop/src-tauri/src/project_canvas_package/ipc.rs +++ b/desktop/src-tauri/src/project_canvas_package/ipc.rs @@ -3,7 +3,7 @@ use std::{ fs, os::unix::{ fs::{FileTypeExt, PermissionsExt}, - net::UnixStream as StdUnixStream, + net::{UnixListener as StdUnixListener, UnixStream as StdUnixStream}, }, path::PathBuf, time::Duration, @@ -48,19 +48,45 @@ pub(super) fn start(app: AppHandle) -> Result<(), String> { .map_err(|error| format!("remove stale project canvas update socket: {error}"))?; } - let std_listener = std::os::unix::net::UnixListener::bind(&socket_path) + let std_listener = StdUnixListener::bind(&socket_path) .map_err(|error| format!("bind project canvas update socket: {error}"))?; fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600)) .map_err(|error| format!("secure project canvas update socket: {error}"))?; std_listener .set_nonblocking(true) .map_err(|error| format!("configure project canvas update socket: {error}"))?; - let listener = tokio::net::UnixListener::from_std(std_listener) - .map_err(|error| format!("start project canvas update socket: {error}"))?; - tauri::async_runtime::spawn(run(listener, app, socket_path)); + let serve_path = socket_path.clone(); + spawn_serving(std_listener, socket_path, move |listener| { + run(listener, app, serve_path) + }); Ok(()) } +/// Hand a bound socket to the async runtime and serve it there. +/// +/// `tokio::net::UnixListener::from_std` registers the socket with the Tokio +/// reactor, so it only works from inside the runtime. `start` runs on the main +/// thread from Tauri's `setup` hook, which is not in runtime context — doing +/// the conversion there panics, and the panic crosses the non-unwinding +/// `did_finish_launching` boundary, aborting the app before it opens a window. +/// So the conversion has to happen in the spawned task, not at the call site. +#[cfg(unix)] +pub(super) fn spawn_serving(std_listener: StdUnixListener, socket_path: PathBuf, serve: F) +where + F: FnOnce(tokio::net::UnixListener) -> Fut + Send + 'static, + Fut: std::future::Future + Send + 'static, +{ + tauri::async_runtime::spawn(async move { + match tokio::net::UnixListener::from_std(std_listener) { + Ok(listener) => serve(listener).await, + Err(error) => { + eprintln!("buzz-desktop: project Canvas update socket stopped: {error}"); + let _ = fs::remove_file(&socket_path); + } + } + }); +} + #[cfg(not(unix))] pub(super) fn start(_app: tauri::AppHandle) -> Result<(), String> { Ok(()) diff --git a/desktop/src-tauri/src/project_canvas_package/tests.rs b/desktop/src-tauri/src/project_canvas_package/tests.rs index 41a620961b..8a8c84b1a7 100644 --- a/desktop/src-tauri/src/project_canvas_package/tests.rs +++ b/desktop/src-tauri/src/project_canvas_package/tests.rs @@ -655,3 +655,32 @@ fn native_navigation_policy_blocks_external_document_navigation() { &"file:///tmp/secret".parse().unwrap() )); } + +// `start` runs on the main thread from Tauri's `setup` hook, with no Tokio +// runtime in context. Registering the socket with the reactor there aborts the +// whole app on launch, so the handoff has to survive a plain sync caller. +#[cfg(unix)] +#[test] +fn agent_update_socket_serves_when_started_outside_the_async_runtime() { + use std::{ + os::unix::net::{UnixListener as StdUnixListener, UnixStream as StdUnixStream}, + sync::mpsc, + time::Duration, + }; + + let temp = TempDir::new().unwrap(); + let socket_path = temp.path().join("agent-updates.sock"); + let listener = StdUnixListener::bind(&socket_path).unwrap(); + listener.set_nonblocking(true).unwrap(); + + let (accepted, wait) = mpsc::channel(); + super::ipc::spawn_serving(listener, socket_path.clone(), move |listener| async move { + if listener.accept().await.is_ok() { + let _ = accepted.send(()); + } + }); + + let _client = StdUnixStream::connect(&socket_path).unwrap(); + wait.recv_timeout(Duration::from_secs(5)) + .expect("socket bound before the runtime handoff should still accept connections"); +}