Skip to content

Commit e3dd77a

Browse files
authored
perf: condition legacy telemetry by widget (#656)
1 parent 0d74f27 commit e3dd77a

27 files changed

Lines changed: 690 additions & 39 deletions

docs/PHASE_3_CHANNEL_BRIDGE_PLAN.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,26 @@ Turn the completed Fuel migration into measurable renderer and IPC savings.
218218
- Renderer frames over 50 ms below 0.1%.
219219
- Steady-state app memory slope below 5 MB/min.
220220

221+
#### Widget runtime metadata and rate guidance
222+
223+
Runtime transport metadata is discovered from
224+
`src/frontend/components/*/widgetRuntimeDefinition.ts`. A migrated widget must
225+
explicitly set `legacyTelemetry: false` and list every typed channel it needs.
226+
Widgets without runtime metadata remain legacy consumers so incremental
227+
migrations fail safe.
228+
229+
Choose the lowest named preset that preserves the widget's observable behavior:
230+
231+
- `driverFocused` — 25 Hz for positional or rapidly changing driver data.
232+
- `gapTiming` — 5 Hz for projections, gaps, and sortable timing data.
233+
- `informational` — 1 Hz for weather and slowly changing labels.
234+
- `static` — event/snapshot delivery with no polling-rate request.
235+
236+
Use `channelRates` on the widget runtime definition when one channel needs a
237+
different rate from the preset. Multiple consumers in one renderer are still
238+
coalesced at the highest requested rate. Full-rate input and precision-sensitive
239+
calculation paths must not be moved to a lower preset.
240+
221241
## 4. Validation Strategy
222242

223243
### Every PR

src/app/bridge/channelRendererBridge.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import {
1010
CHANNEL_SUBSCRIBE,
1111
CHANNEL_UNSUBSCRIBE,
1212
} from './channelBridge';
13+
import {
14+
isRendererPerfMetricsEnabled,
15+
recordChannelCallback,
16+
} from '../rendererPerfMetrics';
1317

1418
interface LocalConsumer {
1519
callback: (payload: never) => void;
@@ -31,7 +35,13 @@ export const createChannelRendererBridge = (): ChannelBridge => {
3135
const subscription = subscriptions.get(channel);
3236
if (!subscription) return;
3337
for (const consumer of subscription.consumers) {
38+
if (!isRendererPerfMetricsEnabled()) {
39+
consumer.callback(payload as never);
40+
continue;
41+
}
42+
const start = performance.now();
3443
consumer.callback(payload as never);
44+
recordChannelCallback(performance.now() - start);
3545
}
3646
}
3747
);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { EventEmitter } from 'node:events';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
const handlers = vi.hoisted(
5+
() => new Map<string, (event: unknown, key: unknown) => void>()
6+
);
7+
const removeHandler = vi.hoisted(() => vi.fn());
8+
9+
vi.mock('electron', () => ({
10+
contextBridge: { exposeInMainWorld: vi.fn() },
11+
ipcRenderer: { invoke: vi.fn() },
12+
ipcMain: {
13+
handle: (
14+
channel: string,
15+
handler: (event: unknown, key: unknown) => void
16+
) => handlers.set(channel, handler),
17+
removeHandler,
18+
},
19+
}));
20+
21+
import { defineRendererSubscriptionBridge } from './defineBridge';
22+
23+
class FakeSender extends EventEmitter {
24+
constructor(readonly id: number) {
25+
super();
26+
}
27+
}
28+
29+
describe('defineRendererSubscriptionBridge', () => {
30+
beforeEach(() => {
31+
handlers.clear();
32+
removeHandler.mockClear();
33+
});
34+
35+
const setup = () =>
36+
defineRendererSubscriptionBridge<'telemetry' | 'sessionData'>({
37+
name: 'legacy-test',
38+
isValidKey: (value): value is 'telemetry' | 'sessionData' =>
39+
value === 'telemetry' || value === 'sessionData',
40+
});
41+
42+
it('validates keys and tracks subscriptions by sender identity', () => {
43+
const bridge = setup();
44+
const sender = new FakeSender(7);
45+
const subscribe = handlers.get('legacy-test:subscribe');
46+
47+
expect(() => subscribe?.({ sender }, 'invalid')).toThrow(
48+
'Invalid legacy-test subscription'
49+
);
50+
subscribe?.({ sender }, 'telemetry');
51+
52+
expect(bridge.registry.has(7, 'telemetry')).toBe(true);
53+
expect(bridge.registry.hasAny('telemetry')).toBe(true);
54+
});
55+
56+
it.each(['did-start-loading', 'destroyed'])(
57+
'cleans renderer state on %s',
58+
(eventName) => {
59+
const bridge = setup();
60+
const sender = new FakeSender(9);
61+
handlers.get('legacy-test:subscribe')?.({ sender }, 'sessionData');
62+
63+
sender.emit(eventName);
64+
65+
expect(bridge.registry.hasAny('sessionData')).toBe(false);
66+
}
67+
);
68+
69+
it('unregisters handlers and clears state when disposed', () => {
70+
const bridge = setup();
71+
const sender = new FakeSender(11);
72+
handlers.get('legacy-test:subscribe')?.({ sender }, 'telemetry');
73+
74+
bridge.dispose();
75+
76+
expect(removeHandler).toHaveBeenCalledWith('legacy-test:subscribe');
77+
expect(removeHandler).toHaveBeenCalledWith('legacy-test:unsubscribe');
78+
expect(bridge.registry.hasAny('telemetry')).toBe(false);
79+
});
80+
});

src/app/bridge/defineBridge.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,106 @@
1-
import { contextBridge } from 'electron';
1+
import { contextBridge, ipcMain, ipcRenderer } from 'electron';
22

33
/** Exposes a typed preload API through Electron's isolated world boundary. */
44
export const defineBridge = <I>(name: string, implementation: I): I => {
55
contextBridge.exposeInMainWorld(name, implementation);
66
return implementation;
77
};
8+
9+
export class RendererSubscriptionRegistry<K extends string> {
10+
private readonly subscriptions = new Map<number, Set<K>>();
11+
12+
subscribe(rendererId: number, key: K): void {
13+
let keys = this.subscriptions.get(rendererId);
14+
if (!keys) {
15+
keys = new Set();
16+
this.subscriptions.set(rendererId, keys);
17+
}
18+
keys.add(key);
19+
}
20+
21+
unsubscribe(rendererId: number, key: K): void {
22+
const keys = this.subscriptions.get(rendererId);
23+
keys?.delete(key);
24+
if (keys?.size === 0) this.subscriptions.delete(rendererId);
25+
}
26+
27+
removeRenderer(rendererId: number): void {
28+
this.subscriptions.delete(rendererId);
29+
}
30+
31+
has(rendererId: number, key: K): boolean {
32+
return this.subscriptions.get(rendererId)?.has(key) ?? false;
33+
}
34+
35+
hasAny(key: K): boolean {
36+
for (const keys of this.subscriptions.values()) {
37+
if (keys.has(key)) return true;
38+
}
39+
return false;
40+
}
41+
42+
clear(): void {
43+
this.subscriptions.clear();
44+
}
45+
}
46+
47+
const subscriptionChannels = (name: string) => ({
48+
subscribe: `${name}:subscribe`,
49+
unsubscribe: `${name}:unsubscribe`,
50+
});
51+
52+
export const createSubscriptionBridgeClient = <K extends string>(
53+
name: string
54+
) => {
55+
const channels = subscriptionChannels(name);
56+
return {
57+
subscribe: (key: K) => ipcRenderer.invoke(channels.subscribe, key),
58+
unsubscribe: (key: K) => ipcRenderer.invoke(channels.unsubscribe, key),
59+
};
60+
};
61+
62+
export const defineRendererSubscriptionBridge = <K extends string>(options: {
63+
name: string;
64+
isValidKey: (value: unknown) => value is K;
65+
}) => {
66+
const channels = subscriptionChannels(options.name);
67+
const registry = new RendererSubscriptionRegistry<K>();
68+
const rendererCleanup = new Map<number, () => void>();
69+
70+
ipcMain.handle(channels.subscribe, (event, key: unknown) => {
71+
if (!options.isValidKey(key)) {
72+
throw new Error(`Invalid ${options.name} subscription`);
73+
}
74+
const rendererId = event.sender.id;
75+
if (!rendererCleanup.has(rendererId)) {
76+
const cleanup = () => {
77+
event.sender.removeListener('destroyed', cleanup);
78+
event.sender.removeListener('did-start-loading', cleanup);
79+
rendererCleanup.delete(rendererId);
80+
registry.removeRenderer(rendererId);
81+
};
82+
rendererCleanup.set(rendererId, cleanup);
83+
event.sender.once('destroyed', cleanup);
84+
event.sender.once('did-start-loading', cleanup);
85+
}
86+
registry.subscribe(rendererId, key);
87+
});
88+
89+
ipcMain.handle(channels.unsubscribe, (event, key: unknown) => {
90+
if (!options.isValidKey(key)) {
91+
throw new Error(`Invalid ${options.name} unsubscribe`);
92+
}
93+
registry.unsubscribe(event.sender.id, key);
94+
});
95+
96+
return {
97+
registry,
98+
dispose: () => {
99+
ipcMain.removeHandler(channels.subscribe);
100+
ipcMain.removeHandler(channels.unsubscribe);
101+
for (const cleanup of rendererCleanup.values()) cleanup();
102+
rendererCleanup.clear();
103+
registry.clear();
104+
},
105+
};
106+
};

src/app/bridge/iracingSdk/iracingSdkBridge.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,10 @@ export async function publishIRacingSDKEvents(
247247
lifecycle?._onTelemetry(telemetry);
248248
perfMetrics.markEnd('lifecycleTelemetry');
249249
fuelProjectionRuntime?.onFrame(telemetry);
250-
if (perfTelemetryDeliveryEnabled) {
250+
if (
251+
perfTelemetryDeliveryEnabled &&
252+
overlayManager.hasLegacyStreamSubscribers('telemetry')
253+
) {
251254
perfMetrics.markStart('telemetryProjection');
252255
const rendererTelemetry = telemetryForRenderer(telemetry);
253256
perfMetrics.markEnd('telemetryProjection');
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { defineRendererSubscriptionBridge } from './defineBridge';
2+
3+
export const LEGACY_STREAM_BRIDGE = 'legacy-stream';
4+
5+
export type LegacyRendererStream = 'telemetry' | 'sessionData';
6+
7+
export const isLegacyRendererStream = (
8+
value: unknown
9+
): value is LegacyRendererStream =>
10+
value === 'telemetry' || value === 'sessionData';
11+
12+
export const setupLegacyRendererSubscriptions = () =>
13+
defineRendererSubscriptionBridge<LegacyRendererStream>({
14+
name: LEGACY_STREAM_BRIDGE,
15+
isValidKey: isLegacyRendererStream,
16+
});

src/app/bridge/rendererExposeBridge.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,31 @@ import {
2323
isRendererPerfMetricsEnabled,
2424
recordTelemetryCallback,
2525
} from '../rendererPerfMetrics';
26+
import {
27+
LEGACY_STREAM_BRIDGE,
28+
type LegacyRendererStream,
29+
} from './legacyRendererSubscriptions';
30+
import { createSubscriptionBridgeClient, defineBridge } from './defineBridge';
2631

2732
export function exposeBridge() {
28-
contextBridge.exposeInMainWorld('irsdkBridge', {
33+
const legacySubscriptions =
34+
createSubscriptionBridgeClient<LegacyRendererStream>(LEGACY_STREAM_BRIDGE);
35+
const legacyListenerCounts = new Map<LegacyRendererStream, number>();
36+
const addLegacyListener = (stream: LegacyRendererStream) => {
37+
const count = legacyListenerCounts.get(stream) ?? 0;
38+
legacyListenerCounts.set(stream, count + 1);
39+
if (count === 0) void legacySubscriptions.subscribe(stream);
40+
};
41+
const removeLegacyListener = (stream: LegacyRendererStream) => {
42+
const count = legacyListenerCounts.get(stream) ?? 0;
43+
if (count <= 1) {
44+
legacyListenerCounts.delete(stream);
45+
void legacySubscriptions.unsubscribe(stream);
46+
return;
47+
}
48+
legacyListenerCounts.set(stream, count - 1);
49+
};
50+
defineBridge<IrSdkBridge>('irsdkBridge', {
2951
onTelemetry: (callback: (value: Telemetry) => void) => {
3052
const handler = (_: Electron.IpcRendererEvent, value: Telemetry) => {
3153
if (!isRendererPerfMetricsEnabled()) {
@@ -36,15 +58,23 @@ export function exposeBridge() {
3658
callback(value);
3759
recordTelemetryCallback(performance.now() - start);
3860
};
61+
addLegacyListener('telemetry');
3962
ipcRenderer.on('telemetry', handler);
40-
return () => ipcRenderer.removeListener('telemetry', handler);
63+
return () => {
64+
ipcRenderer.removeListener('telemetry', handler);
65+
removeLegacyListener('telemetry');
66+
};
4167
},
4268
onSessionData: (callback: (value: Session) => void) => {
4369
const handler = (_: Electron.IpcRendererEvent, value: Session) => {
4470
callback(value);
4571
};
72+
addLegacyListener('sessionData');
4673
ipcRenderer.on('sessionData', handler);
47-
return () => ipcRenderer.removeListener('sessionData', handler);
74+
return () => {
75+
ipcRenderer.removeListener('sessionData', handler);
76+
removeLegacyListener('sessionData');
77+
};
4878
},
4979
onRunningState: (callback: (value: boolean) => void) => {
5080
const handler = (_: Electron.IpcRendererEvent, value: boolean) => {
@@ -54,11 +84,15 @@ export function exposeBridge() {
5484
return () => ipcRenderer.removeListener('runningState', handler);
5585
},
5686
stop: () => {
87+
for (const stream of legacyListenerCounts.keys()) {
88+
void legacySubscriptions.unsubscribe(stream);
89+
}
90+
legacyListenerCounts.clear();
5791
ipcRenderer.removeAllListeners('telemetry');
5892
ipcRenderer.removeAllListeners('sessionData');
5993
ipcRenderer.removeAllListeners('runningState');
6094
},
61-
} as IrSdkBridge);
95+
});
6296

6397
contextBridge.exposeInMainWorld('dashboardBridge', {
6498
onEditModeToggled: (callback: (value: boolean) => void) => {

0 commit comments

Comments
 (0)