Skip to content

perf(desktop): GUI performance sweep — async offload, poll reduction, render stabilization#1641

Merged
tlongwell-block merged 19 commits into
mainfrom
eva/gui-perf-fixes
Jul 8, 2026
Merged

perf(desktop): GUI performance sweep — async offload, poll reduction, render stabilization#1641
tlongwell-block merged 19 commits into
mainfrom
eva/gui-perf-fixes

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

Summary

Team-wide sweep of the desktop GUI for (a) sync work on the macOS main thread (beachball risk), (b) redundant network/draw calls, (c) perceived latency. 7 sweep lanes → ranked findings → fix package on eva/gui-perf-fixes off base 5dce80af, one fix per commit, tip 77bd0e70.

Mechanism (Wren-verified): sync #[tauri::command] runs on the macOS main thread; (async) alone only moves work to Tokio workers. Fix law applied throughout: async command + explicit spawn_blocking; no rusqlite::Connection/locks across .await.

Fixes by lane

  • Boot (Sami): archive-seed hooks deferred behind startupReady — seed delayed, never dropped (test_undefined_pubkey_does_nothing, test_explicit_choice_set_does_not_reseed).
  • Render path (Dawn): messageProfiles reference stabilization kills typing-storm timeline re-renders (render-count-discipline test pair: value-equal re-derive holds the ref → memo skips; real profile change swaps it → memo fires). Plus per-row agent-pubkey rescan removal, non-DM channel-name filter hoist, MessageActionBar memoization.
  • Topbar/Pulse/Huddle polls (Wren): topbar search lazy-loads agent data (dialog open no longer pulls kind:10100); Pulse interval refetches pause when hidden; huddle fallback polls slowed (fail-closed TTS membership semantics preserved — error path still clears set + mutes).
  • Agents/Workflows polling (Quinn): relay-agents poll 30s→5min + background pause (only kind:10100 refresh path — kept, not dropped); redundant personas/teams/managed-idle 30s polls dropped (event-driven agents-data-changed covers them; managed running-5s branch kept — process liveness has no event); workflows overview N-per-channel → single #h-batched query (no limit on either path → no truncation change).
  • Backend offload (Mari): archive sqlite work in spawn_blocking; event sync deferred off setup path; huddle device/model setup offloaded + membership fetches parallelized (TOCTOU sentinels preserved).
  • Reconnect/invalidation (Perci): reconnect replay parallelized (live REQs parallel; paged history bounded at concurrency 4 preserving per-subscription page order); aux backfill concurrency cap; relay-only invalidation taxonomy.
  • Commands (Max): 13 sync-command conversions in commands/** — blocking filesystem/sqlite/CLI/store-mutex work behind explicit spawn_blocking.

Verification

  • Green-at-tip gate on every integration: tsc clean + full desktop unit suite + vite build + cargo check + full src-tauri suite at every merge tip. Final tip 77bd0e70: node tests 2047/2047, Rust 1041 passed / 11 ignored (+ rodio 3/3), tsc --noEmit clean, vite build clean.
  • Disjoint cross-review pairs: every lane reviewed to PASS by a non-author before integration (one lane held at "ready" until its reviewer passed it).
  • Whole-package red-team (Wren) at frozen tip 77bd0e70: PASS, no blockers. Re-checked: no locks/DB connections across .await in reviewed paths; huddle shutdown no longer joins workers under the mutex; deferred startup keeps live-ingestion/agent-observer coverage; reconnect replay ordering; workflows #h batch preserves channel attribution.
  • Live-relay e2e pass at tip 77bd0e70 (519 specs vs local relay): 490 passed initially; 3 flaky (pass on rerun); 2 failures reproduce at base 5dce80af (pre-existing: channels.spec.ts:876 intro-actions, custom-emoji.spec.ts:375); 3 integration failures were relay-env (relay started without BUZZ_RECONCILE_CHANNELS=true, so SQL-seeded channels had no kind:39000 discovery events) — with the correct env all 3 pass. Zero failures attributable to the package.

Reviewer manual-smoke checklist (Wren's carry-forward)

Startup, channel open/archive scrollback, agents tab inbound refresh, huddle STT/TTS startup, workflows overview, relay reconnect.

Toolchain note (environment, not this PR)

Early lane pushes hit buzz-db+sqlx 0.9 requiring rustc 1.94 in pre-push hooks: Homebrew rustc 1.89 was shadowing the repo-pinned toolchain. PATH="$PWD/bin:$PATH" clears it — hook suites then run green with no --no-verify. No lane touched Cargo.toml/Cargo.lock/crates/buzz-db.

Named follow-ups (out of scope, recorded)

  1. secret_store boot-identity redesign — sync keychain access on boot path; HIGH risk, needs its own design.
  2. Subscription multiplexer / multi-#h filter-family merge — Perci design sketch, Wren/Mari red-team gate before build.
  3. relay-agents kind:10100 since-cursor — cut payload, not just cadence; own tests.
  4. Projects issues/PRs #a-batch (deferred Fix D) — MUST ship with scaled pooled limit (limit*N), a-tag re-split, multi-project split/truncation guard test.
  5. Workflow run-reconstruction event stream.
  6. WorkflowsView rename-cache note (Max; pre-existing, non-blocking).

npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc and others added 19 commits July 8, 2026 10:15
Finding [L5] AppShell.tsx:161-164 — 4 archive/ingestion hooks mounted
unconditionally at first render competed with first paint. Gate the two
archive *seed* hooks (useObserverArchiveSeed, useAgentMetricArchiveSeed)
behind startupReady by passing deferredPubkey instead of the eager
identityQuery pubkey, matching the existing presence/user-status
deferral pattern.

useArchiveSync (the eager live-save path) stays unchanged — it owns live
event ingestion and must not be deferred. Only the one-shot first-run
seeds (mergeSaveSubscriptionKinds) move off the boot critical path.

The seeds' explicit-choice guard is untouched: each hook already
early-returns on `if (!pubkey) return`, so a deferred (undefined) pubkey
is a no-op until startup completes, then the seed fires exactly once with
identical semantics. Confirmed by useObserverArchiveSeed/
useAgentMetricArchiveSeed test suites (18/18), incl.
test_undefined_pubkey_does_nothing and
test_explicit_choice_set_does_not_reseed.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L7 search dialog-open kind:10100 pull.\n\nKeep message/user search behavior the same, but defer managed-agent and relay-agent queries until the debounced query reaches the existing minimum search length. Opening and closing the dialog without typing no longer triggers agent discovery/network work.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L7 pulse fixed-interval refetches.\n\nGate Pulse timeline/reaction refetch intervals on document visibility so the visible tab stays fresh while hidden windows stop issuing periodic social API requests. Initial query enablement and invalidation behavior are unchanged.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L7 huddle frontend poll reductions.\n\nKeep event-driven huddle state updates and immediate initial checks, but slow the fallback timers that hit sync/lightweight IPC and duplicate membership refresh paths. This reduces steady-state huddle IPC/relay traffic without touching Rust huddle ownership.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…rm re-renders

Finding [L4] ChannelScreen.tsx messageProfiles (Tier 1): the users-batch
query re-keys on the full sorted pubkey set, so typing churn (a transient
typing-only pubkey entering/leaving the set) produces a fresh lookup object
identity even when no profile value changed. That new reference fails
MessageRow's `prev.profiles === next.profiles` memo check and re-renders the
entire timeline on every keystroke-adjacent typing event.

Add `profileLookupsEqual(a, b)` — a by-value deep-equal over the 5 scalar
summary fields (displayName/avatarUrl/nip05Handle/ownerPubkey/isAgent) — and
use a useRef stabiliser at the ChannelScreen boundary to return the previous
reference when the re-derived lookup is value-equal. Consumers read profiles
by pubkey value only and never treat identity as a change signal, so returning
the stale-but-value-identical reference is safe (traced c6237ef8). The
stabilisation does O(profiles) equality work once at the boundary, replacing
O(rows x profiles) of downstream per-row re-render.

Also derive `agentPubkeys` from the stabilised lookup (was raw query data) so
that Set only churns on a real profile change, demoting the downstream row
scans that keyed off it.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…rom parent

Finding [L4] MessageRow resolvedAgentPubkeys (Tier 2): every mounted row
re-scanned the full `profiles` lookup to rebuild the agent-pubkey Set, so the
same O(profiles) work ran once per row and re-ran on every profile-lookup
change. ChannelScreen already computes this exact Set once (now from the
stabilised lookup) and passes it down as `agentPubkeys`, already normalised.

Consume the passed Set directly, falling back to a module-level stable empty
Set so rows without one keep a constant reference (a fresh `new Set()` per
render would defeat the row memo). Widen `getConfigNudgeAuthorPubkey`'s
parameter to `ReadonlySet<string>` — it only reads via `.has()`, and this
matches how the `agentPubkeys` prop is already typed upstream.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…n provider

Finding [L4] MessageRow channelNames (Tier 3): every row ran
`channels.filter(non-dm).map(name)` in its own useMemo, so the identical
derivation was recomputed once per mounted row and re-ran whenever the channel
list changed. Compute it once in `ChannelNavigationProvider` as
`nonDmChannelNames` (memoised on `[channels]`) and have rows read it directly.

Additive to the context value — `channels` is unchanged, so other consumers are
unaffected.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…callback

Finding [L4] MessageActionBar (Tier 3): the action bar re-rendered with every
row render even when its props were unchanged. Wrap it in `React.memo`. The
memo was dead on arrival, though — the row passed an inline `onRemindLater`
arrow, a fresh function identity every render that defeated the shallow prop
compare. Extract it to a `handleRemindLater` useCallback (deps
[channelId, openReminder]); all other props are already stable (openReminder
and handleReactionSelect are useCallbacks, setBadgeBurstEmoji is a state
setter), so the memo now actually skips.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…iser

Finding [L4] messageProfiles structural-share (Tier 1) — red/green evidence.
`profileLookupsEqual` cases: reference identity, distinct-but-value-equal,
key-count / key-set mismatch, each of the 5 summary fields breaking equality,
empty lookups. Plus a render-count-discipline pair that replays the exact
ChannelScreen ref idiom: value-equal re-derives hold the same reference (the
memo skips — no timeline re-render on typing churn) and a real profile change
swaps it (the memo fires), then re-stabilises around the new value.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L2 archive read/save-subscription/archive_events sqlite work could block the GUI IPC path or async workers under busy_timeout contention.

Move archive planning, commits, save-subscription merge/remove/list, and paginated reads through spawn_blocking with each rusqlite connection scoped inside the blocking closure. Relay query awaits remain between DB phases with no connection or transaction in scope.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L2 event_sync boot reconcile did best-effort JSON reads, SQLite retention checks/writes, and event signing synchronously during Tauri setup after identity resolution.

Snapshot the resolved owner keys, then spawn the reconcile after setup-critical boot work begins. The reconcile runs on spawn_blocking so no filesystem, SQLite, or signing work occupies the GUI setup path or an async worker. Secret-store identity resolution remains synchronous and out of scope by design.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L2 huddle device enumeration used a sync command on provider mount/devicechange, and start/join performed sequential membership fetches plus synchronous STT/TTS ONNX construction on async workers.

Make audio output enumeration an async command backed by spawn_blocking, fetch bot/all memberships in parallel during post-connect setup, and construct STT/TTS pipelines on the blocking pool while preserving starting sentinels, stale-session checks, and final phase checks.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding L3-1: reconnect replay previously awaited every live subscription REQ and paged history replay serially, making reconnect latency grow with active subscription count.

Start live REQs together, then replay paged channel catch-up with a bounded cap while preserving ordering inside each subscription's page loop.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding L3-3: fetchChunkedAuxEvents awaited each auxiliary-event chunk sequentially, delaying visible reaction/edit/deletion backfills on large history windows.

Run aux chunks through a small shared concurrency helper so relay history requests overlap without unbounded fanout. The helper preserves chunk result order and the existing fail-fast semantics.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding L3-2: auto-heal and manual reconnect used broad queryClient.invalidateQueries(), refetching unrelated Tauri/disk/local queries after relay recovery.

Route both reconnect paths through a shared relay-dependent query predicate. The taxonomy includes relay-backed messages/channels/profiles/social/workflows/projects state and excludes local-only agent/runtime/workspace-icon/repo queries.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…nded

L6 finding: useRelayAgentsQuery ran a 30s refetchInterval backed by an
unfiltered relay query for ALL kind:10100 agent profiles, mounted on ~13
always-live surfaces (channel screen, members bar, mentions, sidebar,
profile popovers). It re-pulled the full profile set app-wide every 30s.

Relay agent profiles are near-static; 30s is far too aggressive. This poll
is also the ONLY refresh path for kind:10100 (the agents-data-changed event
fires only for local PERSONA/TEAM/MANAGED_AGENT reconcile, never kind:10100),
so it cannot be dropped. Relaxing to 5min drops relay traffic ~10x, and
refetchIntervalInBackground:false (matches channels/hooks.ts house style)
pauses it while the window is unfocused. No semantics change: the set of
relay agents still refreshes, just less often.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
L6 finding: usePersonasQuery, useTeamsQuery, and useManagedAgentsQuery's
idle branch each ran a 30s refetchInterval that duplicated an existing
event-driven refresh path. Inbound relay changes to PERSONA/TEAM/MANAGED_AGENT
records emit agents-data-changed (personas/mod.rs), which useAgentsDataRefresh
coalesces into a query invalidate (200ms window). The 30s poll was
belt-and-suspenders disk-read IPC on top of correct event invalidation.

Removed the interval from personas and teams entirely. For managed agents,
kept the 5s branch for locally-running agents (process state can change with
no relay event, so that poll is their only liveness signal) and dropped the
idle 30s fallback (control-plane changes have the event path; the 5s branch
already captures the running->stopped transition on its last poll, and user
actions invalidate directly).

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
L6 finding: WorkflowsView's allWorkflowsQuery issued one get_channel_workflows
relay POST per member channel (Promise.all fanout) — N round-trips scaling with
channel count on the Workflows overview.

A nostr #h filter matches ANY listed value, so one query with all channel ids
returns the identical set. Added get_channels_workflows(channel_ids) which
queries once; each WorkflowWire already carries its own channel_id (from the
event h tag), so WorkflowsView groups results client-side via a channelId->name
map. Neither the per-channel nor the batched command sets a limit, so batching
does not change result completeness (review-bar #3: no shared-limit truncation).
e2eBridge gains a matching get_channels_workflows mock so overview e2e still
resolves. Single-channel get_channel_workflows stays for useChannelWorkflowsQuery.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding [L1] commands/** — several Tauri commands still performed filesystem, process discovery, sqlite/local-storage reads, zip/json parsing, managed-agent store work, and workspace symlink updates synchronously on the command thread. Convert the affected commands to async command handlers with explicit spawn_blocking around the blocking sections so the UI thread is not responsible for those operations.

Reacquire AppState from the owned AppHandle inside blocking closures instead of moving borrowed State<'_, AppState> or non-Send guards across await points. Keep existing store mutex serialization inside the blocking closures and preserve command-specific ordering: identity import persists before swapping in-memory keys, workspace apply validates before mutation and persists the effective repos dir before symlink updates, and repos-dir-error emissions still use the AppHandle.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed on behalf of Wes's crew — full diff read lane by lane against tip 77bd0e70, worktree build of the branch, CI green across the board (Desktop Core/Build/E2E Relay, 4× Smoke E2E, 2× E2E Integration, Rust Lint, Windows Rust).

Verified in the code:

  • All 13 sync→async command conversions follow the same safe shape: spawn_blocking closure, std::sync::Mutex locks acquired and released inside the closure, no lock or rusqlite::Connection crossing an .await. Archive commands share one run_archive_db_task helper; the archive DB is WAL + 5s busy_timeout, so cross-thread opens are safe. import_identity even gains a correct persist-before-swap ordering.
  • spawn_event_sync boot deferral: keys snapshotted before spawn; retention writes are last-write-wins upserts under the same store-lock discipline, so racing an inbound reconcile_inbound_persona_event is benign.
  • profileLookupsEqual compares all five UserProfileSummary fields — no stale-render hole — and both MessageRow call sites receive agentPubkeys from parents that still derive it, so the per-row rescan removal loses nothing.
  • Poll removals are genuinely event-covered: both backend agents-data-changed emit sites + the coalescing listener handle personas/teams; relay-agents correctly keeps its (relaxed) poll as the only kind:10100 refresh path; the managed-agents idle→false branch is sound because running→exited can only happen while the 5s running-branch poll is live.
  • Reconnect replay: per-subscription page order preserved (sequential paging inside a sub, concurrency 4 across subs), isActive re-checked after each await; the relay-only invalidation predicate has include/exclude tests.

One non-blocking note — workflows #h batch truncation semantics: the comment says "no limit on either path → no truncation change," but the relay applies MAX_HISTORICAL_LIMIT = 2000 as the default when a filter carries no limit (crates/buzz-relay/src/handlers/req.rs). So the practical budget went from 2000 per channel to 2000 pooled across all channels. Unreachable for kind 30620 volumes today, and follow-up #4 already names this exact trap for the projects batch — but a one-line comment correction at get_channels_workflows would keep the next reader from copying the "no limit = no truncation" reasoning onto a high-volume kind.

Also flagging as a deliberate, user-visible trade (no change requested): relay-agents 30s→5min means a newly registered relay agent can take up to 5 minutes to appear in mention/member surfaces until follow-up #3's since-cursor lands.

Approving. Great discipline on one-fix-per-commit — it made a 44-file perf sweep genuinely reviewable.

@tlongwell-block tlongwell-block merged commit 2cc0eb5 into main Jul 8, 2026
25 checks passed
@tlongwell-block tlongwell-block deleted the eva/gui-perf-fixes branch July 8, 2026 18:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants