From d6b7c2ed2d34cef0a5c2b01443f758e65762837c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 22 Feb 2026 11:52:08 +0700 Subject: [PATCH] refactor(app): centralize perf diagnostics and activity coalescing --- packages/app/src/app/_layout.tsx | 85 ++--- packages/app/src/components/left-sidebar.tsx | 26 +- .../app/src/components/sidebar-agent-list.tsx | 24 +- packages/app/src/contexts/session-context.tsx | 7 +- .../app/src/hooks/use-sidebar-agents-list.ts | 73 +++- packages/app/src/runtime/activity/index.ts | 9 + .../activity/last-activity-coalescer.ts | 67 ++++ .../activity/last-activity-scheduler.ts | 26 ++ packages/app/src/runtime/activity/types.ts | 17 + packages/app/src/runtime/host-runtime.ts | 19 +- .../runtime/perf-diagnostics/debug-tools.ts | 38 +++ .../src/runtime/perf-diagnostics/engine.ts | 311 ++++++++++++++++++ .../host-runtime-diagnostics.ts | 121 +++++++ .../app/src/runtime/perf-diagnostics/index.ts | 22 ++ .../perf-diagnostics-context.ts | 21 ++ .../perf-diagnostics-provider.tsx | 53 +++ .../src/runtime/perf-diagnostics/reporter.ts | 22 ++ .../app/src/runtime/perf-diagnostics/types.ts | 44 +++ .../perf-diagnostics/use-perf-diagnostics.ts | 7 + packages/app/src/stores/session-store.ts | 106 +++++- .../app/src/utils/agent-directory-sync.ts | 8 +- packages/app/src/utils/perf-diagnostics.ts | 20 ++ packages/app/vitest.config.ts | 1 + packages/server/src/client/daemon-client.ts | 73 ++++ 24 files changed, 1119 insertions(+), 81 deletions(-) create mode 100644 packages/app/src/runtime/activity/index.ts create mode 100644 packages/app/src/runtime/activity/last-activity-coalescer.ts create mode 100644 packages/app/src/runtime/activity/last-activity-scheduler.ts create mode 100644 packages/app/src/runtime/activity/types.ts create mode 100644 packages/app/src/runtime/perf-diagnostics/debug-tools.ts create mode 100644 packages/app/src/runtime/perf-diagnostics/engine.ts create mode 100644 packages/app/src/runtime/perf-diagnostics/host-runtime-diagnostics.ts create mode 100644 packages/app/src/runtime/perf-diagnostics/index.ts create mode 100644 packages/app/src/runtime/perf-diagnostics/perf-diagnostics-context.ts create mode 100644 packages/app/src/runtime/perf-diagnostics/perf-diagnostics-provider.tsx create mode 100644 packages/app/src/runtime/perf-diagnostics/reporter.ts create mode 100644 packages/app/src/runtime/perf-diagnostics/types.ts create mode 100644 packages/app/src/runtime/perf-diagnostics/use-perf-diagnostics.ts create mode 100644 packages/app/src/utils/perf-diagnostics.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 7cca7987d..7e7f72c9d 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -49,6 +49,7 @@ import { parseHostAgentRouteFromPathname, } from "@/utils/host-routes"; import { getTauri } from "@/utils/tauri"; +import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics"; polyfillCrypto(); const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); @@ -442,47 +443,49 @@ function MissingDaemonView() { export default function RootLayout() { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 89d7c5c3a..a463e0f08 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -65,10 +65,26 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { const pathname = usePathname(); const { daemons } = useDaemonRegistry(); const runtime = getHostRuntimeStore(); - const runtimeVersion = useSyncExternalStore( + const runtimeConnectionStatusSignature = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), - () => runtime.getVersion(), - () => runtime.getVersion() + () => + daemons + .map( + (daemon) => + `${daemon.serverId}:${ + runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" + }` + ) + .join("|"), + () => + daemons + .map( + (daemon) => + `${daemon.serverId}:${ + runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" + }` + ) + .join("|") ); const activeServerIdFromPath = useMemo( () => parseServerIdFromPathname(pathname), @@ -99,7 +115,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" ), })), - [daemons, runtime, runtimeVersion] + [daemons, runtime, runtimeConnectionStatusSignature] ); const hostTriggerRef = useRef(null); const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); @@ -386,6 +402,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { ) : ( ) : ( 0; const projectIconRequests = useMemo(() => { + if (!isOpen) { + return []; + } const unique = new Map(); for (const option of projectFilterOptions) { if (!option.serverId || !option.workingDir) { @@ -513,7 +526,7 @@ export function SidebarAgentList({ unique.set(`${serverId}:${cwd}`, { serverId, cwd }); } return Array.from(unique.values()); - }, [entries, projectFilterOptions]); + }, [entries, isOpen, projectFilterOptions]); const projectIconQueries = useQueries({ queries: projectIconRequests.map((request) => ({ @@ -526,8 +539,10 @@ export function SidebarAgentList({ const result = await client.requestProjectIcon(request.cwd); return result.icon; }, + select: toProjectIconDataUri, enabled: Boolean( - getHostRuntimeStore().getClient(request.serverId) && + isOpen && + getHostRuntimeStore().getClient(request.serverId) && isHostRuntimeConnected( getHostRuntimeStore().getSnapshot(request.serverId) ) && @@ -548,10 +563,7 @@ export function SidebarAgentList({ if (!request) { continue; } - const icon = projectIconQueries[i]?.data ?? null; - const dataUri = icon - ? `data:${icon.mimeType};base64,${icon.data}` - : null; + const dataUri = projectIconQueries[i]?.data ?? null; map.set(`${request.serverId}:${request.cwd}`, dataUri); } return map; diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 014b70729..c6c322065 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -205,6 +205,9 @@ function SessionProviderInternal({ const setAgentLastActivity = useSessionStore( (state) => state.setAgentLastActivity ); + const flushAgentLastActivity = useSessionStore( + (state) => state.flushAgentLastActivity + ); const setPendingPermissions = useSessionStore( (state) => state.setPendingPermissions ); @@ -376,10 +379,11 @@ function SessionProviderInternal({ // If the client drops mid-initialization, clear pending flags useEffect(() => { if (!isConnected) { + flushAgentLastActivity(); pendingAgentUpdatesRef.current.clear(); setInitializingAgents(serverId, new Map()); } - }, [serverId, isConnected, setInitializingAgents]); + }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]); const applyAgentUpdatePayload = useCallback( (update: AgentUpdatePayload) => { @@ -430,7 +434,6 @@ function SessionProviderInternal({ next.delete(agentId); return next; }); - return; } diff --git a/packages/app/src/hooks/use-sidebar-agents-list.ts b/packages/app/src/hooks/use-sidebar-agents-list.ts index c3777a6d4..cb651f95e 100644 --- a/packages/app/src/hooks/use-sidebar-agents-list.ts +++ b/packages/app/src/hooks/use-sidebar-agents-list.ts @@ -1,10 +1,8 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { useSessionStore, type Agent } from "@/stores/session-store"; import { getHostRuntimeStore, - isHostRuntimeDirectoryLoading, - useHostRuntimeSession, } from "@/runtime/host-runtime"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { @@ -124,6 +122,7 @@ function toAggregatedAgent(params: { source: Agent; serverId: string; serverLabel: string; + lastActivityAt: Date; }): AggregatedAgent & { createdAt: Date } { const source = params.source; return { @@ -133,7 +132,7 @@ function toAggregatedAgent(params: { title: source.title ?? null, status: source.status, createdAt: source.createdAt, - lastActivityAt: source.lastActivityAt, + lastActivityAt: params.lastActivityAt, cwd: source.cwd, provider: source.provider, requiresAttention: source.requiresAttention, @@ -149,14 +148,28 @@ export function useSidebarAgentsList(options?: { serverId?: string | null; selectedProjectFilterKeys?: string[]; }): SidebarAgentsListResult { + const isSidebarOpen = options?.isOpen ?? true; const { daemons } = useDaemonRegistry(); const runtime = getHostRuntimeStore(); + const daemonLabelSignature = useMemo( + () => + daemons + .map((daemon) => `${daemon.serverId}:${daemon.label ?? ""}`) + .join("|"), + [daemons] + ); const serverId = useMemo(() => { const value = options?.serverId; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; }, [options?.serverId]); + const serverLabel = useMemo(() => { + if (!serverId) { + return ""; + } + return daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId; + }, [daemonLabelSignature, serverId]); const selectedProjectFilterKeys = useMemo( () => @@ -168,13 +181,40 @@ export function useSidebarAgentsList(options?: { [options?.selectedProjectFilterKeys] ); + const isActive = Boolean(serverId && isSidebarOpen); const liveAgents = useSessionStore((state) => - serverId ? state.sessions[serverId]?.agents ?? null : null + isActive && serverId ? state.sessions[serverId]?.agents ?? null : null ); - const { snapshot } = useHostRuntimeSession(serverId ?? ""); + const agentLastActivity = useSessionStore((state) => + isActive ? state.agentLastActivity : null + ); + const runtimeStatusSignature = useSyncExternalStore( + (onStoreChange) => + isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {}, + () => { + if (!isActive || !serverId) { + return "idle:idle"; + } + const snapshot = runtime.getSnapshot(serverId); + const connectionStatus = snapshot?.connectionStatus ?? "idle"; + const directoryStatus = snapshot?.agentDirectoryStatus ?? "idle"; + return `${connectionStatus}:${directoryStatus}`; + }, + () => { + if (!isActive || !serverId) { + return "idle:idle"; + } + const snapshot = runtime.getSnapshot(serverId); + const connectionStatus = snapshot?.connectionStatus ?? "idle"; + const directoryStatus = snapshot?.agentDirectoryStatus ?? "idle"; + return `${connectionStatus}:${directoryStatus}`; + } + ); + const [connectionStatus = "idle", directoryStatus = "idle"] = + runtimeStatusSignature.split(":", 2); const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => { - if (!serverId || !liveAgents) { + if (!isActive || !serverId || !liveAgents) { return { entries: [] as SidebarAgentListEntry[], projectFilterOptions: [] as SidebarProjectFilterOption[], @@ -183,8 +223,6 @@ export function useSidebarAgentsList(options?: { }; } - const serverLabel = - daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId; const seenAgentIds = new Set(); const byProject = new Map(); const mergedEntries: SidebarAgentListEntry[] = []; @@ -232,10 +270,13 @@ export function useSidebarAgentsList(options?: { projectPlacement: live.projectPlacement ?? null, cwd: live.cwd, }); + const effectiveLastActivity = + agentLastActivity?.get(live.id) ?? live.lastActivityAt; const agent = toAggregatedAgent({ source: live, serverId, serverLabel, + lastActivityAt: effectiveLastActivity, }); pushEntry({ agent, project }); } @@ -255,22 +296,26 @@ export function useSidebarAgentsList(options?: { return left.projectName.localeCompare(right.projectName); }); - return { + const result = { entries: ordered.entries, projectFilterOptions: options, hasAnyData: ordered.entries.length > 0, hasMoreEntries: ordered.hasMore, }; - }, [daemons, liveAgents, selectedProjectFilterKeys, serverId]); + return result; + }, [agentLastActivity, isActive, liveAgents, selectedProjectFilterKeys, serverId, serverLabel]); const refreshAll = useCallback(() => { - if (!serverId || snapshot?.connectionStatus !== "online") { + if (!isActive || !serverId || connectionStatus !== "online") { return; } void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined); - }, [runtime, serverId, snapshot?.connectionStatus]); + }, [connectionStatus, isActive, runtime, serverId]); - const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot)); + const isDirectoryLoading = + isActive && + Boolean(serverId) && + (directoryStatus === "initial_loading" || directoryStatus === "revalidating"); const isInitialLoad = isDirectoryLoading && !hasAnyData; const isRevalidating = isDirectoryLoading && hasAnyData; diff --git a/packages/app/src/runtime/activity/index.ts b/packages/app/src/runtime/activity/index.ts new file mode 100644 index 000000000..f740e744d --- /dev/null +++ b/packages/app/src/runtime/activity/index.ts @@ -0,0 +1,9 @@ +export type { + ActivityFlushHandle, + AgentLastActivityCoalescer, + AgentLastActivityCommitter, + AgentLastActivityUpdates, +} from "./types"; + +export { scheduleAgentLastActivityFlush } from "./last-activity-scheduler"; +export { createAgentLastActivityCoalescer } from "./last-activity-coalescer"; diff --git a/packages/app/src/runtime/activity/last-activity-coalescer.ts b/packages/app/src/runtime/activity/last-activity-coalescer.ts new file mode 100644 index 000000000..fc7e30a73 --- /dev/null +++ b/packages/app/src/runtime/activity/last-activity-coalescer.ts @@ -0,0 +1,67 @@ +import { scheduleAgentLastActivityFlush } from "./last-activity-scheduler"; +import type { + ActivityFlushHandle, + AgentLastActivityCoalescer, + AgentLastActivityCommitter, +} from "./types"; + +export function createAgentLastActivityCoalescer(): AgentLastActivityCoalescer { + let pending = new Map(); + let scheduledFlush: ActivityFlushHandle | null = null; + let committer: AgentLastActivityCommitter | null = null; + + const cancelScheduledFlush = () => { + if (!scheduledFlush) { + return; + } + scheduledFlush.cancel(); + scheduledFlush = null; + }; + + const flushNow = () => { + cancelScheduledFlush(); + if (pending.size === 0) { + return; + } + const updates = pending; + pending = new Map(); + committer?.(updates); + }; + + const scheduleFlush = () => { + if (scheduledFlush) { + return; + } + scheduledFlush = scheduleAgentLastActivityFlush(() => { + scheduledFlush = null; + flushNow(); + }); + }; + + return { + setCommitter(nextCommitter) { + committer = nextCommitter; + }, + + enqueue(agentId, timestamp) { + const current = pending.get(agentId); + if (current && current.getTime() >= timestamp.getTime()) { + return; + } + pending.set(agentId, timestamp); + scheduleFlush(); + }, + + flushNow, + + deletePending(agentId) { + pending.delete(agentId); + }, + + dispose() { + cancelScheduledFlush(); + pending.clear(); + committer = null; + }, + }; +} diff --git a/packages/app/src/runtime/activity/last-activity-scheduler.ts b/packages/app/src/runtime/activity/last-activity-scheduler.ts new file mode 100644 index 000000000..a1110e85c --- /dev/null +++ b/packages/app/src/runtime/activity/last-activity-scheduler.ts @@ -0,0 +1,26 @@ +import type { ActivityFlushHandle } from "./types"; + +const DEFAULT_ACTIVITY_FLUSH_INTERVAL_MS = 16; + +export function scheduleAgentLastActivityFlush( + callback: () => void, + fallbackIntervalMs = DEFAULT_ACTIVITY_FLUSH_INTERVAL_MS +): ActivityFlushHandle { + if (typeof requestAnimationFrame === "function") { + const handle = requestAnimationFrame(callback); + return { + cancel: () => { + if (typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(handle); + } + }, + }; + } + + const handle = setTimeout(callback, fallbackIntervalMs); + return { + cancel: () => { + clearTimeout(handle); + }, + }; +} diff --git a/packages/app/src/runtime/activity/types.ts b/packages/app/src/runtime/activity/types.ts new file mode 100644 index 000000000..59371add3 --- /dev/null +++ b/packages/app/src/runtime/activity/types.ts @@ -0,0 +1,17 @@ +export type AgentLastActivityUpdates = Map; + +export type AgentLastActivityCommitter = ( + updates: AgentLastActivityUpdates +) => void; + +export interface ActivityFlushHandle { + cancel: () => void; +} + +export interface AgentLastActivityCoalescer { + setCommitter: (committer: AgentLastActivityCommitter | null) => void; + enqueue: (agentId: string, timestamp: Date) => void; + flushNow: () => void; + deletePending: (agentId: string) => void; + dispose: () => void; +} diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 0b84fc904..6fc05684e 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react"; import { DaemonClient, type ConnectionState, + type DaemonClientDiagnosticsEvent, type FetchAgentsOptions, } from "@server/client/daemon-client"; import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context"; @@ -19,6 +20,10 @@ import { import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport"; import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { useSessionStore } from "@/stores/session-store"; +import { + recordDaemonClientDiagnostics, + recordHostRuntimeCreateClient, +} from "@/runtime/perf-diagnostics/host-runtime-diagnostics"; export type HostRuntimeConnectionStatus = | "idle" @@ -375,12 +380,22 @@ function selectInitialConnectionId(host: HostProfile): string | null { function createDefaultDeps(): HostRuntimeControllerDeps { return { createClient: ({ host, connection, clientId, runtimeGeneration }) => { + recordHostRuntimeCreateClient({ + serverId: host.serverId, + connectionType: connection.type, + endpoint: + connection.type === "direct" + ? connection.endpoint + : connection.relayEndpoint, + }); const tauriTransportFactory = createTauriWebSocketTransportFactory(); const base = { suppressSendErrors: true, clientId, clientType: "mobile" as const, runtimeGeneration, + onDiagnosticsEvent: (event: DaemonClientDiagnosticsEvent) => + recordDaemonClientDiagnostics(host.serverId, event), ...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}), @@ -1099,10 +1114,10 @@ export class HostRuntimeStore { filter: input.filter ?? { labels: { ui: "true" } }, ...(input.subscribe ? { subscribe: input.subscribe } : {}), }); - const { agents } = applyFetchedAgentDirectory({ + const agents = applyFetchedAgentDirectory({ serverId: input.serverId, entries: payload.entries, - }); + }).agents; controller.markAgentDirectorySyncReady(); return { agents, diff --git a/packages/app/src/runtime/perf-diagnostics/debug-tools.ts b/packages/app/src/runtime/perf-diagnostics/debug-tools.ts new file mode 100644 index 000000000..f4818e80b --- /dev/null +++ b/packages/app/src/runtime/perf-diagnostics/debug-tools.ts @@ -0,0 +1,38 @@ +import { + consumePersistedPerfDiagnosticReports, + getPerfDiagnosticsSnapshot, + isPerfDiagnosticsEnabled, + peekPersistedPerfDiagnosticReports, +} from "./engine"; + +export function installPerfDiagnosticsDebugTools(): () => void { + if (!isPerfDiagnosticsEnabled()) { + return () => {}; + } + + ( + globalThis as { + __PASEO_PERF_DIAGNOSTICS_DEBUG__?: { + snapshot: (limit?: number) => ReturnType; + consumeReports: () => Promise< + Awaited> + >; + peekReports: () => Promise< + Awaited> + >; + }; + } + ).__PASEO_PERF_DIAGNOSTICS_DEBUG__ = { + snapshot: (limit?: number) => getPerfDiagnosticsSnapshot(limit ?? 120), + consumeReports: () => consumePersistedPerfDiagnosticReports(), + peekReports: () => peekPersistedPerfDiagnosticReports(), + }; + + return () => { + ( + globalThis as { + __PASEO_PERF_DIAGNOSTICS_DEBUG__?: unknown; + } + ).__PASEO_PERF_DIAGNOSTICS_DEBUG__ = undefined; + }; +} diff --git a/packages/app/src/runtime/perf-diagnostics/engine.ts b/packages/app/src/runtime/perf-diagnostics/engine.ts new file mode 100644 index 000000000..796b4cf2a --- /dev/null +++ b/packages/app/src/runtime/perf-diagnostics/engine.ts @@ -0,0 +1,311 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { Platform } from "react-native"; +import { getNowMs } from "@/utils/perf"; +import type { + PerfDiagnosticBreadcrumb, + PerfDiagnosticFields, + PerfDiagnosticsReport, + PerfDiagnosticsSnapshot, + RecordPerfDiagnosticMarkOptions, +} from "./types"; + +const STORAGE_KEY = "paseo:perf-diagnostics:v1"; +const LEGACY_STORAGE_KEY = "paseo:js-hang-diagnostics:v1"; +const MONITOR_INTERVAL_MS = 100; +const STALL_THRESHOLD_MS = 250; +const SAMPLE_EVERY_N = 40; +const MAX_BREADCRUMBS = 600; +const BREADCRUMBS_PER_REPORT = 220; +const MAX_STORED_REPORTS = 20; + +const state = { + breadcrumbs: [] as PerfDiagnosticBreadcrumb[], + sampleCursor: 0, + monitorHandle: null as ReturnType | null, + monitorLastTickMs: 0, + monitorScope: null as string | null, + monitorRefCount: 0, + loadLogged: false, + persistQueue: Promise.resolve(), +}; + +function isPrimitive(value: unknown): value is string | number | boolean | null { + return ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +function sanitizeFieldValue(value: unknown): unknown { + if (isPrimitive(value)) { + if (typeof value === "string" && value.length > 180) { + return `${value.slice(0, 180)}...`; + } + return value; + } + if (value instanceof Date) { + return value.toISOString(); + } + if (Array.isArray(value)) { + return `[array:${value.length}]`; + } + if (value instanceof Map) { + return `[map:${value.size}]`; + } + if (value instanceof Set) { + return `[set:${value.size}]`; + } + if (typeof value === "bigint") { + return value.toString(); + } + if (value === undefined) { + return "[undefined]"; + } + return "[object]"; +} + +function sanitizeFields( + fields?: PerfDiagnosticFields +): PerfDiagnosticFields | undefined { + if (!fields) { + return undefined; + } + const next: PerfDiagnosticFields = {}; + let count = 0; + for (const [key, value] of Object.entries(fields)) { + if (count >= 16) { + next.__truncated__ = true; + break; + } + next[key] = sanitizeFieldValue(value); + count += 1; + } + return next; +} + +function shouldEnableDiagnostics(): boolean { + const globalFlag = ( + globalThis as { + __PASEO_PERF_DIAGNOSTICS__?: unknown; + __PASEO_JS_HANG_DIAGNOSTICS__?: unknown; + } + ).__PASEO_PERF_DIAGNOSTICS__; + const legacyFlag = ( + globalThis as { + __PASEO_PERF_DIAGNOSTICS__?: unknown; + __PASEO_JS_HANG_DIAGNOSTICS__?: unknown; + } + ).__PASEO_JS_HANG_DIAGNOSTICS__; + if (typeof globalFlag === "boolean") { + return globalFlag; + } + if (typeof legacyFlag === "boolean") { + return legacyFlag; + } + const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); + return Platform.OS === "android" || isDev; +} + +function shouldSample(): boolean { + state.sampleCursor += 1; + if (state.sampleCursor >= SAMPLE_EVERY_N) { + state.sampleCursor = 0; + return true; + } + return false; +} + +function pushBreadcrumb(item: PerfDiagnosticBreadcrumb): void { + state.breadcrumbs.push(item); + if (state.breadcrumbs.length > MAX_BREADCRUMBS) { + state.breadcrumbs.splice(0, state.breadcrumbs.length - MAX_BREADCRUMBS); + } +} + +function safeParseReports(raw: string | null): PerfDiagnosticsReport[] { + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter( + (entry) => entry && typeof entry === "object" + ) as PerfDiagnosticsReport[]; + } catch { + return []; + } +} + +async function readPersistedReports(): Promise { + const stored = safeParseReports(await AsyncStorage.getItem(STORAGE_KEY)); + if (stored.length > 0) { + return stored; + } + const legacy = safeParseReports(await AsyncStorage.getItem(LEGACY_STORAGE_KEY)); + if (legacy.length > 0) { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(legacy)); + await AsyncStorage.removeItem(LEGACY_STORAGE_KEY); + } + return legacy; +} + +async function persistReport(report: PerfDiagnosticsReport): Promise { + state.persistQueue = state.persistQueue + .then(async () => { + const existing = await readPersistedReports(); + existing.push(report); + if (existing.length > MAX_STORED_REPORTS) { + existing.splice(0, existing.length - MAX_STORED_REPORTS); + } + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(existing)); + }) + .catch((error) => { + console.warn("[PerfDiagnostics] Failed to persist report", { + message: error instanceof Error ? error.message : String(error), + }); + }); + await state.persistQueue; +} + +function buildReport( + scope: string, + atMs: number, + lagMs: number +): PerfDiagnosticsReport { + const id = `${Math.round(atMs)}-${Math.floor(Math.random() * 1_000_000)}`; + const breadcrumbs = state.breadcrumbs.slice(-BREADCRUMBS_PER_REPORT); + const wallTimeMs = Date.now(); + return { + id, + scope, + atMs, + wallTimeMs, + wallTimeIso: new Date(wallTimeMs).toISOString(), + lagMs, + platform: Platform.OS, + breadcrumbs, + }; +} + +export function recordPerfDiagnosticMark( + name: string, + fields?: PerfDiagnosticFields, + options?: RecordPerfDiagnosticMarkOptions +): void { + if (!shouldEnableDiagnostics()) { + return; + } + const force = options?.force === true; + if (!force && !shouldSample()) { + return; + } + pushBreadcrumb({ + atMs: getNowMs(), + kind: "mark", + name, + fields: sanitizeFields(fields), + }); +} + +export function installPerfDiagnosticsMonitor(scope: string): () => void { + if (!shouldEnableDiagnostics()) { + return () => {}; + } + + if (state.monitorRefCount === 0) { + state.monitorScope = scope; + state.monitorLastTickMs = getNowMs(); + state.monitorHandle = setInterval(() => { + const nowMs = getNowMs(); + const lagMs = nowMs - state.monitorLastTickMs - MONITOR_INTERVAL_MS; + if (lagMs >= STALL_THRESHOLD_MS) { + const report = buildReport(state.monitorScope ?? scope, nowMs, lagMs); + recordPerfDiagnosticMark( + "perf.stall_detected", + { + scope: report.scope, + lagMs: Math.round(lagMs), + reportId: report.id, + breadcrumbs: report.breadcrumbs.length, + }, + { force: true } + ); + console.warn("[PerfDiagnostics] JS stall detected", { + scope: report.scope, + lagMs: Math.round(lagMs), + reportId: report.id, + breadcrumbs: report.breadcrumbs.length, + }); + void persistReport(report); + } + state.monitorLastTickMs = nowMs; + }, MONITOR_INTERVAL_MS); + } + + state.monitorRefCount += 1; + + if (!state.loadLogged) { + state.loadLogged = true; + void readPersistedReports() + .then((reports) => { + if (reports.length > 0) { + console.warn("[PerfDiagnostics] Recovered persisted stall reports", { + count: reports.length, + latestReportId: reports[reports.length - 1]?.id ?? null, + }); + } + }) + .catch(() => undefined); + } + + return () => { + state.monitorRefCount = Math.max(0, state.monitorRefCount - 1); + if (state.monitorRefCount === 0 && state.monitorHandle) { + clearInterval(state.monitorHandle); + state.monitorHandle = null; + state.monitorScope = null; + } + }; +} + +export async function consumePersistedPerfDiagnosticReports(): Promise< + PerfDiagnosticsReport[] +> { + const reports = await readPersistedReports(); + await AsyncStorage.removeItem(STORAGE_KEY); + await AsyncStorage.removeItem(LEGACY_STORAGE_KEY); + return reports; +} + +export async function peekPersistedPerfDiagnosticReports(): Promise< + PerfDiagnosticsReport[] +> { + return readPersistedReports(); +} + +export function isPerfDiagnosticsEnabled(): boolean { + return shouldEnableDiagnostics(); +} + +export function getPerfDiagnosticBreadcrumbs( + limit = 120 +): PerfDiagnosticBreadcrumb[] { + if (limit <= 0) { + return []; + } + return state.breadcrumbs.slice(-Math.floor(limit)); +} + +export function getPerfDiagnosticsSnapshot(limit = 120): PerfDiagnosticsSnapshot { + return { + enabled: shouldEnableDiagnostics(), + monitorScope: state.monitorScope, + breadcrumbCount: state.breadcrumbs.length, + breadcrumbs: getPerfDiagnosticBreadcrumbs(limit), + }; +} diff --git a/packages/app/src/runtime/perf-diagnostics/host-runtime-diagnostics.ts b/packages/app/src/runtime/perf-diagnostics/host-runtime-diagnostics.ts new file mode 100644 index 000000000..1d445defb --- /dev/null +++ b/packages/app/src/runtime/perf-diagnostics/host-runtime-diagnostics.ts @@ -0,0 +1,121 @@ +import type { DaemonClientDiagnosticsEvent } from "@server/client/daemon-client"; +import { recordPerfDiagnosticMark } from "./engine"; + +let fastTransportSampleCursor = 0; +const transportRateByServer = new Map< + string, + { + startedAtMs: number; + messageCount: number; + bytes: number; + errorCount: number; + slowCount: number; + } +>(); + +function shouldSampleFastTransportEvent(): boolean { + fastTransportSampleCursor += 1; + if (fastTransportSampleCursor >= 50) { + fastTransportSampleCursor = 0; + return true; + } + return false; +} + +export function recordHostRuntimeCreateClient(params: { + serverId: string; + connectionType: "direct" | "relay"; + endpoint: string; +}): void { + recordPerfDiagnosticMark( + "host_runtime.create_client", + { + serverId: params.serverId, + connectionType: params.connectionType, + endpoint: params.endpoint, + }, + { force: true } + ); +} + +export function recordDaemonClientDiagnostics( + serverId: string, + event: DaemonClientDiagnosticsEvent +): void { + if (event.type === "transport_message_timing") { + const nowWallTimeMs = Date.now(); + const current = + transportRateByServer.get(serverId) ?? { + startedAtMs: nowWallTimeMs, + messageCount: 0, + bytes: 0, + errorCount: 0, + slowCount: 0, + }; + current.messageCount += 1; + current.bytes += event.payloadBytes; + if (event.outcome !== "ok") { + current.errorCount += 1; + } + if (event.totalMs >= 8) { + current.slowCount += 1; + } + if (nowWallTimeMs - current.startedAtMs >= 1000) { + recordPerfDiagnosticMark( + "daemon_client.message_rate", + { + serverId, + windowMs: nowWallTimeMs - current.startedAtMs, + messageCount: current.messageCount, + bytes: current.bytes, + errorCount: current.errorCount, + slowCount: current.slowCount, + }, + { force: current.errorCount > 0 || current.slowCount > 0 } + ); + current.startedAtMs = nowWallTimeMs; + current.messageCount = 0; + current.bytes = 0; + current.errorCount = 0; + current.slowCount = 0; + } + transportRateByServer.set(serverId, current); + } + + if (event.type === "transport_binary_frame") { + if (event.payloadBytes < 16_384) { + return; + } + recordPerfDiagnosticMark( + "daemon_client.binary_frame", + { + serverId, + channel: event.channel, + messageType: event.messageType, + payloadBytes: event.payloadBytes, + }, + { force: true } + ); + return; + } + + const isSlow = + event.totalMs >= 8 || event.parseMs >= 4 || event.validateMs >= 4; + const isError = event.outcome !== "ok"; + if (!isSlow && !isError && !shouldSampleFastTransportEvent()) { + return; + } + recordPerfDiagnosticMark( + "daemon_client.transport_message", + { + serverId, + messageType: event.messageType, + outcome: event.outcome, + payloadBytes: event.payloadBytes, + parseMs: Math.round(event.parseMs * 100) / 100, + validateMs: Math.round(event.validateMs * 100) / 100, + totalMs: Math.round(event.totalMs * 100) / 100, + }, + { force: isSlow || isError } + ); +} diff --git a/packages/app/src/runtime/perf-diagnostics/index.ts b/packages/app/src/runtime/perf-diagnostics/index.ts new file mode 100644 index 000000000..cbb7f0f26 --- /dev/null +++ b/packages/app/src/runtime/perf-diagnostics/index.ts @@ -0,0 +1,22 @@ +export type { + PerfDiagnosticBreadcrumb, + PerfDiagnosticFields, + PerfDiagnosticsReport, + PerfDiagnosticsSnapshot, + PerfDiagnosticsReporter, + RecordPerfDiagnosticMarkOptions, +} from "./types"; + +export { + consumePersistedPerfDiagnosticReports, + getPerfDiagnosticBreadcrumbs, + getPerfDiagnosticsSnapshot, + installPerfDiagnosticsMonitor, + isPerfDiagnosticsEnabled, + peekPersistedPerfDiagnosticReports, + recordPerfDiagnosticMark, +} from "./engine"; + +export { getPerfDiagnosticsReporter } from "./reporter"; +export { PerfDiagnosticsProvider } from "./perf-diagnostics-provider"; +export { usePerfDiagnostics } from "./use-perf-diagnostics"; diff --git a/packages/app/src/runtime/perf-diagnostics/perf-diagnostics-context.ts b/packages/app/src/runtime/perf-diagnostics/perf-diagnostics-context.ts new file mode 100644 index 000000000..703c612c2 --- /dev/null +++ b/packages/app/src/runtime/perf-diagnostics/perf-diagnostics-context.ts @@ -0,0 +1,21 @@ +import { createContext } from "react"; +import type { PerfDiagnosticsReporter, PerfDiagnosticsSnapshot } from "./types"; + +const EMPTY_SNAPSHOT: PerfDiagnosticsSnapshot = { + enabled: false, + monitorScope: null, + breadcrumbCount: 0, + breadcrumbs: [], +}; + +const NOOP_REPORTER: PerfDiagnosticsReporter = { + mark: () => {}, + installMonitor: () => () => {}, + consumeReports: async () => [], + peekReports: async () => [], + isEnabled: () => false, + getSnapshot: () => EMPTY_SNAPSHOT, +}; + +export const PerfDiagnosticsContext = + createContext(NOOP_REPORTER); diff --git a/packages/app/src/runtime/perf-diagnostics/perf-diagnostics-provider.tsx b/packages/app/src/runtime/perf-diagnostics/perf-diagnostics-provider.tsx new file mode 100644 index 000000000..907950ee7 --- /dev/null +++ b/packages/app/src/runtime/perf-diagnostics/perf-diagnostics-provider.tsx @@ -0,0 +1,53 @@ +import { useEffect, useMemo, type ReactNode } from "react"; +import { installPerfDiagnosticsDebugTools } from "./debug-tools"; +import { PerfDiagnosticsContext } from "./perf-diagnostics-context"; +import { getPerfDiagnosticsReporter } from "./reporter"; + +interface PerfDiagnosticsProviderProps { + children: ReactNode; + scope?: string; +} + +export function PerfDiagnosticsProvider({ + children, + scope = "root_layout", +}: PerfDiagnosticsProviderProps) { + const reporter = useMemo(() => getPerfDiagnosticsReporter(), []); + + useEffect(() => { + if (!reporter.isEnabled()) { + return; + } + + const removeDebugTools = installPerfDiagnosticsDebugTools(); + const stopMonitor = reporter.installMonitor(scope); + + void reporter + .peekReports() + .then((reports) => { + if (reports.length === 0) { + return; + } + const latest = reports[reports.length - 1]; + console.warn("[PerfDiagnostics] Loaded persisted stall reports", { + count: reports.length, + latestReportId: latest?.id ?? null, + latestWallTime: latest?.wallTimeIso ?? null, + latestLagMs: latest ? Math.round(latest.lagMs) : null, + latestBreadcrumbs: latest?.breadcrumbs.length ?? null, + }); + }) + .catch(() => undefined); + + return () => { + removeDebugTools(); + stopMonitor(); + }; + }, [scope, reporter]); + + return ( + + {children} + + ); +} diff --git a/packages/app/src/runtime/perf-diagnostics/reporter.ts b/packages/app/src/runtime/perf-diagnostics/reporter.ts new file mode 100644 index 000000000..490b339b5 --- /dev/null +++ b/packages/app/src/runtime/perf-diagnostics/reporter.ts @@ -0,0 +1,22 @@ +import { + consumePersistedPerfDiagnosticReports, + getPerfDiagnosticsSnapshot, + installPerfDiagnosticsMonitor, + isPerfDiagnosticsEnabled, + peekPersistedPerfDiagnosticReports, + recordPerfDiagnosticMark, +} from "./engine"; +import type { PerfDiagnosticsReporter } from "./types"; + +const reporter: PerfDiagnosticsReporter = { + mark: recordPerfDiagnosticMark, + installMonitor: installPerfDiagnosticsMonitor, + consumeReports: consumePersistedPerfDiagnosticReports, + peekReports: peekPersistedPerfDiagnosticReports, + isEnabled: isPerfDiagnosticsEnabled, + getSnapshot: getPerfDiagnosticsSnapshot, +}; + +export function getPerfDiagnosticsReporter(): PerfDiagnosticsReporter { + return reporter; +} diff --git a/packages/app/src/runtime/perf-diagnostics/types.ts b/packages/app/src/runtime/perf-diagnostics/types.ts new file mode 100644 index 000000000..ab8245244 --- /dev/null +++ b/packages/app/src/runtime/perf-diagnostics/types.ts @@ -0,0 +1,44 @@ +export type PerfDiagnosticFields = Record; + +export interface PerfDiagnosticBreadcrumb { + atMs: number; + kind: "mark" | "span"; + name: string; + durationMs?: number; + fields?: PerfDiagnosticFields; +} + +export interface PerfDiagnosticsReport { + id: string; + scope: string; + atMs: number; + wallTimeMs: number; + wallTimeIso: string; + lagMs: number; + platform: string; + breadcrumbs: PerfDiagnosticBreadcrumb[]; +} + +export interface RecordPerfDiagnosticMarkOptions { + force?: boolean; +} + +export interface PerfDiagnosticsSnapshot { + enabled: boolean; + monitorScope: string | null; + breadcrumbCount: number; + breadcrumbs: PerfDiagnosticBreadcrumb[]; +} + +export interface PerfDiagnosticsReporter { + mark: ( + name: string, + fields?: PerfDiagnosticFields, + options?: RecordPerfDiagnosticMarkOptions + ) => void; + installMonitor: (scope: string) => () => void; + consumeReports: () => Promise; + peekReports: () => Promise; + isEnabled: () => boolean; + getSnapshot: (limit?: number) => PerfDiagnosticsSnapshot; +} diff --git a/packages/app/src/runtime/perf-diagnostics/use-perf-diagnostics.ts b/packages/app/src/runtime/perf-diagnostics/use-perf-diagnostics.ts new file mode 100644 index 000000000..65db3e1c1 --- /dev/null +++ b/packages/app/src/runtime/perf-diagnostics/use-perf-diagnostics.ts @@ -0,0 +1,7 @@ +import { useContext } from "react"; +import { PerfDiagnosticsContext } from "./perf-diagnostics-context"; +import type { PerfDiagnosticsReporter } from "./types"; + +export function usePerfDiagnostics(): PerfDiagnosticsReporter { + return useContext(PerfDiagnosticsContext); +} diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 990d89ef1..f02951ea0 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -24,6 +24,10 @@ import type { ServerCapabilities, } from "@server/shared/messages"; import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; +import { + createAgentLastActivityCoalescer, + type AgentLastActivityCommitter, +} from "@/runtime/activity"; // Re-export types that were in session-context export type MessageEntry = @@ -257,6 +261,10 @@ interface SessionStoreActions { // Agent activity timestamps setAgentLastActivity: (agentId: string, timestamp: Date) => void; + setAgentLastActivityBatch: ( + updates: Map | ((prev: Map) => Map) + ) => void; + flushAgentLastActivity: () => void; // Permissions setPendingPermissions: (serverId: string, perms: Map | ((prev: Map) => Map)) => void; @@ -278,6 +286,7 @@ type SessionStore = SessionStoreState & SessionStoreActions; const SESSION_STORE_LOG_TAG = "[SessionStore]"; let sessionStoreUpdateCount = 0; +const agentLastActivityCoalescer = createAgentLastActivityCoalescer(); function logSessionStoreUpdate( type: string, @@ -332,9 +341,34 @@ function areServerCapabilitiesEqual( } export const useSessionStore = create()( - subscribeWithSelector((set, get) => ({ - sessions: {}, - agentLastActivity: new Map(), + subscribeWithSelector((set, get) => { + const commitActivityUpdates: AgentLastActivityCommitter = (updates) => { + set((prev) => { + let nextActivity: Map | null = null; + for (const [agentId, timestamp] of updates.entries()) { + const current = prev.agentLastActivity.get(agentId); + if (current && current.getTime() >= timestamp.getTime()) { + continue; + } + if (!nextActivity) { + nextActivity = new Map(prev.agentLastActivity); + } + nextActivity.set(agentId, timestamp); + } + if (!nextActivity) { + return prev; + } + return { + ...prev, + agentLastActivity: nextActivity, + }; + }); + }; + agentLastActivityCoalescer.setCommitter(commitActivityUpdates); + + return { + sessions: {}, + agentLastActivity: new Map(), // Session management initializeSession: (serverId, client, audioPlayer) => { @@ -354,14 +388,34 @@ export const useSessionStore = create()( }, clearSession: (serverId) => { + agentLastActivityCoalescer.flushNow(); set((prev) => { - if (!(serverId in prev.sessions)) { + const session = prev.sessions[serverId]; + if (!session) { return prev; } logSessionStoreUpdate("clearSession", serverId); const nextSessions = { ...prev.sessions }; delete nextSessions[serverId]; - return { ...prev, sessions: nextSessions }; + let nextActivity = prev.agentLastActivity; + if (session.agents.size > 0) { + const candidate = new Map(prev.agentLastActivity); + let changed = false; + for (const agentId of session.agents.keys()) { + if (candidate.delete(agentId)) { + changed = true; + } + agentLastActivityCoalescer.deletePending(agentId); + } + if (changed) { + nextActivity = candidate; + } + } + return { + ...prev, + sessions: nextSessions, + agentLastActivity: nextActivity, + }; }); }, @@ -714,20 +768,49 @@ export const useSessionStore = create()( // Agent activity timestamps (top-level, does NOT mutate session object) setAgentLastActivity: (agentId, timestamp) => { + agentLastActivityCoalescer.enqueue(agentId, timestamp); + }, + + setAgentLastActivityBatch: (updates) => { set((prev) => { - const currentTimestamp = prev.agentLastActivity.get(agentId); - if (currentTimestamp && currentTimestamp.getTime() === timestamp.getTime()) { + const nextActivity = + typeof updates === "function" + ? updates(prev.agentLastActivity) + : updates; + if (nextActivity === prev.agentLastActivity) { + return prev; + } + if (nextActivity.size === 0) { + if (prev.agentLastActivity.size === 0) { + return prev; + } + return { + ...prev, + agentLastActivity: new Map(), + }; + } + let changed = false; + for (const [agentId, timestamp] of nextActivity.entries()) { + const currentTimestamp = prev.agentLastActivity.get(agentId); + if (!currentTimestamp || currentTimestamp.getTime() !== timestamp.getTime()) { + changed = true; + break; + } + } + if (!changed && nextActivity.size === prev.agentLastActivity.size) { return prev; } - const nextActivity = new Map(prev.agentLastActivity); - nextActivity.set(agentId, timestamp); return { ...prev, - agentLastActivity: nextActivity, + agentLastActivity: new Map(nextActivity), }; }); }, + flushAgentLastActivity: () => { + agentLastActivityCoalescer.flushNow(); + }, + // Permissions setPendingPermissions: (serverId, perms) => { set((prev) => { @@ -840,5 +923,6 @@ export const useSessionStore = create()( } return entries; }, - })) + }; + }) ); diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index 414e653e5..c58dd8345 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -48,11 +48,17 @@ export function applyFetchedAgentDirectory(input: { entries: FetchAgentsEntry[]; }): { agents: Map } { const { agents, pendingPermissions } = buildAgentDirectoryState(input); + const store = useSessionStore.getState(); + store.setAgents(input.serverId, agents); + + const lastActivityByAgentId = new Map(); for (const agent of agents.values()) { - store.setAgentLastActivity(agent.id, agent.lastActivityAt); + lastActivityByAgentId.set(agent.id, agent.lastActivityAt); } + store.setAgentLastActivityBatch(lastActivityByAgentId); + store.setPendingPermissions(input.serverId, pendingPermissions); store.setInitializingAgents(input.serverId, new Map()); store.setHasHydratedAgents(input.serverId, true); diff --git a/packages/app/src/utils/perf-diagnostics.ts b/packages/app/src/utils/perf-diagnostics.ts new file mode 100644 index 000000000..adfcc02ff --- /dev/null +++ b/packages/app/src/utils/perf-diagnostics.ts @@ -0,0 +1,20 @@ +// Compatibility shim while callers move to `@/runtime/perf-diagnostics`. +// Keep this file side-effect free. +export { + consumePersistedPerfDiagnosticReports, + getPerfDiagnosticBreadcrumbs, + getPerfDiagnosticsSnapshot, + installPerfDiagnosticsMonitor, + isPerfDiagnosticsEnabled, + peekPersistedPerfDiagnosticReports, + recordPerfDiagnosticMark, +} from "@/runtime/perf-diagnostics"; + +export type { + PerfDiagnosticBreadcrumb, + PerfDiagnosticFields, + PerfDiagnosticsReport, + PerfDiagnosticsSnapshot, + PerfDiagnosticsReporter, + RecordPerfDiagnosticMarkOptions, +} from "@/runtime/perf-diagnostics"; diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index ace6e0ecb..cb2782353 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ alias: { "@": path.resolve(__dirname, "src"), "@server": path.resolve(__dirname, "../server/src"), + "react-native": "react-native-web", }, }, }); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index efe9b6fe5..b7a85e0f3 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -97,6 +97,13 @@ const consoleLogger: Logger = { error: (obj, msg) => console.error(msg, obj), } +function getNowMs(): number { + if (typeof performance !== 'undefined' && typeof performance.now === 'function') { + return performance.now() + } + return Date.now() +} + export type { DaemonTransport, DaemonTransportFactory, @@ -163,8 +170,26 @@ export type DaemonClientConfig = { baseDelayMs?: number maxDelayMs?: number } + onDiagnosticsEvent?: (event: DaemonClientDiagnosticsEvent) => void } +export type DaemonClientDiagnosticsEvent = + | { + type: 'transport_message_timing' + messageType: string + payloadBytes: number + parseMs: number + validateMs: number + totalMs: number + outcome: 'ok' | 'parse_error' | 'validation_error' + } + | { + type: 'transport_binary_frame' + channel: BinaryMuxChannel + messageType: number + payloadBytes: number + } + export type SendMessageOptions = { messageId?: string images?: Array<{ data: string; mimeType: string }> @@ -432,6 +457,14 @@ export class DaemonClient { }) } + private emitDiagnosticsEvent(event: DaemonClientDiagnosticsEvent): void { + try { + this.config.onDiagnosticsEvent?.(event) + } catch { + // Diagnostics hooks must never break daemon message handling. + } + } + // ============================================================================ // Connection // ============================================================================ @@ -2638,12 +2671,19 @@ export class DaemonClient { } private handleTransportMessage(data: unknown): void { + const startedAtMs = getNowMs() const rawData = data && typeof data === 'object' && 'data' in data ? (data as { data: unknown }).data : data const rawBytes = asUint8Array(rawData) if (rawBytes) { const frame = decodeBinaryMuxFrame(rawBytes) if (frame) { + this.emitDiagnosticsEvent({ + type: 'transport_binary_frame', + channel: frame.channel, + messageType: frame.messageType, + payloadBytes: frame.payload?.byteLength ?? 0, + }) this.handleBinaryFrame(frame) return } @@ -2654,19 +2694,52 @@ export class DaemonClient { } let parsedJson: unknown + let parseMs = 0 try { + const parseStartedAtMs = getNowMs() parsedJson = JSON.parse(payload) + parseMs = getNowMs() - parseStartedAtMs } catch { + this.emitDiagnosticsEvent({ + type: 'transport_message_timing', + messageType: 'unknown', + payloadBytes: payload.length, + parseMs: 0, + validateMs: 0, + totalMs: getNowMs() - startedAtMs, + outcome: 'parse_error', + }) return } + const validateStartedAtMs = getNowMs() const parsed = WSOutboundMessageSchema.safeParse(parsedJson) + const validateMs = getNowMs() - validateStartedAtMs if (!parsed.success) { const msgType = (parsedJson as { type?: string })?.type ?? 'unknown' + this.emitDiagnosticsEvent({ + type: 'transport_message_timing', + messageType: msgType, + payloadBytes: payload.length, + parseMs, + validateMs, + totalMs: getNowMs() - startedAtMs, + outcome: 'validation_error', + }) this.logger.warn({ msgType, error: parsed.error.message }, 'Message validation failed') return } + this.emitDiagnosticsEvent({ + type: 'transport_message_timing', + messageType: parsed.data.type, + payloadBytes: payload.length, + parseMs, + validateMs, + totalMs: getNowMs() - startedAtMs, + outcome: 'ok', + }) + if (parsed.data.type === 'pong') { return }