Files
buzz/desktop/src/app/useAppShellLifecycleEffects.ts
Will Pfleger 2c99ee7af5 fix(desktop): resolve bundled sidecar on cheap path and bound login-shell spawns (#6904)
## Why

PR #6330 split agent harness/runtime detection into a cheap (cache-only)
path and a forced (spawning) path. Two regressions followed, both
surfacing as every harness showing "(not installed)" / "CLI missing"
across the agent create/edit picker, Agents > Agent defaults, and
Settings > Agents — blocking agent create/edit until the user clicked
Install in Settings > Agents.

## Root cause

One underlying bug, two victims:

- **Boot false-negative.** The resolve cache is in-memory, so it starts
cold on every launch. `resolve_command_cached` (the cheap path)
consulted only the Buzz-managed shim dirs plus that cold cache, and
`buzz_managed_command_path`'s allowlist structurally excludes
`buzz-agent`. The bundled sidecar could therefore never resolve on the
cheap path until a forced pass warmed the cache, so cheap-path surfaces
rendered all-missing at boot. App setup never warms the cache.
- **"Check again" hang.** `run_in_login_shell` used an untimeouted
`Command::output()`; a wedged login shell froze the whole forced
pipeline, leaving "Check again" spinning forever.

## What

- `resolve_command_cached` now also calls `resolve_workspace_command`,
resolving the bundled sidecar via a filesystem stat (no spawn) — the
same class of work the managed-shim check already performs. `buzz-agent`
can no longer report missing, even inside the boot warm window.
- New `discovery/bounded_command.rs` runs any discovery child under a
hard wall-clock deadline, polling with `try_wait` rather than blocking
on `wait()`. Stdout and stderr are piped to two drain threads whose
buffers share an aggregate `CAPTURE_LIMIT`; a breach fails closed (kill
the tree, return `None`), so a noisy or hostile probe can force neither
unbounded memory nor disk fill. Tree teardown runs on every exit path —
timeout, error, cap breach, *and* success — because a login-shell rc
file or auth CLI can legitimately background a descendant that would
otherwise outlive discovery. Ownership is deliberately asymmetric:
- **Unix:** the child leads its own process group (`process_group(0)`);
teardown is `SIGTERM` → bounded grace → `SIGKILL` on the group. A
descendant that leaves the group (`setsid`/`setpgid`) while holding a
pipe is not owned and may survive one probe, but can never hang or
unbound the helper: the Unix drains read nonblocking and end on
`WouldBlock` once teardown sets the stop flag, so the join returns
promptly without waiting on an escaped writer's EOF.
- **Windows:** the child is spawned `CREATE_SUSPENDED`, assigned to a
kill-on-close Job Object while frozen, then resumed. The job owns the
root before any descendant can exist and is created without breakaway,
so no writer can escape — a hard whole-tree guarantee, and closing the
job reaps the tree even after the root has exited. Any failure to
create, assign, or resume is fail-closed: the child is terminated and
reaped and the spawn returns `None` (discovery treats it as
command-not-found) rather than running unowned.
- Each login-shell candidate is bounded by a 10s timeout via that
helper, falling through to the next candidate on timeout instead of
aborting the resolve. The login-shell path cache is generation-aware: a
probe that loses to a concurrent refresh or lands mid-refresh returns
the authoritative cached value (or re-probes under the new generation)
rather than its own rejected local result, so a losing thread can never
settle the UI with a PATH-missing catalog while the cache holds a fresh
success.
- Warm the ACP runtime catalog once at `AppShell` mount and gate the
cheap-path surfaces on that pass. A module-level boot-warm state (`idle`
→ `pending` → `settled`/`failed`, deduped per launch) lets
`useAcpRuntimesQuery` present a cold catalog as *loading* while the
first forced pass runs and as a *retryable error* (carrying the probe's
real reason) if it fails, instead of blessing "every harness not
installed" as authoritative. A non-empty catalog always wins, so a
revalidation or later failure never blanks a good list; the gate only
overlays once the warm has started, so onboarding (which renders before
the warm) is unaffected. Deduping per launch also fixes the previous
per-remount re-fire.

## Verification

Unix teardown and the drain contract are runtime-proven by
`#[ignore]`-free tests that record a backgrounded descendant's real PID
and assert the helper returns promptly on both the success and timeout
paths without blocking on that writer. The generation-aware login-shell
cache is covered by deterministic tests through a `cfg(test)` injectable
probe seam that assert the function's return value under both
concurrent-refresh interleavings — the losing caller returns the peer's
committed success, and a mid-probe refresh forces a re-probe to the
fresh value. The Windows ownership contract has no CI lane, so
`bounded_command.rs` carries two `#[ignore]`-gated tests (spawn/assign
race, looped; and the timeout path) for a sanctioned run on a Windows
host. The boot-warm gate is covered by unit tests for the pure overlay
and the `startBootWarm` failure → retry → settle lifecycle.

Origin: [Buzz
thread](buzz://message?channel=5ef5d5bb-643f-4b87-bbf4-e8b64585ffeb&id=a4b1c4485de4d35cff0f914d4f4211c796f44f670e76de2ef9431f7e882c906e)

Fixes #6872
Related #6662

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-28 17:45:42 -04:00

92 lines
3.4 KiB
TypeScript

import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { startBootWarm } from "@/features/agents/acpRuntimesQuery";
import { setDesktopAppBadge } from "@/features/notifications/lib/desktop";
import { useForegroundQueryRefresh } from "@/features/workflows/hooks";
import { relayClient } from "@/shared/api/relayClient";
import { useRelayResumeTriggers } from "@/shared/api/useRelayResumeTriggers";
type AppShellLifecycleEffectsOptions = {
desktopBadgeEnabled: boolean;
homeBadgeCountExcludingHighPriority: number;
topLevelUnreadChannelIds: ReadonlySet<string>;
unreadChannelNotificationCount: number;
};
export function useAppShellLifecycleEffects({
desktopBadgeEnabled,
homeBadgeCountExcludingHighPriority,
topLevelUnreadChannelIds,
unreadChannelNotificationCount,
}: AppShellLifecycleEffectsOptions) {
// Event-driven reconnect: network online / focus / visibility short-circuit
// the backoff timer when the relay session is degraded (CMD+R gap G1).
useRelayResumeTriggers();
useForegroundQueryRefresh();
// Warm the ACP runtime catalog once at app launch. The shared runtime-catalog
// cache is in-memory only, so it starts cold every boot; the cheap discovery
// path reports every harness as "(not installed)" until a forced pass warms
// it. The create/edit picker and Agents > Agent defaults surfaces read that
// cheap path, so without this warm they render all-missing (and block agent
// save) until the user visits Settings > Agents — the accidental workaround.
// `startBootWarm` drives the module-level boot-warm gate (once per launch, so
// this remounting effect never re-fires the probe) which makes those cheap
// surfaces show loading/retryable-error instead of blessing the cold catalog,
// and swallows the probe's own errors so a failure leaves the last good
// catalog in place without an unhandled rejection.
const queryClient = useQueryClient();
React.useEffect(() => {
void startBootWarm(queryClient);
}, [queryClient]);
// Prevent webview file:/// navigation on file drop outside the composer.
// Scoped to file drags only (text drag-and-drop into inputs still works).
// Composer's onDrop fires first (React synthetic before window bubble).
React.useEffect(() => {
function preventNavigation(e: DragEvent) {
if (e.dataTransfer?.types.includes("Files")) {
e.preventDefault();
}
}
window.addEventListener("dragover", preventNavigation);
window.addEventListener("drop", preventNavigation);
return () => {
window.removeEventListener("dragover", preventNavigation);
window.removeEventListener("drop", preventNavigation);
};
}, []);
React.useEffect(() => {
let isCancelled = false;
void relayClient.preconnect().catch((error) => {
if (!isCancelled) {
console.error("Failed to preconnect to relay", error);
}
});
return () => {
isCancelled = true;
};
}, []);
React.useEffect(() => {
if (!desktopBadgeEnabled) {
return;
}
const count =
unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority;
void setDesktopAppBadge(
count
? { kind: "count", count }
: { kind: topLevelUnreadChannelIds.size ? "dot" : "none" },
);
}, [
desktopBadgeEnabled,
homeBadgeCountExcludingHighPriority,
topLevelUnreadChannelIds,
unreadChannelNotificationCount,
]);
}