feat(desktop): add configurable transport reconnect hook (#1059)

Adds a build-time-configured transport reconnect hook so internal builds can recover the underlying transport (e.g. WARP VPN) before the relay reconnect, while OSS builds stay a pure no-op.

The generic "Reconnect to relay" button is transport-agnostic; internal users behind a VPN need the transport re-established first. A build-time env var `BUZZ_BUILD_RELAY_RECONNECT_CMD` (set only in `squareup/buzz-releases`) carries a JSON config validated into a typed Rust struct at compile time, so the OSS binary ships with zero VPN knowledge.

- New Tauri command `relay_reconnect_hook` runs structured fixed-argv steps plus a readiness probe, wrapped in `tokio::task::spawn_blocking`; non-fatal end-to-end so any failure falls through to `preconnect()`.
- `ReconnectHookConfig` lives in one dep-free source file `include!`'d by both `build.rs` and the runtime command, so the compile-time validation and runtime parse cannot drift.
- `useReconnectRelay.ts` invokes the hook before relay preconnect, guarded so a hook rejection cannot abort the reconnect.
- When `BUZZ_BUILD_RELAY_RECONNECT_CMD` is unset (OSS), the command compiles to an early-return no-op.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-16 10:15:15 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 2300248d3b
commit ba776b9959
12 changed files with 451 additions and 24 deletions
+1
View File
@@ -38,6 +38,7 @@ export default defineConfig({
"**/spoiler.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",
+2
View File
@@ -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]
+14
View File
@@ -1,3 +1,7 @@
// 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");
@@ -5,6 +9,7 @@ fn main() {
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") {
@@ -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())
+2
View File
@@ -22,6 +22,7 @@ mod personas;
mod prevent_sleep;
mod profile;
mod relay_members;
mod relay_reconnect;
mod social;
mod teams;
mod workflows;
@@ -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::*;
@@ -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,
}
@@ -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
View File
@@ -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");
+37 -23
View File
@@ -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,
@@ -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;
@@ -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>
@@ -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 = {
@@ -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);
@@ -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}
@@ -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";
@@ -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
@@ -0,0 +1,149 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/relay-reconnect-screenshots";
async function settle(page: import("@playwright/test").Page) {
// Tolerate cancelled animations (skeleton → live swap rejects `.finished`
// with AbortError) AND indefinitely-running ones (the degraded-state pulse
// never resolves `.finished`): allSettled handles rejection, the timeout
// race handles infinite animations so this can never hang the test.
await page.evaluate(() =>
Promise.race([
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
new Promise((resolve) => setTimeout(resolve, 1_000)),
]),
);
}
/** Drive the relay client into a state via the real E2E connection-state seam. */
async function driveConnectionState(
page: import("@playwright/test").Page,
state: "connected" | "disconnected",
) {
await page.evaluate((s) => {
const setter = (
window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (state: string) => void;
}
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__;
if (!setter) throw new Error("E2E relay state setter not installed.");
setter(s);
}, state);
}
async function scrollSidebarToBottom(page: import("@playwright/test").Page) {
// The relay block anchors at the bottom of the sidebar's scroll region and is
// painted UNDER the absolute, z-30 profile footer (it sits within the footer's
// 68px band when unscrolled — toBeVisible passes since it's in-DOM, but a human
// can't see it). The scroll region is specifically [data-sidebar="content"];
// scrolling it fully down lifts the block ~96px clear of the footer. Targeting
// any old overflowing descendant matches the wrong element (e.g. a menu button),
// so the selector must be exact.
await page
.getByTestId("app-sidebar")
.locator('[data-sidebar="content"]')
.evaluate((scroller) => {
scroller.scrollTop = scroller.scrollHeight;
});
}
async function openProfilePopover(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-profile-avatar-button").click();
// Anchor on a stable popover child so the screenshot captures the open menu.
await expect(page.getByTestId("profile-popover-settings")).toBeVisible();
// Await the Radix open animation on the [data-state] ancestor — the popper
// wrapper carries no animations, so screenshots would otherwise capture a
// half-faded popover.
await page.getByTestId("profile-popover-settings").evaluate((el) =>
Promise.all(
el
.closest("[data-state]")
?.getAnimations()
.map((a) => a.finished) ?? [],
),
);
}
test.describe("relay reconnect affordance screenshots", () => {
test("01 — profile popover reconnect item hidden when healthy", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("channel-general")).toBeVisible();
await openProfilePopover(page);
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
await settle(page);
await page.screenshot({
path: `${SHOTS}/01-profile-popover-healthy.png`,
});
});
test("02 — profile popover reconnect item shown when degraded", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("channel-general")).toBeVisible();
await driveConnectionState(page, "disconnected");
await openProfilePopover(page);
await expect(page.getByTestId("profile-popover-reconnect")).toBeVisible({
timeout: 5_000,
});
await settle(page);
await page.screenshot({
path: `${SHOTS}/02-profile-popover-degraded.png`,
});
});
test("03 — sidebar has no reconnect prompt when healthy", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("channel-general")).toBeVisible();
await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
// The relay block renders at the BOTTOM of the scrollable sidebar content,
// below the fold at this viewport. Scroll to the bottom so 03 frames the
// same region where 04 will show the block — making the absence legible.
await scrollSidebarToBottom(page);
await settle(page);
// Frame the left sidebar directly — the relay-unreachable block lives there,
// and a full-window shot makes its presence/absence illegible against the
// unrelated top connection banner.
await page.getByTestId("app-sidebar").screenshot({
path: `${SHOTS}/03-sidebar-healthy.png`,
});
});
test("04 — sidebar reconnect prompt shown when degraded, channels visible", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("channel-general")).toBeVisible();
await driveConnectionState(page, "disconnected");
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
// The cached channel list stays visible alongside the prompt.
await expect(page.getByTestId("channel-general")).toBeVisible();
// Scroll the block clear of the occluding footer (symmetric with 03).
await scrollSidebarToBottom(page);
await settle(page);
await page.getByTestId("app-sidebar").screenshot({
path: `${SHOTS}/04-sidebar-degraded.png`,
});
});
});
+67
View File
@@ -19,6 +19,23 @@ async function setMockWebsocketSendsStalled(
}, stall);
}
async function driveConnectionDegraded(
page: import("@playwright/test").Page,
state: "reconnecting" | "stalled" | "disconnected",
) {
await page.evaluate((s) => {
const setter = (
window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (state: string) => void;
}
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__;
if (!setter) {
throw new Error("E2E relay state setter is not installed.");
}
setter(s);
}, state);
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
@@ -48,3 +65,53 @@ test("passive relay watchdog does not write while the websocket is half-open", a
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
});
test("sidebar reconnect prompt flips on live relay degradation without a query error", async ({
page,
}) => {
await page.goto("/");
// Healthy boot: channels render from the mock bridge and the reconnect
// prompt is absent (no query error, connection healthy).
await expect(page.getByTestId("channel-general")).toBeVisible();
await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
// Drive ONLY the live connection state degraded — no channelsQuery error is
// set. Pre-fix the block keyed off `channelsQuery.error` alone, so it stays
// absent here; post-fix the dual signal surfaces it.
await driveConnectionDegraded(page, "disconnected");
// `disconnected` reports immediately (reconnecting/stalled debounce ~2s),
// but allow margin for the React render to flush.
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
// The cached channel list stays visible alongside the prompt (layout intent:
// surface the affordance, don't yank context).
await expect(page.getByTestId("channel-general")).toBeVisible();
});
test("profile popover reconnect item is hidden when healthy and shown when degraded", async ({
page,
}) => {
await page.goto("/");
// Healthy boot: open the profile popover and wait for it to mount. The
// reconnect item must be ABSENT because the relay is connected. Anchoring on
// a stable popover item first ensures the count assertion reflects the gate,
// not an un-mounted popover. Pre-fix the item rendered unconditionally, so
// this fails before the gate is added.
await expect(page.getByTestId("channel-general")).toBeVisible();
await page.getByTestId("sidebar-profile-avatar-button").click();
await expect(page.getByTestId("profile-popover-settings")).toBeVisible();
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
// Drive the live connection degraded. The gate reads `useRelayConnection()`,
// so the item surfaces reactively while the popover stays open.
await driveConnectionDegraded(page, "disconnected");
await expect(page.getByTestId("profile-popover-reconnect")).toBeVisible({
timeout: 5_000,
});
});