fix(desktop): start the Canvas update socket inside the async runtime

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-08-31 16:20:23 +10:00
parent 3eb9a239bb
commit 92c2e2ffc0
2 changed files with 60 additions and 5 deletions

View File

@@ -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<F, Fut>(std_listener: StdUnixListener, socket_path: PathBuf, serve: F)
where
F: FnOnce(tokio::net::UnixListener) -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + 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(())

View File

@@ -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");
}