Skip to content
Merged
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default defineConfig({
"**/video-attachment.spec.ts",
"**/mentions.spec.ts",
"**/relay-reconnect.spec.ts",
"**/relay-reconnect-screenshots.spec.ts",
"**/workflows.spec.ts",
"**/identity-archive.spec.ts",
"**/identity-archive-hide.spec.ts",
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ default = []
mesh-llm = ["dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime"]

[build-dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-build = { version = "2", features = [] }

[target.'cfg(unix)'.dependencies]
Expand Down
14 changes: 14 additions & 0 deletions desktop/src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
// Shared schema, included from the same source the runtime command parses with,
// so the build-time validation below and the runtime parse cannot drift.
include!("src/commands/reconnect_hook_config.rs");

fn main() {
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_URL");
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP");
println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_PUBLIC_KEY");
println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_ENDPOINT");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DATABRICKS_HOST");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DATABRICKS_MODEL");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD");
println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)");

if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") {
Expand All @@ -23,6 +28,15 @@ fn main() {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_DATABRICKS_MODEL={model}");
}

if let Ok(val) = std::env::var("BUZZ_BUILD_RELAY_RECONNECT_CMD") {
let parsed: serde_json::Value = serde_json::from_str(&val)
.unwrap_or_else(|e| panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD is not valid JSON: {e}"));
serde_json::from_value::<ReconnectHookConfig>(parsed).unwrap_or_else(|e| {
panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD doesn't match ReconnectHookConfig: {e}")
});
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}");
}

let updater_public_key = std::env::var("BUZZ_UPDATER_PUBLIC_KEY")
.ok()
.map(|value| value.trim().to_string())
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ mod personas;
mod prevent_sleep;
mod profile;
mod relay_members;
mod relay_reconnect;
mod social;
mod teams;
mod workflows;
Expand Down Expand Up @@ -49,6 +50,7 @@ pub use personas::*;
pub use prevent_sleep::*;
pub use profile::*;
pub use relay_members::*;
pub use relay_reconnect::*;
pub use social::*;
pub use teams::*;
pub use workflows::*;
Expand Down
27 changes: 27 additions & 0 deletions desktop/src-tauri/src/commands/reconnect_hook_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Canonical `ReconnectHookConfig` definition, `include!`d into BOTH
// `build.rs` (compile-time validation) and `commands/relay_reconnect.rs`
// (runtime deserialization). build scripts cannot import from the crate, so
// sharing the source via `include!` is what guarantees the build-time check
// and the runtime parse use an identical schema — zero drift surface.
//
// Keep this file dependency-free: only `serde` derives, no crate-internal
// imports. Both consumers have `serde` available.

/// Typed config carried by the build-time env var `BUZZ_BUILD_RELAY_RECONNECT_CMD`.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)] // build.rs only constructs it for validation
pub struct ReconnectHookConfig {
/// Ordered commands to run. Each inner vec is [program, arg1, arg2, ...].
pub steps: Vec<Vec<String>>,
/// Command whose stdout is polled for readiness after steps complete.
pub ready_probe: Vec<String>,
/// RAW SUBSTRING matched in the probe's stdout to consider the transport
/// ready — NOT a parsed field. Pick a token unlikely to collide with other
/// substrings in the probe output (e.g. `warp-cli -j status` JSON, where
/// "Connected" can collide with "Connecting"/"Disconnected").
pub ready_match: String,
/// Per-process wall-clock cap (ms) for each step and the readiness probe
/// phase; non-fatal on expiry — the generic relay reconnect still fires.
pub timeout_ms: u64,
}
127 changes: 127 additions & 0 deletions desktop/src-tauri/src/commands/relay_reconnect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//! Configurable transport-reconnect hook.
//!
//! When the build-time env var `BUZZ_BUILD_RELAY_RECONNECT_CMD` is set (internal
//! builds), this command runs an ordered sequence of subprocess steps followed by
//! a readiness poll before the frontend fires the relay WebSocket reconnect.
//!
//! OSS builds (env var unset) get a pure no-op — zero WARP knowledge compiled in.

// Single source of truth for the config schema, shared with build.rs via
// `include!`. See reconnect_hook_config.rs for why this is shared, not a module.
include!("reconnect_hook_config.rs");

#[tauri::command]
pub async fn relay_reconnect_hook() -> Result<(), String> {
let Some(config_str) = option_env!("BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD") else {
return Ok(()); // OSS build — no-op
};

// Safe: build.rs already validated this parses correctly against the same schema.
let config: ReconnectHookConfig = serde_json::from_str(config_str)
.map_err(|e| format!("reconnect hook config parse error: {e}"))?;

// spawn_blocking because the desktop Tauri crate doesn't enable tokio's
// `process` feature; std::process::Command + thread::sleep are synchronous
// and must not run on an async worker. The whole hook is non-fatal — a join
// failure logs and returns Ok so the frontend's relay reconnect still fires.
if let Err(e) = tokio::task::spawn_blocking(move || run_hook(&config)).await {
eprintln!("[relay_reconnect_hook] task join failed: {e}");
}

Ok(())
}

/// Run a fixed-argv command (`argv[0]` + `argv[1..]`) with a wall-clock cap.
///
/// `std::process::Command::output()` blocks until the child exits — a wedged
/// `warp-cli` (the exact degraded-transport case this hook targets) would hang
/// forever, pinning the blocking-pool thread and leaving the frontend `invoke`
/// unresolved. So we spawn, poll `try_wait()` every 500ms, and kill+reap on the
/// deadline. Modeled on `media_transcode.rs` `run_ffmpeg_with_timeout`.
///
/// stdout/stderr are piped and read only after the child exits. The pipe-buffer
/// deadlock noted there (a child blocking on write() when the ~64 KiB OS pipe
/// fills) does not apply: `warp-cli` emits a few lines, far below the buffer.
fn run_with_timeout(
argv: &[String],
timeout: std::time::Duration,
) -> Result<std::process::Output, String> {
let mut child = std::process::Command::new(&argv[0])
.args(&argv[1..])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("spawn failed: {e}"))?;

let deadline = std::time::Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => {
let stdout = child.stdout.take().map_or_else(Vec::new, |mut s| {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut s, &mut buf);
buf
});
return Ok(std::process::Output {
status,
stdout,
stderr: Vec::new(),
});
}
Ok(None) => {
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait(); // reap zombie
return Err(format!("timed out after {}ms", timeout.as_millis()));
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
Err(e) => return Err(format!("wait failed: {e}")),
}
}
}

/// Runs the configured steps then polls the readiness probe. Every failure is
/// logged and swallowed — the caller treats the hook as best-effort.
fn run_hook(config: &ReconnectHookConfig) {
let cap = std::time::Duration::from_millis(config.timeout_ms);

// Run each step sequentially (fixed-argv, no shell). Each step is capped at
// `timeout_ms` so a hung child can't stall the whole hook — on cap it's
// killed, logged, and we move on, same non-fatal contract as a spawn error.
for step in &config.steps {
if step.is_empty() {
continue;
}
match run_with_timeout(step, cap) {
Ok(o) if !o.status.success() => {
eprintln!("[relay_reconnect_hook] step {:?} exited {}", step, o.status);
}
Err(e) => {
eprintln!("[relay_reconnect_hook] step {:?} failed: {e}", step);
}
_ => {}
}
}

// Poll readiness probe until match or timeout.
if config.ready_probe.is_empty() {
return;
}
let deadline = std::time::Instant::now() + cap;
while std::time::Instant::now() < deadline {
// Cap each probe at the time left to the deadline, so one wedged probe
// can't push the total probe phase past `timeout_ms`.
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
match run_with_timeout(&config.ready_probe, remaining) {
Ok(output) if String::from_utf8_lossy(&output.stdout).contains(&config.ready_match) => {
return;
}
Err(e) => {
eprintln!("[relay_reconnect_hook] probe failed: {e}");
}
_ => {}
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,7 @@ pub fn run() {
get_active_workspace,
set_prevent_sleep_active,
get_agent_memory,
relay_reconnect_hook,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");
Expand Down
60 changes: 37 additions & 23 deletions desktop/src/features/sidebar/ui/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
MessageCirclePlus,
Zap,
} from "lucide-react";
import {
isRelayConnectionDegraded,
useRelayConnection,
} from "@/shared/api/useRelayConnection";
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
import {
isRelayUnreachableError,
Expand Down Expand Up @@ -282,6 +286,18 @@ export function AppSidebar({

const { isPending: isReconnectPending, reconnect } = useReconnectRelay();

// The sidebar reconnect prompt must surface the moment the relay drops, not
// only after `channelsQuery` finally errors (it has a 60s staleTime +
// refetchInterval, so the error lags 60-120s behind a dropped socket).
// OR-in the live, debounced connection state — same signal that drives
// ConnectionBanner — so the prompt flips within ~2s of degradation.
const relayConnectionState = useRelayConnection();
const hasRelayUnreachableError = errorMessage
? isRelayUnreachableError(errorMessage)
: false;
const isRelayConnectionDegradedNow =
hasRelayUnreachableError || isRelayConnectionDegraded(relayConnectionState);

const [createSectionState, setCreateSectionState] = React.useState<{
open: boolean;
pendingChannelId: string | null;
Expand Down Expand Up @@ -722,30 +738,28 @@ export function AppSidebar({
</>
) : null}

{errorMessage ? (
isRelayUnreachableError(errorMessage) ? (
<div
className="px-3 py-2 text-sm"
data-testid="sidebar-relay-unreachable"
{isRelayConnectionDegradedNow ? (
<div
className="px-3 py-2 text-sm"
data-testid="sidebar-relay-unreachable"
>
<span className="text-muted-foreground">
{RELAY_UNREACHABLE_SHORT}{" "}
</span>
<button
className="text-primary hover:underline disabled:opacity-50"
data-testid="sidebar-reconnect"
disabled={isReconnectPending}
onClick={() => void reconnect()}
type="button"
>
<span className="text-muted-foreground">
{RELAY_UNREACHABLE_SHORT}{" "}
</span>
<button
className="text-primary hover:underline disabled:opacity-50"
data-testid="sidebar-reconnect"
disabled={isReconnectPending}
onClick={() => void reconnect()}
type="button"
>
{isReconnectPending ? "Reconnecting…" : "Reconnect"}
</button>
</div>
) : (
<div className="px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)
{isReconnectPending ? "Reconnecting…" : "Reconnect"}
</button>
</div>
) : errorMessage ? (
<div className="px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
</SidebarContent>

Expand Down
16 changes: 15 additions & 1 deletion desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import type { Workspace } from "@/features/workspaces/types";
import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher";
import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types";
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
import {
isRelayConnectionDegraded,
useRelayConnection,
} from "@/shared/api/useRelayConnection";
import { cn } from "@/shared/lib/cn";

type SidebarProfileCardProps = {
Expand Down Expand Up @@ -54,6 +58,12 @@ export function SidebarProfileCard({
// workspace-provider and QueryClient safe at this level.
const selfProfileCache = useSelfProfileCache();
const { isPending, reconnect } = useReconnectRelay();
// Only offer reconnect when the relay is actually degraded — keep the item
// visible while a reconnect is in flight so it does not vanish mid-click if
// the live state briefly flips.
const isRelayConnectionDegradedNow = isRelayConnectionDegraded(
useRelayConnection(),
);

const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
const profileCardRef = React.useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -134,7 +144,11 @@ export function SidebarProfileCard({
isStatusPending={isPresencePending}
onClearUserStatus={onClearUserStatus}
onOpenSettings={onOpenSettings}
onReconnect={() => void reconnect()}
onReconnect={
isRelayConnectionDegradedNow || isPending
? () => void reconnect()
: undefined
}
onSetStatus={onSetPresenceStatus ?? (() => {})}
onSetUserStatus={onSetUserStatus}
triggerContainerRef={profileCardRef}
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/shared/api/useReconnectRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner";

import { relayClient } from "@/shared/api/relayClient";
Expand All @@ -31,6 +32,14 @@ export function useReconnectRelay(): {
inFlightRef.current = true;
setIsPending(true);
try {
// Run transport-layer reconnect hook (e.g. WARP VPN re-auth for internal builds).
// No-op in OSS builds. Non-fatal — transport failure shouldn't block relay reconnect.
try {
await invoke("relay_reconnect_hook");
} catch (err) {
console.warn("[useReconnectRelay] reconnect hook failed:", err);
}

await relayClient.preconnect();
await queryClient.invalidateQueries();
// No success toast — the banner auto-hides once the connection state
Expand Down
Loading