From 33c4d32628713a42958a45364855b04561a803f6 Mon Sep 17 00:00:00 2001 From: Charlie Croom Date: Mon, 14 Sep 2026 11:18:43 -0400 Subject: [PATCH 1/2] Add development diagnostics for relay warm and media paths Gate client warm/preparation logging behind localStorage (buzz.debug.relay) and log every dev-broker request, query filter, media fetch and origin rejection to the Vite output. Signed-off-by: Charlie Croom Amp-Thread-ID: https://ampcode.com/threads/T-01a0a053-f85b-754c-a353-03fb77a26363 Co-authored-by: Amp --- dev/relay-broker.mjs | 21 ++++++++++++++++++++- src/features/relay/debug.ts | 10 ++++++++++ src/features/relay/media.ts | 29 +++++++++++++++++++++++------ src/features/relay/store.ts | 32 ++++++++++++++++++++++++++++++-- 4 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 src/features/relay/debug.ts diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 2027d4a1..839541f2 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -334,6 +334,14 @@ export function relayBrokerPlugin({ ); server.middlewares.use(async (req, res, next) => { if (!req.url?.startsWith("/api/relay/")) return next(); + const startedAt = Date.now(); + const route = new URL(req.url, "http://localhost").pathname; + res.on("finish", () => { + if (route === "/api/relay/media") return; + server.config.logger.info( + `[relay-broker] ${req.method} ${route} -> ${res.statusCode} (${Date.now() - startedAt}ms)`, + ); + }); const origin = `http://${req.headers.host ?? ""}`; // Same-origin browser access only; trusted plugins/local processes are not sandboxed. if (!/^(localhost|127\.0\.0\.1):\d+$/.test(req.headers.host ?? "")) @@ -343,8 +351,12 @@ export function relayBrokerPlugin({ (req.headers.origin && req.headers.origin !== origin) || (req.headers["sec-fetch-site"] && req.headers["sec-fetch-site"] !== "same-origin") - ) + ) { + server.config.logger.info( + `[relay-broker] rejected ${req.method} ${route}: origin=${req.headers.origin ?? "none"} sec-fetch-site=${req.headers["sec-fetch-site"] ?? "none"}`, + ); return json(res, 403, { error: "Origin rejected" }); + } const url = new URL(req.url, origin); try { if (url.pathname === "/api/relay/register" && req.method === "POST") { @@ -702,6 +714,9 @@ export function relayBrokerPlugin({ const bytes = Buffer.from(await upstream.arrayBuffer()); if (bytes.length > MAX_MEDIA_BYTES) return json(res, 413, { error: "Media budget exceeded" }); + server.config.logger.info( + `[relay-broker] media ${target.pathname} ${bytes.length}B ${type} (${Date.now() - startedAt}ms)`, + ); res.writeHead(200, { "Content-Type": type, "Cache-Control": "private, max-age=3600", @@ -865,6 +880,10 @@ export function relayBrokerPlugin({ !validFilters(filters) ) return json(res, 400, { error: "Read filter rejected" }); + if (route === "/api/relay/query") + server.config.logger.info( + `[relay-broker] query ${req.headers["x-buzz-read-priority"] === "background" ? "background" : "foreground"} ${JSON.stringify(filters).slice(0, 240)}`, + ); const gifSearchPath = gifs ? await getGifSearchPath(relay) : null; if (gifs && !gifSearchPath) return json(res, 404, { error: "GIF search is unavailable" }); diff --git a/src/features/relay/debug.ts b/src/features/relay/debug.ts new file mode 100644 index 00000000..b8b0d9b2 --- /dev/null +++ b/src/features/relay/debug.ts @@ -0,0 +1,10 @@ +/** Development diagnostics for the warm/preparation paths. Enable per browser + * with localStorage "buzz.debug.relay" = "1"; never active in other tabs or builds. */ +export function relayDebug(...parts: readonly unknown[]): void { + try { + if (globalThis.localStorage?.getItem("buzz.debug.relay") !== "1") return; + } catch { + return; + } + console.info("[relay]", ...parts); +} diff --git a/src/features/relay/media.ts b/src/features/relay/media.ts index 93b96843..a0301b83 100644 --- a/src/features/relay/media.ts +++ b/src/features/relay/media.ts @@ -1,4 +1,5 @@ import { ByteLru } from "./budget"; +import { relayDebug } from "./debug"; /** Small decoded-avatar hot set. Never warms originals/attachments that this renderer doesn't display. * Natural dimensions account for decoded pixels, not compressed transfer bytes. */ export function createMediaPreparation({ @@ -11,6 +12,7 @@ export function createMediaPreparation({ let disposed = false; const active = new Set(); const cancellations = new Map void>(); + const startedAt = new Map(); function pump() { if (disposed || typeof Image === "undefined") return; while (active.size < 2 && queue.length) { @@ -19,7 +21,7 @@ export function createMediaPreparation({ const image = new Image(); active.add(image); let finished = false; - const finish = () => { + const finish = (outcome: string) => { if (finished) return; finished = true; clearTimeout(timeout); @@ -27,19 +29,26 @@ export function createMediaPreparation({ active.delete(image); cancellations.delete(image); pending.delete(url); + relayDebug( + "avatar", + outcome, + url.slice(-28), + `${Date.now() - (startedAt.get(url) ?? Date.now())}ms`, + ); + startedAt.delete(url); pump(); }; const timeout = setTimeout(() => { image.src = ""; - finish(); + finish("timeout"); }, 10000); - cancellations.set(image, finish); - image.onerror = finish; + cancellations.set(image, () => finish("cancelled")); + image.onerror = () => finish("error"); image.onload = () => { // Do not explicitly decode enormous originals just to prepare an avatar. const bytes = image.naturalWidth * image.naturalHeight * 4; if (bytes > maxBytes / 2) { - finish(); + finish(`too-large ${image.naturalWidth}x${image.naturalHeight}`); return; } void image @@ -50,9 +59,12 @@ export function createMediaPreparation({ }, () => {}, ) - .finally(finish); + .finally(() => + finish(`ok ${image.naturalWidth}x${image.naturalHeight}`), + ); }; image.referrerPolicy = "no-referrer"; + startedAt.set(url, Date.now()); image.src = url; } } @@ -67,6 +79,11 @@ export function createMediaPreparation({ pending.add(url); queue.push(url); } + relayDebug( + "avatar queue", + queue.length, + `of ${urls.length} (active ${active.size})`, + ); pump(); }, stats: () => ({ diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index 74e90933..4071d68b 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -18,6 +18,7 @@ import { parseWindow, windowFilter, type WindowCursor } from "./window"; import { ByteLru, byteSize } from "./budget"; import type { HeadPersistence, SavedHead } from "./persistence"; import { createMediaPreparation } from "./media"; +import { relayDebug } from "./debug"; type Listener = () => void; type WindowState = { @@ -327,11 +328,14 @@ export function createChannelStore( return url ? [url] : []; }); }); + relayDebug("media prepare", channelId.slice(0, 8), `${urls.length} urls`); media.prepare(urls); } async function fetchProfiles(rows: readonly ChannelMessage[]) { try { - await directory.ensure(rows.flatMap(rowProfileIds), "background"); + const ids = rows.flatMap(rowProfileIds); + relayDebug("profiles warm", ids.length, "ids"); + await directory.ensure(ids, "background"); if (!disposed && intent) prepareMedia(intent); } catch { // Names are optional for channel rendering. Missing profiles remain retryable. @@ -407,6 +411,7 @@ export function createChannelStore( priority: Priority, ): Promise { const generation = epoch; + const startedAt = now(); const accessVersion = accessVersions.get(channelId) ?? 0; const controller = new AbortController(); controllers.add(controller); @@ -444,6 +449,7 @@ export function createChannelStore( ) throw new Error("Channel head exceeds the read budget"); } catch (error) { + relayDebug("head failed", channelId.slice(0, 8), describe(error)); if ( !disposed && generation === epoch && @@ -455,6 +461,11 @@ export function createChannelStore( } finally { controllers.delete(controller); } + relayDebug( + "head", + channelId.slice(0, 8), + `${head.rows.length} rows ${now() - startedAt}ms ${priority}`, + ); heads.set(channelId, head); setList(list); // Durable message warmth must not wait behind optional name enrichment. @@ -923,8 +934,25 @@ export function createChannelStore( if ( preparing || (head && !head.cached && now() - head.savedAt < FRESH_FOR) - ) + ) { + relayDebug( + "prepare skip", + channelId.slice(0, 8), + preparing + ? "in-flight" + : head + ? head.cached + ? "cached-head" + : "fresh-head" + : "no-head", + ); return; + } + relayDebug( + "prepare fetch", + channelId.slice(0, 8), + head ? (head.cached ? "cached-head" : "stale-head") : "no-head", + ); // One speculative head, no backlog from crossing sidebar rows. Keep it // foreground so selecting this same request cannot inherit a host-side // background wait; the other reader slots remain available for demand. From 16c9db977005545e2aa1769e950015140f73718b Mon Sep 17 00:00:00 2001 From: Charlie Croom Date: Mon, 14 Sep 2026 11:22:28 -0400 Subject: [PATCH 2/2] Add Developer settings tab with cache clear and broker stats Visible only on localhost in dev builds (hostname check excludes packaged desktop, which serves production bundles from tauri://localhost). Exposes the relay broker /api/relay/stats counters and a clear-cache action that keeps account and sidebar settings. Signed-off-by: Charlie Croom Amp-Thread-ID: https://ampcode.com/threads/T-01a0a053-f85b-754c-a353-03fb77a26363 Co-authored-by: Amp --- src/app/DeveloperSettings.tsx | 119 ++++++++++++++++++++++++++++++++++ src/app/Settings.tsx | 31 ++++++++- src/app/navigation.ts | 4 +- 3 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 src/app/DeveloperSettings.tsx diff --git a/src/app/DeveloperSettings.tsx b/src/app/DeveloperSettings.tsx new file mode 100644 index 00000000..8f5d34f5 --- /dev/null +++ b/src/app/DeveloperSettings.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from "react"; +import type { RelayData } from "../features/relay/service"; + +type BrokerStats = { + queries: number; + errors: number; + media: number; + connects: number; +}; + +const STATS_POLL_MS = 5000; + +/** Only the dev broker serves /api/relay/*. Packaged builds and proxied + * deployments have no such endpoint, so absence is normal, not an error. */ +async function fetchStats(): Promise { + try { + const res = await fetch("/api/relay/stats"); + if (!res.ok) return undefined; + return (await res.json()) as BrokerStats; + } catch { + return undefined; + } +} + +export function DeveloperSettings({ relay }: { relay: RelayData }) { + const [stats, setStats] = useState(); + const [clearing, setClearing] = useState(false); + const [status, setStatus] = useState(); + + useEffect(() => { + let alive = true; + async function poll() { + const next = await fetchStats(); + if (alive) setStats(next); + } + void poll(); + const timer = setInterval(poll, STATS_POLL_MS); + return () => { + alive = false; + clearInterval(timer); + }; + }, []); + + async function clearCache() { + setClearing(true); + try { + await relay.clearCache(); + setStatus("Caches cleared. Channels and media will refetch on demand."); + } catch (error) { + setStatus(`Clear failed: ${String(error)}`); + } finally { + setClearing(false); + } + } + + return ( +
+

+ Developer +

+
+

+ Diagnostics for local development. This tab only appears when the app + is served from localhost in a development build. +

+
+

Relay broker stats

+ {stats ? ( +
+
+
Queries
+
{stats.queries}
+
+
+
Errors
+
{stats.errors}
+
+
+
Media
+
{stats.media}
+
+
+
Connects
+
{stats.connects}
+
+
+ ) : ( +

+ Broker stats are unavailable. They exist only when the dev relay + broker is running on this origin. +

+ )} +
+
+

Caches

+

+ Clears cached channels, messages, and media. Account, relay, and + sidebar settings are kept. +

+ + {status && ( +

+ {status} +

+ )} +
+
+
+ ); +} diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index 04a899d5..905710e4 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -1,6 +1,13 @@ import { useEffect, useState, useSyncExternalStore } from "react"; import { RecoveryScreen } from "./RecoveryScreen"; -import { Blocks, Settings2, UserRound, Palette, Bell } from "lucide-react"; +import { + Blocks, + Settings2, + UserRound, + Palette, + Bell, + Wrench, +} from "lucide-react"; import type { PluginManager } from "../plugins/manager"; import type { Communities } from "../features/communities/service"; import { PluginImport } from "./PluginImport"; @@ -10,13 +17,26 @@ import type { Appearance } from "../shared/theme/service"; import { AppearanceSettings } from "./AppearanceSettings"; import { NotificationSettings } from "./NotificationSettings"; import type { NotificationsService } from "../features/notifications/service"; +import { DeveloperSettings } from "./DeveloperSettings"; -const sections = [ +type Section = { id: string; label: string; icon: typeof UserRound }; + +const baseSections: Section[] = [ { id: "profile", label: "Profile", icon: UserRound }, { id: "plugins", label: "Plugins", icon: Blocks }, { id: "appearance", label: "Appearance", icon: Palette }, { id: "notifications", label: "Notifications", icon: Bell }, -] as const; +]; + +// DEV alone is not enough: packaged desktop builds load a production bundle +// from tauri://localhost, so the hostname check excludes them too. +export const developerMode = + import.meta.env.DEV && + /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname); + +const sections: Section[] = developerMode + ? [...baseSections, { id: "developer", label: "Developer", icon: Wrench }] + : baseSections; export function Settings({ plugins, @@ -104,6 +124,11 @@ export function Settings({ + {developerMode && ( + + )}