Status: Proposal (revised after Phase 0 measurement) — May 2026 Audience: Maintainers and LLM coding agents working on the codebase Companion files:
ARCHITECTURE_RULES.md— the enforceable rules derived from this reviewPERFORMANCE_TEST_SUMMARY.md— empirical evidence (5 controlled sessions, May 2026) underpinning the revised priorities belowIMPLEMENTATION_PLAN.md— running progress logRevision note (2026-05-11): Findings tables and the implementation plan have been updated to reflect Phase 0 measurement. Confirmed/demoted/promoted status is annotated against each finding. The biggest change is that P1 (telemetry firehose) is now empirically confirmed as the primary in-race memory-leak source, while L4 (reference-lap accumulation) is empirically disconfirmed as the dominant in-race leak source — although it remains a real correctness issue. A new finding L6 (multi-class startup baseline cost) has been added.
Revision note (2026-05-12): A follow-up test (Practice 2 in
PERFORMANCE_TEST_SUMMARY.md§3.6) captured a real-multiplayer session with a known driver-join burst. Result: A2/A3/L5/A7 are now empirically CONFIRMED as the primary leak source in busy online sessions, with quantification at ~5–6 MB of permanent app memory per joining driver, concentrated in renderers hosting driver-list-aware widgets and not released when drivers leave. A new finding P7 (driver-join leak) captures the user-facing symptom, and the SessionLifecycle / bridge-callback-cleanup work that addresses it has been pulled forward from Phase 2 into Phase 1.
This document captures findings from a multi-agent audit of the irDashies codebase, the recommended target architecture, and a phased implementation plan. It exists so that future contributors (human and LLM) share the same understanding of why the architecture is moving in this direction.
irDashies has a healthy foundation (Electron 35 secure defaults, contextBridge, native iRacing SDK addon, Zustand with custom equality, version-tagged stores) but is suffering from three compounding problems:
- A telemetry hot-path that fans the entire ~340-key telemetry object out to every overlay window 25 Hz then triggers React render storms because a handful of hooks subscribe to raw, unrounded float arrays. Phase 0 testing confirmed this is the primary in-race memory-leak source (steady +13 to +20 MB/min, driver-count-independent). See
PERFORMANCE_TEST_SUMMARY.md§3-4. - Architectural debt around lifecycle, coupling, and god-files —
Standings/has become an unofficial domain library that 6+ other widgets reach into; the disconnect-reset code path is dead; the multi-profile system is bypassed by a hardcoded'default'profile; adding a widget edits 5+ god-files. - Several latent stability and security issues — synchronous
fs.writeFileSyncon the main process, no global error handlers, arbitrary Chromium switch injection through user settings, and unbounded native pointer dereferences in the SDK addon.
Two further phenomena emerged from Phase 0 testing that were not in the original audit:
- Multi-class race startup carries a one-time ~+1.5 GB memory cost above single-class baseline (Renderer settings, NetworkService, and GPU processes all jump significantly at app start). This is configuration-dependent baseline allocation, not gradual accumulation; it is the most likely contributor to the "feels heavier on multi-class" symptom.
- Each driver who joins an active iRacing session costs ~5–6 MB of permanent app memory, concentrated in renderers hosting driver-list-aware widgets (Standings, Relative, Blindspot, etc.), and not released when drivers leave. In a busy online session with 50+ join events this adds ~250–300 MB on top of the steady-state leak and is the dominant factor in why "busy online sessions feel worse than AI sessions of the same field size, despite AI being CPU-heavier." This is the user-facing symptom of the lifecycle-and-cleanup architectural gap (A2/A3/L5/A7).
The right fix is not "rewrite in C++." The native layer is already C++ and is already fast. The right fix is to (a) introduce a derived-telemetry layer in the Electron main process, (b) replace the raw telemetry firehose with per-channel typed subscriptions, and (c) clean up the architectural debt that would otherwise be carried into the new design.
C++/native optimisations are deferred to a final phase and only undertaken if profiling proves a specific calculation is CPU-bound — Phase 0 measurement suggests it likely won't be.
Empirical status legend (after Phase 0): CONFIRMED = directly evidenced by the test data; PARTIALLY CONFIRMED = some aspects supported; DISCONFIRMED (as leak source) = the hypothesis that this drives the in-race memory leak was tested and rejected, though the underlying issue may still be a real concern; NOT INDEPENDENTLY TESTED = subsumed under another finding's test or not exercised by the test matrix.
| # | Finding | Where | Impact | Empirical status |
|---|---|---|---|---|
| P1 | Full ~340-key telemetry payload structured-cloned to every overlay 25 Hz | iracingSdkBridge.ts:85, overlayManager.ts:486 | Primary in-race memory leak source. Driver-count-independent constant-per-tick allocation; ~+13–20 MB/min steady; also highest CPU/IPC cost; multiplies linearly with open windows | CONFIRMED (PERFORMANCE_TEST_SUMMARY §3-4) |
| P5 | memo(DriverInfoRow) defeated by prop churn — useDriverStandings returns brand-new objects every tick |
DriverInfoRow.tsx:145, Standings.tsx:154-247 | Primary CPU/latency driver. Multi-class processTelemetry p99 +69 % over single-class; Standings-bearing renderer shows largest CPU growth; ~750 cell-component renders per tick at full grid | CONFIRMED (PERFORMANCE_TEST_SUMMARY §3-4) |
| P2 | useDriverPositions subscribes to raw CarIdxLapDistPct (exact equality on float array) |
useDriverPositions.tsx:55 | Re-runs full Standings + Relative pipeline every tick | Subsumed under P1; not independently tested |
| P3 | ReferenceLapStoreUpdater runs collectLapData for every driver every frame on raw input |
ReferenceLapStoreUpdater.tsx:60-84 | Per-tick CPU cost only — not a memory-leak source. Scales p99 with driver count but the allocations are GC'd within the tick. Still worth fixing because it is the easiest win in Phase 1 | PARTIALLY CONFIRMED — CPU yes, leak no (PERFORMANCE_TEST_SUMMARY §3.3) |
| P4 | SectorTimingStore.tick() subscribes to raw LapDistPct/SessionTime |
useSectorTiming.tsx:48-80 | Per-frame global wake-up | Subsumed under P1; not independently tested |
| P6 | Hidden / minimised overlays still receive every broadcast | overlayManager.ts:484-510 | Wasted IPC + GPU work | NOT TESTED — all test windows were visible throughout |
| P7 (new) | Driver-join leak. Each driver who joins an active iRacing session causes ~5–6 MB of app memory to be allocated, concentrated in renderers hosting driver-list-aware widgets (Standings, Relative, Blindspot, FasterCarsFromBehind, SlowCarAhead, etc.). Memory is not released when drivers leave. Underlying mechanism is A2 + A3 + L5 + A7 (no session lifecycle, no reset path, module-global callback Sets in bridges not cleared on driver disconnect) |
Cross-cutting: iracingSdkBridge.ts:27-29, dashboardBridge.ts:63-66, the driver-list-aware widgets in src/frontend/components/, plus the missing sessionLifecycle module under src/app/ |
Dominant leak source in busy online sessions. ~+260 MB across a 5-minute join burst with ~50 joiners. Compounds on top of P1's steady-state leak; pushes V8 to its major-GC threshold sooner and explains why busy online sessions feel worse than equally-sized AI grids | CONFIRMED (PERFORMANCE_TEST_SUMMARY §3.6) |
| # | Finding | Where | Impact |
| ------ | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| A1 | Standings/ is a hidden domain library; 6+ widgets cross-import from Standings/hooks/ and Standings/relativeGapHelpers.ts | FasterCarsFromBehind/hooks/useCarBehind.tsx:2-3, RejoinIndicator/RejoinIndicator.tsx:15-18, TrackMap/TrackCanvas.tsx:19, InformationBar/InformationBar.tsx:6-7, SectorDelta/hooks/useLiveSectorDelta.ts:9, Weather/Weather.tsx:6 | Refactoring Standings breaks five other widgets |
| A2 | No SessionLifecycle abstraction — each store invents its own session-change detector | LapTimesStoreUpdater.tsx:22, SectorTimingStore.tsx:250, PitLapStore, ReferenceLapStore | Now confirmed as a primary leak source. Stores drift; resets miss; replays look like new laps; and — per Practice 2 — there is no centralised handler for "driver joined / driver left" that bridges and stores can hook into, which is the mechanism driving P7 | CONFIRMED implicated for driver-join leak (PERFORMANCE_TEST_SUMMARY §3.6); also strongly implicated for L6 startup cost |
| A3 | useResetOnDisconnect has zero callers and omits 3 stores | useResetOnDisconnect.ts | Dead code; reference laps and sector timing leak across sessions. Now reclassified: not implicated as the in-race steady-state leak source (single-driver Test 1 leaks at +19.7 MB/min with no reference data), but CONFIRMED implicated for the driver-join leak (P7) — the absence of a reset path matches the observed pattern exactly: state added on join, nothing to release it on leave. The team has shipped a one-time migrateReferenceLaps() cleanup (referenceLaps.ts:15-37, 2026-05 release) which removes stale on-disk data once but does not address the missing in-session reset path | CONFIRMED implicated for driver-join leak; DISCONFIRMED as in-race steady-state leak source |
| A4 | God-files for widget registration drive the "8-step recipe" | WidgetIndex.tsx, SettingsLoader.tsx:52-91, SettingsMenu.tsx, widgetConfigs.ts (692 lines), defaultDashboard.ts (1,272 lines) | Every new widget is a merge-conflict event |
| A5 | Profile system bypassed — getDashboard('default') hardcoded | overlayManager.ts:670, 728, 735 | Hardware accel, autostart, tray settings ignore the active profile |
| A6 | No real settings migration — deepMergeConfig only fills gaps | defaultDashboard.ts:1203 | Renaming or retyping a setting silently corrupts user dashboards |
| A7 | 8 ad-hoc bridges with module-global callback Sets | rendererExposeBridge.ts, iracingSdkBridge.ts:27-29, dashboardBridge.ts:63-66 | Hand-wired pattern, no factory; closed renderers and disconnected drivers both leave callbacks behind — per Practice 2 this is the per-driver cost driving P7. Per-window subscription map provided by defineBridge (Phase 1 work) replaces the module-globals and gives a hook for cleanup on driver disconnect | CONFIRMED implicated for driver-join leak (PERFORMANCE_TEST_SUMMARY §3.6) |
| A8 | frontend → app import leaks | Standings.stories.tsx:22, Relative.stories.tsx:17, src/types/telemetry.ts:1 | Layering rule violated; _GENERATED_telemetry.ts ownership unclear |
| A9 | _GENERATED_telemetry.ts regen is manual and not enforced | generate-var-types.js (not in any package.json script) | Native upgrade silently desyncs the type system |
| # | Finding | Where | Impact | Empirical status |
|---|---|---|---|---|
| L2 | YAML re-parsed on every getSessionData call (regex fixes run before each yaml.load) |
irsdk-node.ts:229 | Promoted: also a startup memory concern, not just CPU. Most plausible contributor to multi-class +1.5 GB startup baseline (L6). Memoising on currDataVersion would address both |
STRONGLY IMPLICATED for startup cost |
| L6 (new) | Multi-class race startup allocates ~+1.5 GB above single-class baseline at app start (Renderer settings 184 → 335 MB, NetworkService 53 → 231 MB, GPU 156 → 410 MB) | Configuration-dependent; manifests at app.on('ready') time before any race starts |
One-time hit at session start, not gradual; pushes V8 closer to its major-GC threshold so subsequent in-race growth triggers GC pauses sooner | CONFIRMED (PERFORMANCE_TEST_SUMMARY §3.4) — likely driven by L2; also possibly S5 for NetworkService component |
| L1 | Synchronous fs.writeFileSync on the main process for every dashboard / reference-lap / pit-lane / fuel save |
storage.ts:12, referenceLaps.ts:91, pitLaneData.ts:34, fuelDatabase.ts:36 | Event-loop stalls on save; reference-lap data is unbounded | WEAKLY IMPLICATED in tick-dip events; isolated test needed (PERFORMANCE_TEST_SUMMARY §6.6) |
| L3 | No process.on('uncaughtException') / 'unhandledRejection') handler |
main.ts | Silent main-process crashes | Not tested by Phase 0; remains a hygiene gap |
| L4 | Reference-lap data has no size cap | referenceLaps.ts:24-29 | Disk + parse cost grows over months — but not implicated in measured in-race leak (single-driver Test 1 leaks identically). Same demotion rationale as A3 | DISCONFIRMED as in-race leak source; remains a real disk/IO concern |
| L5 | Module-global callback Sets in bridges aren't cleared on window close or on driver disconnect |
iracingSdkBridge.ts:27-29, dashboardBridge.ts:63-66 | Now confirmed as a primary leak source in busy sessions. Per-driver allocation pattern observed in Practice 2 with selectivity that exactly matches subscription-based callback accumulation: Primary + Left (driver-list-aware widgets) absorbed ~+260 MB across a 5-min join burst; Right (no driver-list-aware widgets) was flat. Pair with A7 — both are the same fix | CONFIRMED implicated for driver-join leak (PERFORMANCE_TEST_SUMMARY §3.6) |
| # | Severity | Finding | Where |
|---|---|---|---|
| S1 | HIGH | Renderer can persist arbitrary Chromium switches → next launch executes them (e.g. --remote-debugging-port=9222, --disable-web-security) |
chromiumFlags.ts:36-37, overlayManager.ts:708-722 |
| S2 | MEDIUM | Native addon dereferences NULL on out-of-range index / pre-init _data |
irsdk_node.cc:502-521, :444, :178-242 |
| S3 | MEDIUM | logBridge invokes logger[level](...) with renderer-supplied level — should validate against ['debug','info','warn','error'] |
logBridge.ts:6-8 |
| S4 | MEDIUM | No CSP, no setWindowOpenHandler, no will-navigate deny — defense-in-depth gap |
overlayManager.ts:148, 814, index.html |
| S5 | LOW | Analytics forwards every warn/error log line (including userData paths with Windows username) to PostHog |
analytics.ts:99-120 |
What's already good (do not regress): contextIsolation on, nodeIntegration off, sandbox on, asar fuses enabled, update-electron-app over HTTPS, contextBridge used everywhere — no ipcRenderer / require leaked to the renderer.
iRacing process (shared memory @ 60 Hz)
│
▼
┌─────────────────────────────────────────────────────────────┐
│ C++ N-API addon (irsdk_node) │
│ - Wait + memcpy + transcode only (unchanged) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Worker thread: SDK loop │
│ - Owns the blocking waitForData loop │
│ - postMessage / SharedArrayBuffer back to main │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Main process: Telemetry Hub │
│ │
│ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ Raw frame cache │───▶│ Session Lifecycle bus │ │
│ └──────────────────┘ └────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Telemetry Processors (own tick rate) │ │
│ │ - StandingsProcessor (5 Hz) │ │
│ │ - RelativeGapProcessor (5 Hz) │ │
│ │ - ReferenceLapProcessor (event-driven) │ │
│ │ - SectorTimingProcessor (event-driven) │ │
│ │ - FuelProjectionProcessor (1 Hz) │ │
│ │ - InputLiveProcessor (60 Hz) │ │
│ │ - TrackPositionsProcessor (25 Hz) │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Channel Bus (per-window subscription) │ │
│ │ - 'standings.snapshot' → typed payload │ │
│ │ - 'fuel.projection' → typed payload │ │
│ │ - 'trackmap.positions' → typed payload │ │
│ │ - ... one channel per processor output │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ (slim, typed IPC; no full-telemetry firehose)
▼
┌─────────────────────────────────────────────────────────────┐
│ Renderer windows │
│ - Each window declares the channels it needs │
│ - Widgets are near-stateless views over channel snapshots │
│ - Visibility-aware: hidden windows pause subscriptions │
└─────────────────────────────────────────────────────────────┘
| Layer | Owns | May import from | Must not import from |
|---|---|---|---|
src/app/irsdk/native/ |
C++ addon | — | anything in src/ |
src/app/irsdk/ |
SDK wrapper, type generation | irsdk/native |
bridge, frontend |
src/app/sessionLifecycle/ (new) |
Session lifecycle event bus | irsdk |
bridge, frontend |
src/app/processors/ (new) |
Telemetry processors (derived data) | irsdk, sessionLifecycle |
bridge, frontend |
src/app/bridge/ |
Channel bus, per-window subscriptions, IPC handlers | processors, storage, sessionLifecycle |
frontend |
src/app/storage/ |
Persistence (async only) | sessionLifecycle |
bridge, frontend |
src/types/ |
Shared type contracts (snapshot shapes, settings shapes) | — | anything else |
src/frontend/domain/ (new) |
Channel hooks, derived selectors that multiple widgets share | @irdashies/types, bridge type defs |
src/app/* |
src/frontend/context/ |
Channel-snapshot stores (1 store per channel, plus TelemetryStore for raw) |
@irdashies/types |
src/app/* |
src/frontend/components/<Widget>/ |
One widget; pure consumer | @irdashies/context, @irdashies/domain, @irdashies/utils, @irdashies/types |
other widgets, src/app/* |
The "no cross-widget imports" rule is the structural invariant. If two widgets need the same logic, it lives in frontend/domain/ (or as a new processor + channel).
- Derived telemetry is a first-class layer, owned by the main process. Renderers do not re-derive what processors already compute.
- Per-channel subscriptions, not a firehose. Each renderer declares the channels it needs. Closed/hidden windows pause.
- Tick-rate budget per channel. 60 Hz only where the eye demands it (input bars). 5 Hz for sortable/intervalled UI. Event-driven for crossings/laps.
- Typed bridge contracts. Each channel has an explicit
interface SnapshotShapeinsrc/types/. The IPC payload is the snapshot, nothing more. - Single session lifecycle event source. Stores subscribe to
enter/exit/sessionNumChange/disconnect; they do not invent their own detection. - Self-registering widgets. A
WidgetDefinitionobject carries{ id, component, settingsComponent, defaultConfig, displayName, alwaysEnabled }. No god-files. - Versioned settings + migrator registry. Every widget settings shape carries a
version; breaking changes ship a migrator. - Async I/O only on the main process. No
fs.*Syncanywhere outside startup. Writes are debounced. - Validate at the IPC boundary. Every
ipcMain.handlevalidates input. Renderer-supplied strings never become method names, file paths, or process flags without an allowlist. - Profiling is on by default.
perfMetricsalways runs; results are visible in a debug overlay. - Tests cover processors. Pure processor classes are unit-tested with recorded frame sequences. Logic is not buried in React hooks.
Each phase is independently shippable. Phases 0 and 0.5 are prerequisites; phases 2 and onward are the architectural move and can be delivered widget-by-widget.
- ✅ Enabled
PERF_METRICS=1and capturedprocessTelemetryvsbroadcastp99 across 6 controlled sessions (Test Drive, AI single-class, AI multi-class, two real-multiplayer practices, PCC). - ✅ React DevTools Profiler traces taken; Standings-bearing renderer identified as the dominant CPU consumer.
- ✅ Per-renderer memory growth measured; in-race steady-state leak rate quantified at +13–20 MB/min (driver-count-independent).
- ✅ Multi-class startup baseline allocation quantified at ~+1.5 GB above single-class.
- ✅ Driver-join leak quantified at ~5–6 MB per joining driver (Practice 2, known burst); confirmed concentrated in driver-list-aware renderers; not released on driver leave.
- ✅ Findings published in
PERFORMANCE_TEST_SUMMARY.md. - ✅ Empirical baseline + targets table committed below (used as Phase 1 exit criteria).
Baseline numbers:
| Metric | Phase 0 baseline | Phase 1 target | Source |
|---|---|---|---|
| In-race steady-state app memory slope (40-car AI single-class) | +16.5 MB/min | < +5 MB/min | PERFORMANCE_TEST_SUMMARY §3.3 |
processTelemetry p99 mean (40-car AI single-class) |
7.4 ms | < 3 ms | PERFORMANCE_TEST_SUMMARY §3.3 |
| Tick-rate stability (% samples ≥ 20 Hz) | ~99 % (single-class), ~91 % (multi-class) | ≥ 99.5 % | PERFORMANCE_TEST_SUMMARY §3.3–3.4 |
| Multi-class startup memory (app cold start, fresh instance) | 2,908 MB | < 1,800 MB (≤25 % over single-class) | PERFORMANCE_TEST_SUMMARY §3.4 |
| Per-joining-driver permanent memory cost (real multiplayer) | ~5–6 MB | < 1 MB | PERFORMANCE_TEST_SUMMARY §3.6 |
| Renderer wake-ups per second at full grid | TBD (React Profiler) | ≥ 50 % reduction | Phase 1 sub-task |
Outstanding Phase 0 investigations (see PERFORMANCE_TEST_SUMMARY §6 for the full list — these do not block Phase 0.5 but inform Phase 1 prioritisation):
- Empty-dashboard baseline (HIGH) — gates Phase 1 success criteria
- Single-widget isolation tests (HIGH) — identifies heaviest widget
Early-session step-change cause— RESOLVED by Practice 2 (PERFORMANCE_TEST_SUMMARY §6.7); driver joins are the cause; produced new finding P7- Reference-lap A/B (LOW, downgraded) — does an existing reference lap raise the steady-state slope on top of P1?
- PCC dashboard config delta (MEDIUM)
- NetworkService allocation cause (MEDIUM)
- Sync-I/O tick-dip correlation (LOW)
- Cumulative driver-join cost in long sessions (MEDIUM, new) — does the per-joiner cost plateau (state keyed by car ID) or grow unboundedly?
Independent fixes that should not be carried forward into the new architecture. L2 is now the highest-priority item in this phase because Phase 0 testing confirmed it as a startup memory concern (likely contributor to L6) in addition to its original CPU framing.
- L2 (now elevated): Memoize YAML parse on
currDataVersion(irsdk-node.ts:229) — skip both regex fix-ups andyaml.loadwhen version is unchanged. Addresses both startup memory (L6) and steady-state main-thread cost. - S1: Allowlist
chromiumFlagsswitches. - S2: Null-guard the native addon (
GetTelemetryVarByIndex,GetTelemetryData,BroadcastMessage). - S3: Validate
logBridgelevel argument. - S4: Add
setWindowOpenHandler({action:'deny'}),will-navigatedeny, and a CSP viasession.webRequest.onHeadersReceived. - L1: Convert sync
fs.writeFileSynctofs.promises.writeFile+ 250 ms debounce. Note: the one-timemigrateReferenceLaps()at startup (referenceLaps.ts:15-37) may remain synchronous — R6.1 permits sync I/O during startup. - L3: Add
process.on('uncaughtException')/'unhandledRejection')handlers. - P6: Pause broadcasts to non-visible windows (
webContents.isVisible()).
Exit criteria: all HIGH/MEDIUM security findings closed; no sync I/O on the main thread outside startup; global error handlers registered; YAML parse memoised. Re-run the Phase 0 multi-class test and capture deltas in PERFORMANCE_TEST_SUMMARY.md.
Originally a 2–3 day "rounding hooks + memo equality" sweep. The 2026-05-12 Practice 2 test showed the A2/A3/L5/A7 cluster is a primary leak source in busy online sessions (P7, ~5–6 MB per joining driver, ~+260 MB across a 5-min join burst), so the SessionLifecycle skeleton and the per-window/per-driver bridge cleanup are pulled forward from Phase 2 into Phase 1. The full A2/A3 architectural rewrite (renaming, dead-code removal, migration of every store's reset path) stays in Phase 2.
Perf wins (original scope):
- P2/P3/P4: Switch raw float subscriptions to
useTelemetryValuesRoundedper the precision table inARCHITECTURE_RULES.md. - P5: Add a custom
propsAreEqualtomemo(DriverInfoRow)(or pre-compute primitive props in the parent). - P1 (interim): Trim the IPC payload — drop string-typed and unused vars at the bridge before broadcast.
Lifecycle bones (pulled forward to address P7):
- A2 (skeleton): Introduce
src/app/sessionLifecycle/as a single event source with at minimumonDriverJoined(carIdx),onDriverLeft(carIdx),onSessionNumChange(),onDisconnect(). Wired up to the existing session-data + telemetry stream iniracingSdkBridge. No store migrations yet — that is Phase 2. - L5 + A7: Replace module-global callback
Sets iniracingSdkBridgeanddashboardBridgewith per-window subscription maps. AddonDriverLeft→ drop per-driver entries hook to whichever store(s) hold per-driver state and have a visible leak (Standings, Relative, Blindspot, FasterCarsFromBehind, SlowCarAhead — guided by the renderer-selectivity data in Practice 2). Other stores can wait for Phase 2. - A3 (start): Wire
useResetOnDisconnectto the new event source for the stores it already covers. Remove the dead-code label from it. Full reset-handler audit is Phase 2.
Exit criteria:
- ≥50 % reduction in renderer wake-ups per second at full grid
- In-race steady-state app memory slope < +5 MB/min (single-class AI 40-car) — was: ≥50 % reduction
- Per-joining-driver permanent memory cost < 1 MB (real multiplayer, Practice 2 replay)
- No widget regressions; Storybook stories still render
- Re-run Phase 0 test matrix and append the deltas to
PERFORMANCE_TEST_SUMMARY.md
Must precede the channel-bus refactor. Note: the highest-impact pieces of A2/A3/L5/A7 have been pulled into Phase 1 to fix the driver-join leak. Phase 2 completes the cleanup.
- A1: Extract
frontend/domain/fromStandings/. MoveuseDriverStandings,useDriverPositions,useDriverRelatives,useDriverLivePositions,useTrackTemperature,relativeGapHelpers,interpolation, and the sharedStandingstype. Cross-widget imports fromStandings/become a lint error. - A2/A3 (completion): Migrate every remaining store to register reset handlers with
sessionLifecycle(Phase 1 delivered the event source and wired the leaking stores; this completes the migration to every store). DeleteuseResetOnDisconnectonce nothing imports the dead version. AddonEnter/onExitevents alongside the join/leave/disconnect ones; distinguish live vs replay. - A4: Replace
WIDGET_MAP+SettingsLoaderswitch + per-widgetdefaultDashboard.tsentries with a self-registeringWidgetDefinition. - A5: Remove hardcoded
'default'profile fromoverlayManager.ts. - A6: Add
version: numberper widget settings shape; introducesrc/types/migrators/<widget>.tsregistry. Run on load. - A7 (completion): Roll Phase 1's per-window subscription pattern out to the remaining bridges via a
defineBridge<I>(channel, impl)helper. The two bridges that drive P7 are already done in Phase 1; this is the rest. - A9: Add a
npm run check:generated-typesscript and a CI step that fails if_GENERATED_telemetry.tswould change.
Exit criteria: AGENTS.md "8-step recipe" is reduced to 2 steps. Lint forbids frontend/components/<X>/ from importing frontend/components/<Y>/. CI guards regen. Every Zustand store registers a reset handler with sessionLifecycle (R3.1 enforced).
- Introduce
publishChannel('<channel>', snapshot)on the bridge side anduseChannelSnapshot('<channel>')on the renderer side. - Each window's preload sends a
subscribe(channels[])request; main process only computes/sends what's subscribed. - Migrate one widget at a time, starting with Fuel (small, self-contained, big payload reduction). Keep the legacy
'telemetry'channel running until everything's migrated.
Exit criteria: Fuel widget runs entirely off 'fuel.projection'; legacy 'telemetry' channel still works for unmigrated widgets.
Port the heavy stores to main-process TelemetryProcessor classes (one per phase increment to keep PRs reviewable):
LapTimesProcessorCarSpeedsProcessorRelativeGapProcessorReferenceLapProcessorSectorTimingProcessorFuelProjectionProcessorStandingsProcessor
Each processor:
- Subscribes to the raw frame and
sessionLifecycle - Owns its tick rate
- Emits a typed snapshot on a named channel
- Has unit tests using recorded frame sequences
Renderers shrink to subscriptions + presentation.
Exit criteria: the legacy 'telemetry' IPC channel can be deleted (or restricted to a dev-only debug subscriber).
Move the blocking waitForData loop into a worker_threads Worker. Instantiate the native SDK inside the worker (the binding holds raw-pointer state and is not thread-safe).
Exit criteria: main-process event loop is not blocked during normal SDK operation.
After Phase 4, re-measure. Realistic candidates if needed:
- Binary IPC for the highest-rate channels via SharedArrayBuffer.
- A native circular buffer for reference-lap point collection.
Do not start Phase 6 without a profile that proves a specific calculation is the bottleneck. Sorting ~30-64 entries and Hermite interpolation are not it.
Phase 0 was measured by hand (run iRacing, capture PerfMetrics CSVs, regress in Excel). That is not sustainable as a per-PR regression gate. Build a headless harness so every PR that touches a hot path produces a number:
- Trace replay: record one telemetry stream per scenario (single-driver Test Drive, AI 40-car single-class, AI 40-car multi-class, real-multiplayer-with-join-burst). Feed each through irDashies headlessly via the existing mock-data infrastructure (src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts).
- Memory-slope assertion: linear-regress the renderer heap over a 5-min window and fail the bench if slope exceeds the per-scenario threshold defined in Phase 0's baseline table.
- Synthetic driver-join test: fire N simulated driver-join events into the bridge and assert on the heap delta. This is the regression gate for L5/A7/P7 — anything that re-introduces a per-driver leak shows up here.
- PerfMetrics CSVs from Phase 0 are the seed corpus. They are listed in
PERFORMANCE_TEST_SUMMARY.md§8.
This work should land alongside Phase 1 so Phase 1's exit numbers are measured by the harness, not by hand. It is tracked in IMPLEMENTATION_PLAN.md §3.
These need a maintainer call before the relevant phase begins:
- Domain folder name.
frontend/domain/vsfrontend/derived/vsfrontend/shared/. Pick one. Blocks Phase 2 (A1). - Channel subscription model. Push-based (main process tracks subscribers and sends only to them) vs pull-based (renderer requests snapshots on demand for low-rate channels). Recommended: push for everything, including a small
'session.snapshot'for first paint. Blocks Phase 3. - Settings migrator location. Per-widget folder vs central registry in
src/types/migrators/. Recommended: central registry to keep migrators discoverable. Blocks Phase 2 (A6). - Backwards compatibility window. How long do we keep the legacy
'telemetry'channel running alongside the channel bus? Recommended: until all in-tree widgets have migrated, then remove in a single PR. Blocks Phase 4. - Long-term native direction. Stay all-TypeScript above the native shim, or pursue Rust + napi-rs as a learning path? Recommended: stay all-TypeScript until Phase 6 proves a specific need. Blocks Phase 6.
- SessionLifecycle event source location. Where does the canonical session-state-machine live: in
iracingSdkBridge(where session-data already arrives), in a siblingsrc/app/sessionLifecycle/module, or in the existingSessionStore(renderer-side)? Recommended:src/app/sessionLifecycle/in the main process — that lets bridges and stores both subscribe and is the only sensible home for the per-driver cleanup hooks that Phase 1 needs. Blocks Phase 1 lifecycle bones. - Driver-state ownership pattern. When
onDriverLeft(carIdx)fires, do consumers (a) drop their per-driver entries immediately, (b) mark them stale and reap on a timer, or (c) keep them and let the nextonSessionNumChangeclear everything? Recommended: (a) for stores that allocate per driver (Standings, Relative, Blindspot, etc.); (c) for stores that don't. Affects whether re-joining drivers see their state reset. Blocks Phase 1 lifecycle bones.
Honest list of remaining blind spots. Pick these up as separate work:
- Test coverage gap. No audit of which logic paths have tests vs not. Phase 4 will be much riskier without a baseline.
- Storybook leakage into prod bundle. Not verified that no
.storiesfiles end up in the production bundle. - Bundle size & tree-shaking. Vite config looks fine but actual bundle was not measured.
src/app/webserver/(componentServer.ts,bridgeProxy.ts). Surfaced briefly as afrontend → appimport leak; functionality and attack surface not audited.- Analytics PII. analytics.ts:99-120 forwards every
warn/errorlog line including image-storage paths containing the Windows username — needs a closer look. - Cumulative driver-join cost over a full endurance session. Practice 2 measured a 5-minute join burst (~50 joiners → ~+260 MB). What happens over a 2-hour open practice where hundreds of drivers cycle through? If per-driver state is keyed by something stable (car ID) it may plateau; if by something ephemeral (session-relative index) it grows unboundedly. PERFORMANCE_TEST_SUMMARY §6.8 covers the test. Until measured, treat Phase 1's per-joiner cost target (
< 1 MB) as a necessary but not sufficient condition. - Driver-rejoin behaviour. When a driver leaves and rejoins, is their previous per-driver state restored, replaced, or both retained? The choice affects both correctness (laptime history, sector colours) and the L5/A7 fix design. Not exercised by Phase 0; relevant to Phase 1.
- Per-renderer crash recovery. Practice 2 did not exercise renderer-process crashes. Recovery logic exists in overlayManager.ts:203-222 but is unverified in the leaked-callback scenario — does a crashed renderer's bridge subscriptions get cleaned up before the new renderer registers fresh ones?