From f83c5d3b0f88a24914c54ff906ec947fd3a659a3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 11 Oct 2026 16:18:21 +0700 Subject: [PATCH] fix(single-instance): propagate D-Bus setup and handoff errors (#3584) * fix(single-instance): propagate D-Bus setup and handoff errors * chore: satisfy release metadata and test header checks * fix(single-instance): launch normally without a D-Bus session bus Log a warning instead of failing setup when the session bus is unreachable, and add context to handoff errors. * ci: run the single-instance D-Bus test under dbus-run-session Run it in test-rust on Linux and keep it out of the updater integration job, which has no session bus. --------- Co-authored-by: Lucas Nogueira --- .changes/single-instance-dbus-errors.md | 5 + .github/workflows/integration-tests.yml | 4 +- .github/workflows/test-rust.yml | 7 +- plugins/single-instance/Cargo.toml | 3 + .../src/platform_impl/linux.rs | 39 +++- plugins/single-instance/tests/dbus_errors.rs | 192 ++++++++++++++++++ 6 files changed, 237 insertions(+), 13 deletions(-) create mode 100644 .changes/single-instance-dbus-errors.md create mode 100644 plugins/single-instance/tests/dbus_errors.rs diff --git a/.changes/single-instance-dbus-errors.md b/.changes/single-instance-dbus-errors.md new file mode 100644 index 000000000..9820c23d5 --- /dev/null +++ b/.changes/single-instance-dbus-errors.md @@ -0,0 +1,5 @@ +--- +"single-instance": patch +--- + +On Linux, a secondary instance that cannot deliver its arguments to the primary instance now returns a plugin initialization error instead of exiting successfully, and an invalid `dbus_id` returns an error instead of panicking. When the session bus is unavailable the app keeps launching normally, without single-instance support, and logs a warning. diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 92dc999d0..d12c5bcfd 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -137,4 +137,6 @@ jobs: # about a minute linking each of those builds. None of it matters to the updater. CARGO_PROFILE_RELEASE_LTO: 'false' CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' - run: cargo test --test '*' -- --ignored + # The single-instance D-Bus test is also ignored by default, but needs a session bus and + # runs in test-rust.yml instead. + run: cargo test --workspace --exclude tauri-plugin-single-instance --test '*' -- --ignored diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml index 97a6b825e..de3363d80 100644 --- a/.github/workflows/test-rust.yml +++ b/.github/workflows/test-rust.yml @@ -111,7 +111,7 @@ jobs: if: contains(matrix.platform.target, 'unknown-linux') run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev + sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev dbus - uses: dtolnay/rust-toolchain@1.90 with: @@ -174,6 +174,11 @@ jobs: # Only on the platforms we actually test on, since cargo skips doc tests when cross compiling. elif [ "$COMMAND" = test ] && ! $RUNNER test --doc --package "$package" --target "$TARGET" "${features[@]}"; then failed+=("$package (doc tests)") + # The D-Bus test is ignored by default because it needs its own session bus, and it + # is compiled out with `--all-features` (the `semver` feature changes the bus name). + elif [ "$package" = tauri-plugin-single-instance ] && [ "$COMMAND" = test ] && [[ "$TARGET" == *-linux-* ]] \ + && ! dbus-run-session -- $RUNNER test --package "$package" --target "$TARGET" --test dbus_errors -- --ignored; then + failed+=("$package (D-Bus tests)") fi echo "::endgroup::" done diff --git a/plugins/single-instance/Cargo.toml b/plugins/single-instance/Cargo.toml index c8d65c1cd..5ff98ee58 100644 --- a/plugins/single-instance/Cargo.toml +++ b/plugins/single-instance/Cargo.toml @@ -26,6 +26,9 @@ tauri-plugin-deep-link = { path = "../deep-link", version = "2.6.1", optional = semver = { version = "1", optional = true } tokio = { version = "1", features = ["net"] } +[dev-dependencies] +tauri = { workspace = true, features = ["test"] } + [target."cfg(target_os = \"windows\")".dependencies.windows-sys] version = "0.61" features = [ diff --git a/plugins/single-instance/src/platform_impl/linux.rs b/plugins/single-instance/src/platform_impl/linux.rs index 9ddadafbc..833687c78 100644 --- a/plugins/single-instance/src/platform_impl/linux.rs +++ b/plugins/single-instance/src/platform_impl/linux.rs @@ -53,22 +53,30 @@ pub fn init( app_handle: app.clone(), }; - match zbus::blocking::connection::Builder::session() - .unwrap() - .name(dbus_name.as_str()) - .unwrap() + let builder = match zbus::blocking::connection::Builder::session() { + Ok(builder) => builder, + Err(error) => { + tracing::warn!( + "single-instance: invalid D-Bus session address, launching normally: {error}" + ); + return Ok(()); + } + }; + + match builder + .name(dbus_name.as_str())? .replace_existing_names(false) .allow_name_replacements(false) - .serve_at(dbus_path.as_str(), single_instance_dbus) - .unwrap() + .serve_at(dbus_path.as_str(), single_instance_dbus)? .build() { Ok(connection) => { app.manage(ConnectionHandle(connection)); } Err(zbus::Error::NameTaken) => { - if let Ok(connection) = Connection::session() { - let _ = connection.call_method( + let connection = Connection::session()?; + connection + .call_method( Some(dbus_name.as_str()), dbus_path.as_str(), Some("org.SingleInstance.DBus"), @@ -82,12 +90,21 @@ pub fn init( .to_string_lossy() .as_ref(), ), - ); - } + ) + .map_err(|error| { + format!("failed to forward arguments to the running instance: {error}") + })?; + // Exit successfully only after the primary accepted the arguments. app.cleanup_before_exit(); std::process::exit(0); } - _ => {} + Err(error) => { + // Without a session bus there is no way to find other instances. + tracing::warn!( + "single-instance: D-Bus session bus unavailable, launching normally: {error}" + ); + return Ok(()); + } } app.manage(DBusName(dbus_name)); diff --git a/plugins/single-instance/tests/dbus_errors.rs b/plugins/single-instance/tests/dbus_errors.rs new file mode 100644 index 000000000..8ef294e54 --- /dev/null +++ b/plugins/single-instance/tests/dbus_errors.rs @@ -0,0 +1,192 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +#![cfg(all( + any(target_os = "linux", target_os = "freebsd"), + not(feature = "semver") +))] + +use std::{ + env, fs, + path::PathBuf, + process::{Child, Command, Output, Stdio}, + sync::mpsc, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tauri::test::{mock_builder, mock_context, noop_assets}; + +const TEST_NAME: &str = "dbus_setup_errors"; +const ID_ENV: &str = "TAURI_DBUS_ERROR_ID"; +const SETUP_ERROR: &str = "SETUP_ERROR:"; +const STARTED: &str = "STARTED"; + +struct RejectCallback(mpsc::Sender<()>); + +#[zbus::interface(name = "org.SingleInstance.DBus")] +impl RejectCallback { + fn execute_callback(&self, _argv: Vec, _cwd: String) -> zbus::fdo::Result<()> { + self.0.send(()).unwrap(); + Err(zbus::fdo::Error::Failed( + "test primary rejected delivery".into(), + )) + } +} + +struct Sandbox(PathBuf); +impl Drop for Sandbox { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +struct TestChild(Option); +impl TestChild { + fn spawn(id: &str, address: &str) -> Self { + Self(Some( + Command::new(env::current_exe().unwrap()) + .args(["--ignored", "--exact", TEST_NAME, "--nocapture"]) + .env(ID_ENV, id) + .env("DBUS_SESSION_BUS_ADDRESS", address) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(), + )) + } + + fn wait(mut self) -> Output { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if self.0.as_mut().unwrap().try_wait().unwrap().is_some() { + return self.0.take().unwrap().wait_with_output().unwrap(); + } + assert!(Instant::now() < deadline, "D-Bus test child timed out"); + thread::sleep(Duration::from_millis(10)); + } + } +} +impl Drop for TestChild { + fn drop(&mut self) { + if let Some(child) = self.0.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn assert_setup_error(output: Output) -> String { + assert_eq!(output.status.code(), Some(101)); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains(SETUP_ERROR), "{stdout}"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("PluginInitialization(\"single-instance\""), + "{stderr}" + ); + stdout +} + +fn assert_started(output: Output) { + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains(STARTED), "{stdout}"); +} + +#[test] +#[ignore = "requires an explicit dbus-run-session invocation"] +fn dbus_setup_errors() -> Result<(), tauri::Error> { + if let Ok(id) = env::var(ID_ENV) { + let result = mock_builder() + .plugin( + tauri_plugin_single_instance::Builder::new() + .dbus_id(id) + .build(), + ) + .build(mock_context(noop_assets())); + return match result { + // Without a usable session bus the app must still launch. + Ok(app) => { + println!("{STARTED}"); + tauri_plugin_single_instance::destroy(&app); + Ok(()) + } + Err(tauri::Error::PluginInitialization(plugin, message)) => { + assert_eq!(plugin, "single-instance"); + println!("{SETUP_ERROR} {message}"); + Err(tauri::Error::PluginInitialization(plugin, message)) + } + Err(error) => panic!("unexpected error: {error}"), + }; + } + + let address = + env::var("DBUS_SESSION_BUS_ADDRESS").expect("run this test inside dbus-run-session"); + let id = format!("org.tauri.DBusErrorTest.p{}", std::process::id()); + let root = env::temp_dir().join(format!( + "tauri-dbus-error-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir(&root).unwrap(); + let sandbox = Sandbox(root); + let missing_address = format!("unix:path={}", sandbox.0.join("missing.sock").display()); + assert_started(TestChild::spawn(&id, &missing_address).wait()); + assert_started(TestChild::spawn(&id, "invalid-address").wait()); + assert_setup_error(TestChild::spawn("invalid dbus id", &address).wait()); + + // A real endpoint rejects delivery immediately, without bus timeouts or startup races. + let name = format!("{id}.SingleInstance"); + let path = format!("/{}", name.replace('.', "/")); + let (tx, rx) = mpsc::channel(); + let owner = zbus::blocking::connection::Builder::session() + .unwrap() + .name(name.as_str()) + .unwrap() + .serve_at(path.as_str(), RejectCallback(tx)) + .unwrap() + .build() + .unwrap(); + let failure = assert_setup_error(TestChild::spawn(&id, &address).wait()); + assert!(failure.contains("test primary rejected delivery")); + rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty))); + drop(owner); + + // The successful primary and secondary paths still use the real plugin and bus. + let (tx, rx) = mpsc::channel(); + let app = mock_builder() + .plugin( + tauri_plugin_single_instance::Builder::new() + .dbus_id(format!("{id}.Success")) + .callback(move |_, args, cwd| tx.send((args, cwd)).unwrap()) + .build(), + ) + .build(mock_context(noop_assets()))?; + let secondary = TestChild::spawn(&format!("{id}.Success"), &address).wait(); + assert!(secondary.status.success()); + assert!( + !String::from_utf8(secondary.stdout) + .unwrap() + .contains(STARTED) + ); + let (args, cwd) = rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!( + args, + [ + env::current_exe().unwrap().to_str().unwrap(), + "--ignored", + "--exact", + TEST_NAME, + "--nocapture" + ] + ); + assert_eq!(cwd, env::current_dir().unwrap().to_str().unwrap()); + assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty))); + tauri_plugin_single_instance::destroy(&app); + Ok(()) +}