Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ keyring = { version = "3.6.3", default-features = false, features = ["sync-secre
# connection is dropped, which the plugin does immediately. Default features
# keep the pure-Rust zbus backend, matching the plugin (no libdbus needed).
notify-rust = "4"
# The renderer recovery ladder's crash edge. `WebView::connect_web_process_terminated`
# is gated on `v2_20`; wry already enables `v2_40` on the same crate version, so
# this adds no native dependency beyond the webkit2gtk wry links against.
webkit2gtk = { version = "2.0.2", features = ["v2_40"] }
# Session-bus observation of the single-instance well-known name, at the version
# tauri-plugin-single-instance already resolves, so both share one zbus stack.
zbus = "5.17"

[target.'cfg(target_os = "macos")'.dependencies]
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback"] }
Expand Down
39 changes: 38 additions & 1 deletion desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ mod prevent_sleep;
mod ptt_shortcut;
mod relay;
mod relay_admission;
#[cfg(target_os = "linux")]
mod render_recovery;
mod reset;
mod secret_store;
mod shutdown;
Expand Down Expand Up @@ -135,6 +137,33 @@ async fn wait_for_stable_initial_window_geometry<R: tauri::Runtime>(window: &tau

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let context = tauri::generate_context!();

// Linux renderer recovery runs before anything else: it may hand this
// launch off to a child carrying a different WebKit environment, which is
// only safe while the process is still single threaded and owns no
// single-instance name.
#[cfg(target_os = "linux")]
let render_recovery_session = match render_recovery::boot(
&context.config().identifier,
&context.package_info().version.to_string(),
) {
render_recovery::Boot::Run(session) => Some(session),
// Recovery is off for this launch; the app still starts normally.
render_recovery::Boot::Off(_) => None,
// A child is Buzz now, --reset-rendering-mode did its work, or another
// process owns this episode and this one must not compete for the name.
render_recovery::Boot::HandedOff
| render_recovery::Boot::Reset
| render_recovery::Boot::Superseded => return,
// The user asked for something that cannot be delivered. Say so and
// exit non-zero rather than starting an app that ignores the request.
render_recovery::Boot::Fatal(diagnostic) => {
eprintln!("buzz-desktop: {diagnostic}");
std::process::exit(1);
}
};

// mesh-llm's async chains (model download, node start/join) overflow
// tokio's default 2 MiB worker stacks — a stack-guard SIGABRT, not a
// panic. Upstream mesh-llm and mesh-console both run on 8 MiB worker
Expand Down Expand Up @@ -342,6 +371,14 @@ pub fn run() {
#[cfg(not(buzz_updater_enabled))]
let builder = builder;

// Registered after the single-instance plugin so the well-known name is
// already held when the ladder writes its `owned` receipt.
#[cfg(target_os = "linux")]
let builder = match render_recovery_session {
Some(session) => builder.plugin(render_recovery::plugin(session)),
None => builder,
};

let app = builder
.register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| {
let app = ctx.app_handle().clone();
Expand Down Expand Up @@ -915,7 +952,7 @@ pub fn run() {
is_auto_update_supported,
set_window_vibrancy,
])
.build(tauri::generate_context!())
.build(context)
.expect("error while building tauri application");

let shutdown_done = Arc::new(AtomicBool::new(false));
Expand Down
172 changes: 172 additions & 0 deletions desktop/src-tauri/src/render_recovery/classify.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//! Turning a durable record plus a bus observation into a boot decision.
//!
//! Pure over its inputs — the bus observation and process liveness are passed
//! in — so every arm of the decision table is a unit test rather than a
//! process-orchestration exercise.

use super::dbus::Observation;
use super::state::{Phase, Record, RETRY_GENERATION};

/// How the current name owner relates to the child the record names.
///
/// The `Uncorrelatable` arm is the one that is easy to get wrong: an owner that
/// exists but cannot be tied to our record is a handoff failure and a
/// diagnostic, and it is *never* evidence that the name is free.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Ownership {
/// Nobody owns the name.
Absent,
/// The owner is the child this record names.
Correlated,
/// A different process owns the name.
Mismatched,
/// Somebody owns the name but the record cannot be matched against them —
/// no recorded identity, no owner pid, or a receipt from another bus.
Uncorrelatable,
}

/// What a fresh, untagged process should do about the record it found.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Decision {
/// A live episode owns this app right now; this process is a duplicate.
Defer(&'static str),
/// Re-run `tier` under the same token, one generation on.
RetrySameTier { reason: &'static str, tier: usize },
/// The attempt at `tier` failed; move one rung down, or stop if `tier` was
/// the last one.
AdvanceOrStop { reason: &'static str, tier: usize },
/// A tier started cleanly under this version. Run it again.
ReuseProfile { tier: usize },
/// The ladder is spent. Run `tier` and stop — no advance, no episode.
StopExhausted { tier: usize },
/// The record is unusable; delete it and launch from the baseline.
DiscardAndBaseline { reason: &'static str },
}

/// Correlate the recorded child against what the bus reports.
pub(crate) fn correlate(
record: &Record,
observation: &Observation,
bus_id: Option<&str>,
) -> Ownership {
let owner = match observation {
Observation::Free => return Ownership::Absent,
// An unreachable bus proves nothing about the name. Treat it as an
// owner we cannot identify rather than as a free name.
Observation::Unavailable(_) => return Ownership::Uncorrelatable,
Observation::Owned(owner) => owner,
};

match record.phase {
// `owned` and later carry a unique name, which is the precise
// identity — but only on the bus that issued it.
Phase::Owned | Phase::Confirmed => match (&record.unique_name, &record.bus_id) {
(Some(unique_name), Some(recorded_bus)) => {
if bus_id != Some(recorded_bus.as_str()) {
Ownership::Uncorrelatable
} else if *unique_name == owner.unique_name {
Ownership::Correlated
} else {
Ownership::Mismatched
}
}
_ => Ownership::Uncorrelatable,
},
// Before ownership the only identity is the pid.
_ => match (record.pid, owner.pid) {
(Some(recorded), Some(actual)) if recorded == actual => Ownership::Correlated,
(Some(_), Some(_)) => Ownership::Mismatched,
_ => Ownership::Uncorrelatable,
},
}
}

/// Decide what to do with `record`.
///
/// `pid_alive` answers whether a recorded pre-ownership child is still running;
/// `version` is the current app version, which invalidates a persisted profile
/// across an upgrade.
pub(crate) fn decide(
record: &Record,
observation: &Observation,
bus_id: Option<&str>,
version: &str,
pid_alive: &dyn Fn(u32) -> bool,
) -> Decision {
if record.version != version {
return Decision::DiscardAndBaseline {
reason: "app version changed since the record was written",
};
}

let ownership = correlate(record, observation, bus_id);
let owner_present = !matches!(observation, Observation::Free);

match record.phase {
// Terminal. Re-running the ladder from here would loop forever
// against a machine no profile satisfies.
Phase::Exhausted => Decision::StopExhausted { tier: record.tier },

// Nobody ever claimed this attempt: the child died before its first
// action, or never started. One retry, same tier, next generation.
Phase::Prepared if record.generation < RETRY_GENERATION => Decision::RetrySameTier {
reason: "prepared attempt was never claimed",
tier: record.tier,
},
Phase::Prepared => Decision::AdvanceOrStop {
reason: "prepared attempt was never claimed and the retry is spent",
tier: record.tier,
},

// `started` is the bounded window between the claim and name
// acquisition. Correlation against the *recorded* child is what
// separates an episode in flight from a handoff failure.
Phase::Started => match ownership {
Ownership::Correlated => Decision::Defer("recovery child owns the name"),
Ownership::Mismatched => Decision::AdvanceOrStop {
reason: "a different process owns the name: handoff failed",
tier: record.tier,
},
Ownership::Uncorrelatable if owner_present => Decision::AdvanceOrStop {
reason: "the name has an owner that cannot be correlated: handoff failed",
tier: record.tier,
},
// Name unowned: the child is either still in the pre-ownership
// interval or gone.
_ => match record.pid {
Some(pid) if pid_alive(pid) => {
Decision::Defer("recovery child is alive in the pre-ownership interval")
}
_ => Decision::AdvanceOrStop {
reason: "recovery child is gone before acquiring the name",
tier: record.tier,
},
},
},

// `owned` means the child really did take over. Only the same
// connection still holding the name keeps the episode in flight.
Phase::Owned => match ownership {
Ownership::Correlated => Decision::Defer("recovery child still owns the name"),
Ownership::Mismatched => Decision::AdvanceOrStop {
reason: "the recorded owner was replaced by another process",
tier: record.tier,
},
Ownership::Uncorrelatable if owner_present => Decision::AdvanceOrStop {
reason: "the name owner cannot be correlated to the recorded child",
tier: record.tier,
},
_ => Decision::AdvanceOrStop {
reason: "the recorded owner released the name",
tier: record.tier,
},
},

// A tier that reached the startup boundary. This is the persisted
// "last crash-free startup profile" fact.
Phase::Confirmed => match ownership {
Ownership::Correlated => Decision::Defer("the confirmed instance is still running"),
_ => Decision::ReuseProfile { tier: record.tier },
},
}
}
28 changes: 28 additions & 0 deletions desktop/src-tauri/src/render_recovery/cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//! The two renderer flags.
//!
//! Parsed from raw argv before Tauri starts, because the tier they select has
//! to be in the process environment before WebKit initializes.

/// Force the safest profile for this launch only. The ladder does not advance
/// and the persisted record is untouched.
pub(crate) const SAFE_RENDERING: &str = "--safe-rendering";
/// Delete the persisted profile and episode state, report it, and exit.
pub(crate) const RESET_RENDERING_MODE: &str = "--reset-rendering-mode";

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Flags {
pub safe_rendering: bool,
pub reset_rendering_mode: bool,
}

pub(crate) fn parse<'a>(args: impl IntoIterator<Item = &'a std::ffi::OsStr>) -> Flags {
let mut flags = Flags::default();
for arg in args {
match arg.to_str() {
Some(SAFE_RENDERING) => flags.safe_rendering = true,
Some(RESET_RENDERING_MODE) => flags.reset_rendering_mode = true,
_ => {}
}
}
flags
}
89 changes: 89 additions & 0 deletions desktop/src-tauri/src/render_recovery/dbus.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//! Facts about the single-instance well-known name, read from the session bus.
//!
//! This module only *observes*; correlating an observation against a recorded
//! child is `classify`'s job. The distinction matters because "somebody owns
//! the name" is never evidence that our recovery child survived, and "the bus
//! did not answer" is never evidence that the name is free.

/// The single-instance name `tauri-plugin-single-instance` registers: the app
/// identifier plus `.SingleInstance` (the `semver` feature, which would append
/// a version suffix, is not enabled in this build).
pub(crate) fn single_instance_name(identifier: &str) -> String {
format!("{identifier}.SingleInstance")
}

/// Who currently holds the well-known name.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Owner {
pub unique_name: String,
/// `None` when the bus refused `GetConnectionUnixProcessID` — the owner
/// exists but cannot be identified.
pub pid: Option<u32>,
}

/// What the bus said about the name.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Observation {
/// `GetNameOwner` reported no owner. The only state in which a handoff may
/// proceed.
Free,
Owned(Owner),
/// The bus could not be reached or the call failed. Deliberately distinct
/// from `Free`.
Unavailable(String),
}

/// Identity of the session bus itself. A unique name like `:1.4` is only
/// meaningful against the bus that issued it, so `owned` receipts are bound to
/// this value as well as to the unique name.
pub(crate) fn bus_id() -> Option<String> {
call::<String>("GetId", &())
}

/// Observe the current owner of `name`.
pub(crate) fn observe(name: &str) -> Observation {
let connection = match zbus::blocking::Connection::session() {
Ok(connection) => connection,
Err(error) => return Observation::Unavailable(format!("session bus: {error}")),
};
match connection.call_method(
Some("org.freedesktop.DBus"),
"/org/freedesktop/DBus",
Some("org.freedesktop.DBus"),
"GetNameOwner",
&name,
) {
Ok(reply) => match reply.body().deserialize::<String>() {
Ok(unique_name) => {
let pid = call::<u32>("GetConnectionUnixProcessID", &unique_name);
Observation::Owned(Owner { unique_name, pid })
}
Err(error) => Observation::Unavailable(format!("GetNameOwner reply: {error}")),
},
// The bus answers NameHasNoOwner as an error reply; that is the one
// failure that genuinely means "free".
Err(zbus::Error::MethodError(name, _, _))
if name.as_str() == "org.freedesktop.DBus.Error.NameHasNoOwner" =>
{
Observation::Free
}
Err(error) => Observation::Unavailable(format!("GetNameOwner: {error}")),
}
}

fn call<T>(method: &str, body: &(impl serde::Serialize + zbus::zvariant::DynamicType)) -> Option<T>
where
T: for<'d> zbus::zvariant::Type + serde::de::DeserializeOwned,
{
let connection = zbus::blocking::Connection::session().ok()?;
let reply = connection
.call_method(
Some("org.freedesktop.DBus"),
"/org/freedesktop/DBus",
Some("org.freedesktop.DBus"),
method,
body,
)
.ok()?;
reply.body().deserialize::<T>().ok()
}
Loading
Loading