From 5ca9c0b4c7b3f0456cd17b27f82804bebd5646e8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 29 Sep 2026 17:51:10 +0200 Subject: [PATCH] test(perf): detach J1's IPC capture before the J2 click J2 used J1's capture to see pending launch bridge calls while settling, but its ipcMain listener stayed attached and ran for every bridge call of the measured click. The capture can now be detached; J2 detaches J1's right after settling, before the click. Co-Authored-By: Claude Opus 5.5 --- .../src/journeys/open-source-journey-app.ts | 4 +++ .../journey-main-ipc-capture.spec.ts | 34 +++++++++++++++++-- .../performance/journey-main-ipc-capture.ts | 33 ++++++++++++++++-- docs/architecture/performance-journeys.md | 4 ++- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/apps/electron-backend-e2e/src/journeys/open-source-journey-app.ts b/apps/electron-backend-e2e/src/journeys/open-source-journey-app.ts index 13f63a610..5273d385d 100644 --- a/apps/electron-backend-e2e/src/journeys/open-source-journey-app.ts +++ b/apps/electron-backend-e2e/src/journeys/open-source-journey-app.ts @@ -3,6 +3,7 @@ import type { ElectronApplication, Page } from '@playwright/test'; import { defaultXtreamPortalName } from '../electron-test-fixtures'; import { countJourneyMainIpcInFlight, + detachJourneyMainIpcCapture, installJourneyMainIpcCapture, JOURNEY_MAIN_IPC_STATE_KEY, JOURNEY_RENDERER_API_TRACE_CHANNEL, @@ -201,6 +202,9 @@ export async function measureOpenSourceJourney( ledger, probeOptions.stateKey ); + // J1's capture was only needed to see pending launch calls while + // settling; detached, it no longer runs for every J2 bridge call. + await detachJourneyMainIpcCapture(electronApp, JOURNEY_MAIN_IPC_STATE_KEY); await card.click({ timeout: timeoutMs }); const renderer: JourneyRendererProbeState = await waitForJourneyRendererProbe( diff --git a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts index d6a7f8aa3..219bc041f 100644 --- a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts @@ -9,6 +9,7 @@ import type { ElectronApplication } from '@playwright/test'; import { assertJourneyMainIpcCapture, countJourneyMainIpcInFlight, + detachJourneyMainIpcCapture, installJourneyMainIpcCapture, JOURNEY_RENDERER_API_TRACE_CHANNEL, peekJourneyMainIpcCapture, @@ -139,6 +140,7 @@ test('rejects captures that cannot bound the counter exactly', () => { */ function createFakeElectronApp(): { readonly app: ElectronApplication; + listeners(): number; send( senderId: number, method: string, @@ -161,6 +163,7 @@ function createFakeElectronApp(): { } as unknown as ElectronApplication; return { app, + listeners: () => ipcMain.listenerCount(channel), send: (senderId, method, args, phase = 'start') => { ipcMain.emit( channel, @@ -175,7 +178,8 @@ async function withCapture( options: Partial, run: ( fake: ReturnType, - read: () => Promise + read: () => Promise, + stateKey: string ) => Promise ): Promise { const stateKey = `__journeyIpcCaptureTest${Math.random()}`; @@ -188,9 +192,15 @@ async function withCapture( stateKey, ...options, }); - await run(fake, () => peekJourneyMainIpcCapture(fake.app, stateKey)); + await run( + fake, + () => peekJourneyMainIpcCapture(fake.app, stateKey), + stateKey + ); } finally { - delete (globalThis as unknown as Record)[stateKey]; + const target = globalThis as unknown as Record; + delete target[stateKey]; + delete target[`${stateKey}:detach`]; } } @@ -344,3 +354,21 @@ test('tracks bridge calls in flight from start to success or error', async () => assert.equal(countJourneyMainIpcInFlight(state), 0); }); }); + +test('a detached capture stops listening and keeps its last state', async () => { + await withCapture({}, async (fake, read, stateKey) => { + fake.send(1, 'getSettings', []); + assert.equal(fake.listeners(), 1); + await detachJourneyMainIpcCapture(fake.app, stateKey); + assert.equal(fake.listeners(), 0); + fake.send(1, 'getSettings', []); + fake.send(1, 'dbGetAppState', []); + const state = await read(); + assert.equal(state.callsBeforeSentinel, 1); + assert.deepEqual(state.inFlightByMethod, { getSettings: 1 }); + await assert.rejects( + detachJourneyMainIpcCapture(fake.app, stateKey), + /not-attached/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts index 4e7dbbd08..8fc3d7c7f 100644 --- a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts +++ b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts @@ -102,7 +102,10 @@ export async function installJourneyMainIpcCapture( } }; target[input.stateKey] = state; - ipcMain.on(input.channel, (event, payload: unknown) => { + const listener = ( + event: { sender: { id: number } }, + payload: unknown + ): void => { const record = typeof payload === 'object' && payload !== null ? (payload as Record) @@ -165,10 +168,36 @@ export async function installJourneyMainIpcCapture( state.callsBeforeSentinel += 1; state.callsByMethod[method] = (state.callsByMethod[method] ?? 0) + 1; - }); + }; + ipcMain.on(input.channel, listener); + // Kept next to the state (which is read as JSON) so the capture can + // be detached from the same main process later. + target[`${input.stateKey}:detach`] = () => { + ipcMain.removeListener(input.channel, listener); + }; }, options); } +/** + * Removes a capture's listener. J2 detaches J1's capture once it has used it + * to settle, so the launch listener does not run for every bridge call of + * the measured click. + */ +export async function detachJourneyMainIpcCapture( + electronApp: ElectronApplication, + stateKey: string +): Promise { + await electronApp.evaluate((_electron, key) => { + const target = globalThis as unknown as Record; + const detach = target[`${key}:detach`]; + if (typeof detach !== 'function') { + throw new Error('journey-main-ipc-capture-not-attached'); + } + (detach as () => void)(); + delete target[`${key}:detach`]; + }, stateKey); +} + /** Total of `inFlightByMethod`. */ export function countJourneyMainIpcInFlight( state: JourneyMainIpcCaptureState diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index a1c9c3329..620db7a09 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -264,7 +264,9 @@ fails the iteration). Bridge calls in flight come from J1's IPC capture: it was installed before the document loaded, and the preload follows every traced `start` with exactly one `success` or `error`, so a call that is still pending cannot resolve after the click and have its DOM changes or follow-up -calls counted as J2. The settle is a snapshot, and Playwright's +calls counted as J2. After settling, J1's capture is detached +(`detachJourneyMainIpcCapture`), so its listener does not run for every +bridge call of the measured click. The settle is a snapshot, and Playwright's actionability checks run between it and the click. The probe and the IPC capture keep counting pre-click activity until the click event itself, and the ledger splits at the click stamp. So the record rejects an iteration