feat(desktop): add temporary renderer profiling harness - #6162
Draft
wpfleger96 wants to merge 1 commit into
Draft
Conversation
wpfleger96
force-pushed
the
duncan/renderer-profiling-harness
branch
5 times, most recently
from
August 21, 2026 19:24
29362ef to
1ea0a29
Compare
Add a bounded, local-only JSONL profiler for renderer stalls, input latency, Tauri IPC, relay waits, and accumulator growth. The harness is a diagnostic branch for production-relay drive sessions and is not intended to merge. Keep the IPC interception at the Vite module layer so direct Tauri core imports, external plugins, and built-in Tauri submodules are all observed without mutating the native non-configurable invoke bridge. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96
force-pushed
the
duncan/renderer-profiling-harness
branch
from
August 25, 2026 14:20
1ea0a29 to
b416a63
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
A passive, always-on renderer profiling harness. Records are ring-buffered in memory and flushed as JSONL to
<app-data>/profiling/session-<ts>.jsonlevery ~30s and onpagehide. The output lets us produce a ranked breakdown of wall-clock time during felt lag: renderer main-thread stalls vs. waiting on Rust IPC vs. waiting on the relay vs. accumulator growth.New, self-contained:
desktop/src/shared/profiling/{types.ts, drift.ts, recorder.ts, harness.ts, ipc.ts, tauriCoreProxy.ts, viteTauriCoreProxy.ts, harness.test.mjs, viteResolve.test.mjs}desktop/src-tauri/src/commands/profiling_log.rs(append-to-local-file command)Additive touch points only (no production code restructured):
commands/mod.rs,lib.rs— registerappend_profiling_logApp.tsx— onestartProfilingHarness(queryClient)call inCommunityQueryProviderobserverRelayStore.ts— one additivegetObserverStoreCensus()accessor (reads.length/.sizeonly, zero allocation)The four probes
stallwhen observed-minus-expected fire exceeds 50ms (WebKit has no Long Tasks API). Drift is scored only for intervals that both begin and end in the visible, focused foreground — a hidden/occluded/asleep webview defers timers, and that deferral must never be attributed to renderer work; the baseline is re-armed across background windows so the first foreground interval after a return cannot manufacture a phantom stall. A capture-phasekeydown/pointerdownlistener recordsinputwhen total felt latency exceeds 100ms. The listener captures its own receipt clock (performance.now()) synchronously, solatencyis receipt → next painted frame on a single clock basis; the pre-dispatch/OS-queue delay (event.timeStamp→ receipt) is split into a separatequeuedfield, dropped tonullwhen that delta is implausible (a cross-clock basis mismatch). These directly measure Will's felt symptoms.ipc(name + duration + outcome) for every invoke, andipc_pendingfor any invoke outstanding >10s — the deadlock detector Will asked for.relay_req(REQ→EOSE) for history fetches andrelay_pub(send→ack) for publishes.census: observer store sizes, archive totals, transcript count, React Query cache entry count, DOM node count. With the bounded-accumulator fixes now present from currentmain, this shows whether those stores remain bounded in a real long-lived session and whether any remaining growth correlates with lag.Probe 2 interception mechanism + coverage
invokeTauri()inshared/api/tauri.tsis not the only IPC path — ~30 files import rawinvoke/isTauri/Channelfrom@tauri-apps/api/coredirectly (heaviest:relayClientSession.ts,terminalClient.ts, huddle files,hostedCommunityApi.ts), every bundled Tauri plugin (plugin-opener,plugin-process, …) importsinvokefrom that same specifier, and Tauri's own API submodules (@tauri-apps/api/{event,app,window,webview}, ~35 source sites —plugin:event|*,plugin:app|*,plugin:window|*,plugin:webview|*) reach core through the relative import./core.js. The harness intercepts at the module layer, not the window property: apre-enforced ViteresolveIdhook (shared/profiling/viteTauriCoreProxy.ts) redirects both the bare@tauri-apps/api/corespecifier and the relative./core.jsreached from inside@tauri-apps/api/*toshared/profiling/tauriCoreProxy.ts, which re-exports the real core surface (export *) and overrides onlyinvokewith a profiling wrapper. The proxy reaches the real module through the@tauri-core-implspecifier, which the same hook maps straight to the actualcore.js— the non-recursive escape hatch. A bare alias could not see the relative form, so submodule IPC would silently bypass the probe; the resolver closes that. One hook covers all raw importers,invokeTauri(), every bundled plugin, and every built-in submodule.This seam is mandatory on native: Tauri 2.11.5's injected
core.jsdefineswindow.__TAURI_INTERNALS__.invokewithObject.defineProperty(..., { value: fn })and no flags, so it is non-configurable and non-writable — any redefine or assignment throwsTypeErrorand (through the harness's catch) would silently abort every probe. The real core module dereferenceswindow.__TAURI_INTERNALS__.invokeper call (core.js:202), so the module wrapper closes over the realinvokefunction and never writes the window property at all — passive against native, terminal-accessor, andmockIPCshapes alike. The wrapper reports to the harness's observer oncestartProfilingHarnessregisters it, and is a transparent pass-through until then (the proxy loads before the harness starts). The IPC probe's owninvoke("append_profiling_log", ...)also routes through it, so flush writes appear in the capture (trivially filterable bycmd). The pure decision logic lives inshared/profiling/ipc.ts(import-light, unit-tested against the native non-configurable descriptor shape) andshared/profiling/viteTauriCoreProxy.ts(the resolver predicate, unit-tested plus a real Vite resolve-graph test that proves a raw-core import, an external-plugin import, and a built-in submodule's relative./core.jseach resolve through the proxy once, with the escape resolving to the real module).Each probe now installs under its own guard in
startProfilingHarness, so one probe failing to install can never abort the others.Relay probe: singleton wrap, not source edits
The relay probe wraps the
relayClientsingleton's public methods fromharness.tsrather than editingrelayClientSession.ts. Two reasons: (1)relayClientSession.tsis grandfathered over the desktop file-size cap and may not grow, and (2) an instance-property wrap keeps all probe code out of production request paths. The instance wrap shadows the prototype, so internalthis.publishEventcalls are captured — which is why onlypublishEventis wrapped forrelay_pub(allsend*funnel through it; wrapping the wrappers too would double-count). Fetch methods wrapped forrelay_req:fetchChannelHistory,fetchChannelHistoryBefore,fetchAuxEventsByReference,fetchAuxDeletionEventsForAuxEvents,fetchEvents,fetchFirstEvent.Dropped probe: the event-buffer-depth-at-flush measurement from the original brief. It has no external seam on the singleton (it lives inside
relayClientSession.ts, which can't grow), and it was the lowest-value of the relay signals. REQ→EOSE and send→ack round-trips carry the "starved by the relay" signal on their own.Overhead and how it is bounded
RING_CAPACITY); oldest are dropped on overflow and the drop count is stamped on the next flush's first line so the analyzer can detect self-pressure. An unflushed or failed sink can never grow the harness into the thing it measures.spawn_blockingthread; the renderer only awaits the invoke, which is already async.Estimated overhead: negligible relative to the multi-second stalls being measured — a handful of records per second in the common case, one bounded array shift on overflow, one 2Hz timer, one 5s watchdog scan.
Sink safety
append_profiling_logvalidatesfile_stemstrictly tosession-<digits>(path-traversal-safe), creates<app-data>/profiling/on demand, and rotates the file aside with a millisecond suffix once it crosses 50 MiB so a long session cannot grow unbounded. The analyzer globssession-<ts>*.jsonland concatenates.Tests
harness.test.mjs(7 tests): ring-buffer bounds, drop-count stamping, sink-rejection resilience, drift math.profiling_log.rs(2 tests): stem validation acceptssession-<digits>, rejects traversal and malformed stems.Record schema
Every record carries a shared envelope:
t(performance.now(), monotonic),wall(Date.now()),sid(per-launch session id),up(ms since harness start), plus atypediscriminant. One JSONL line per record. Full field definitions indesktop/src/shared/profiling/types.ts.typestalldur(main-thread block ms; foreground-only)inputkind(keydown/pointerdown),latency(receipt → paint ms),queued(pre-dispatch delay ms, ornull)ipccmd,dur(ms),okipc_pendingcmd,age(ms)relay_reqop(fetch method),dur(ms),okrelay_pubop(publish method),dur(ms),okcensusobserverEvents,observerAgents,observerMaxPerAgent,archiveEvents,transcripts,queryCache,domNodesThe first line of any flush that dropped records carries an extra
droppedfield.