From d1e268f1ddd59de7e5aca87d57987f7f2437cc2a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 22:57:48 +0200 Subject: [PATCH] chore(electron): add perf-only worker heap probe --- .../database-worker-post-gc-heap.spec.ts | 303 ++++++++++++++++++ .../workers/database-worker-post-gc-heap.ts | 124 +++++++ 2 files changed, 427 insertions(+) create mode 100644 apps/electron-backend/src/app/workers/database-worker-post-gc-heap.spec.ts create mode 100644 apps/electron-backend/src/app/workers/database-worker-post-gc-heap.ts diff --git a/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.spec.ts b/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.spec.ts new file mode 100644 index 000000000..0ce33a94e --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.spec.ts @@ -0,0 +1,303 @@ +import { once } from 'node:events'; +import { getHeapStatistics } from 'node:v8'; +import { MessageChannel, type MessagePort } from 'node:worker_threads'; +import { + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON, + handleDatabaseWorkerPostGcHeapRequest, + isDatabaseWorkerPostGcHeapRequest, + type DatabaseWorkerPostGcHeapRequest, + type DatabaseWorkerPostGcHeapResult, +} from './database-worker-post-gc-heap'; + +jest.mock('node:v8', () => ({ + getHeapStatistics: jest.fn(), +})); + +const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +const mockedGetHeapStatistics = getHeapStatistics as jest.MockedFunction< + typeof getHeapStatistics +>; + +type FakeResponsePort = { + close: jest.Mock; + messages: DatabaseWorkerPostGcHeapResult[]; + port: MessagePort; + postMessage: jest.Mock; +}; + +function createFakeResponsePort(): FakeResponsePort { + const messages: DatabaseWorkerPostGcHeapResult[] = []; + const close = jest.fn(); + const postMessage = jest.fn( + (message) => { + messages.push(message); + } + ); + + return { + close, + messages, + port: { + close, + postMessage, + } as unknown as MessagePort, + postMessage, + }; +} + +function handleWithFakePort(isIdle: boolean): { + gc: jest.Mock | null; + responsePort: FakeResponsePort; +} { + const responsePort = createFakeResponsePort(); + const gcCandidate = Reflect.get(globalThis, 'gc'); + const request: DatabaseWorkerPostGcHeapRequest = { + type: 'performance:collect-post-gc-heap', + responsePort: responsePort.port, + }; + + handleDatabaseWorkerPostGcHeapRequest(request, isIdle); + + return { + gc: + typeof gcCandidate === 'function' + ? (gcCandidate as unknown as jest.Mock) + : null, + responsePort, + }; +} + +describe('database worker post-GC heap capture', () => { + const originalProfilingValue = process.env[PROFILING_ENV]; + const originalGc = Reflect.get(globalThis, 'gc'); + + beforeEach(() => { + process.env[PROFILING_ENV] = '1'; + Reflect.set(globalThis, 'gc', jest.fn()); + mockedGetHeapStatistics.mockReset(); + mockedGetHeapStatistics.mockReturnValue({ + used_heap_size: 42_000, + } as ReturnType); + }); + + afterAll(() => { + if (originalProfilingValue === undefined) { + delete process.env[PROFILING_ENV]; + } else { + process.env[PROFILING_ENV] = originalProfilingValue; + } + + if (originalGc === undefined) { + Reflect.deleteProperty(globalThis, 'gc'); + } else { + Reflect.set(globalThis, 'gc', originalGc); + } + }); + + it('recognizes only the exact request with a real MessagePort', () => { + const { port1, port2 } = new MessageChannel(); + + try { + expect( + isDatabaseWorkerPostGcHeapRequest({ + type: 'performance:collect-post-gc-heap', + responsePort: port2, + }) + ).toBe(true); + expect(isDatabaseWorkerPostGcHeapRequest(null)).toBe(false); + expect( + isDatabaseWorkerPostGcHeapRequest({ + type: 'performance:collect-post-gc-heap', + }) + ).toBe(false); + expect( + isDatabaseWorkerPostGcHeapRequest({ + type: 'performance:collect-post-gc-heap', + responsePort: { + close: jest.fn(), + postMessage: jest.fn(), + }, + }) + ).toBe(false); + expect( + isDatabaseWorkerPostGcHeapRequest({ + type: 'request', + responsePort: port2, + }) + ).toBe(false); + } finally { + port1.close(); + port2.close(); + } + }); + + it('forces GC before reading and returning the worker V8 heap', async () => { + const lifecycle: string[] = []; + const { port1, port2 } = new MessageChannel(); + Reflect.set( + globalThis, + 'gc', + jest.fn(() => lifecycle.push('gc')) + ); + mockedGetHeapStatistics.mockImplementation(() => { + lifecycle.push('heap'); + return { + used_heap_size: 73_728, + } as ReturnType; + }); + const responsePromise = once(port1, 'message') as Promise< + [DatabaseWorkerPostGcHeapResult] + >; + const peerClosedPromise = once(port1, 'close'); + + handleDatabaseWorkerPostGcHeapRequest( + { + type: 'performance:collect-post-gc-heap', + responsePort: port2, + }, + true + ); + + await expect(responsePromise).resolves.toEqual([ + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: 73_728, + unavailableReason: null, + }, + ]); + await peerClosedPromise; + expect(lifecycle).toEqual(['gc', 'heap']); + }); + + it('fails closed without profiling opt-in', () => { + delete process.env[PROFILING_ENV]; + + const { gc, responsePort } = handleWithFakePort(true); + + expect(gc).not.toHaveBeenCalled(); + expect(mockedGetHeapStatistics).not.toHaveBeenCalled(); + expect(responsePort.messages).toEqual([ + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: null, + unavailableReason: + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.PROFILING_DISABLED, + }, + ]); + expect(responsePort.postMessage).toHaveBeenCalledTimes(1); + expect(responsePort.close).toHaveBeenCalledTimes(1); + }); + + it('does not perturb an active database operation', () => { + const { gc, responsePort } = handleWithFakePort(false); + + expect(gc).not.toHaveBeenCalled(); + expect(mockedGetHeapStatistics).not.toHaveBeenCalled(); + expect(responsePort.messages).toEqual([ + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: null, + unavailableReason: + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.WORKER_BUSY, + }, + ]); + expect(responsePort.postMessage).toHaveBeenCalledTimes(1); + expect(responsePort.close).toHaveBeenCalledTimes(1); + }); + + it('reports an unavailable exposed-GC runtime without reading heap statistics', () => { + Reflect.deleteProperty(globalThis, 'gc'); + + const { responsePort } = handleWithFakePort(true); + + expect(mockedGetHeapStatistics).not.toHaveBeenCalled(); + expect(responsePort.messages).toEqual([ + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: null, + unavailableReason: + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.GC_UNAVAILABLE, + }, + ]); + expect(responsePort.postMessage).toHaveBeenCalledTimes(1); + expect(responsePort.close).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['negative', -1], + ['fractional', 1.5], + ['NaN', Number.NaN], + ['infinite', Number.POSITIVE_INFINITY], + ['unsafe', Number.MAX_SAFE_INTEGER + 1], + ])('rejects a %s heap measurement', (_label, usedHeapSize) => { + mockedGetHeapStatistics.mockReturnValue({ + used_heap_size: usedHeapSize, + } as ReturnType); + + const { gc, responsePort } = handleWithFakePort(true); + + expect(gc).toHaveBeenCalledTimes(1); + expect(mockedGetHeapStatistics).toHaveBeenCalledTimes(1); + expect(responsePort.messages).toEqual([ + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: null, + unavailableReason: + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.CAPTURE_FAILED, + }, + ]); + expect(responsePort.postMessage).toHaveBeenCalledTimes(1); + expect(responsePort.close).toHaveBeenCalledTimes(1); + }); + + it.each(['gc', 'heap'] as const)( + 'reports capture failure when %s collection throws', + (failurePoint) => { + if (failurePoint === 'gc') { + Reflect.set( + globalThis, + 'gc', + jest.fn(() => { + throw new Error('gc failed'); + }) + ); + } else { + mockedGetHeapStatistics.mockImplementation(() => { + throw new Error('heap failed'); + }); + } + + const { responsePort } = handleWithFakePort(true); + + expect(responsePort.messages).toEqual([ + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: null, + unavailableReason: + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.CAPTURE_FAILED, + }, + ]); + expect(responsePort.postMessage).toHaveBeenCalledTimes(1); + expect(responsePort.close).toHaveBeenCalledTimes(1); + } + ); + + it('still closes the one-shot port when posting the result fails', () => { + const responsePort = createFakeResponsePort(); + responsePort.postMessage.mockImplementation(() => { + throw new Error('port closed'); + }); + + expect(() => + handleDatabaseWorkerPostGcHeapRequest( + { + type: 'performance:collect-post-gc-heap', + responsePort: responsePort.port, + }, + true + ) + ).not.toThrow(); + expect(responsePort.postMessage).toHaveBeenCalledTimes(1); + expect(responsePort.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.ts b/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.ts new file mode 100644 index 000000000..fa95bc390 --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.ts @@ -0,0 +1,124 @@ +import { getHeapStatistics } from 'node:v8'; +import { MessagePort } from 'node:worker_threads'; +import { WORKER_PROFILING_ENV } from './worker-performance-capture.runtime'; + +export const DATABASE_WORKER_POST_GC_HEAP_REQUEST_TYPE = + 'performance:collect-post-gc-heap' as const; +export const DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE = + 'performance:post-gc-heap-result' as const; + +export const DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON = { + CAPTURE_FAILED: 'capture-failed', + GC_UNAVAILABLE: 'gc-unavailable', + PROFILING_DISABLED: 'profiling-disabled', + WORKER_BUSY: 'worker-busy', +} as const; + +export type DatabaseWorkerPostGcHeapUnavailableReason = + (typeof DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON)[keyof typeof DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON]; + +export interface DatabaseWorkerPostGcHeapRequest { + readonly type: typeof DATABASE_WORKER_POST_GC_HEAP_REQUEST_TYPE; + readonly responsePort: MessagePort; +} + +export type DatabaseWorkerPostGcHeapResult = + | { + readonly type: typeof DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE; + readonly postGcHeapUsedBytes: number; + readonly unavailableReason: null; + } + | { + readonly type: typeof DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE; + readonly postGcHeapUsedBytes: null; + readonly unavailableReason: DatabaseWorkerPostGcHeapUnavailableReason; + }; + +export function isDatabaseWorkerPostGcHeapRequest( + message: unknown +): message is DatabaseWorkerPostGcHeapRequest { + if ( + typeof message !== 'object' || + message === null || + Array.isArray(message) + ) { + return false; + } + + const candidate = message as Record; + return ( + candidate['type'] === DATABASE_WORKER_POST_GC_HEAP_REQUEST_TYPE && + candidate['responsePort'] instanceof MessagePort + ); +} + +function unavailableResult( + unavailableReason: DatabaseWorkerPostGcHeapUnavailableReason +): DatabaseWorkerPostGcHeapResult { + return { + type: DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE, + postGcHeapUsedBytes: null, + unavailableReason, + }; +} + +function postResult( + responsePort: MessagePort, + result: DatabaseWorkerPostGcHeapResult +): void { + try { + responsePort.postMessage(result); + } catch { + // Development-only profiling transport must not affect worker behavior. + } finally { + try { + responsePort.close(); + } catch { + // The one-shot port may already be closed by its peer. + } + } +} + +export function handleDatabaseWorkerPostGcHeapRequest( + message: DatabaseWorkerPostGcHeapRequest, + isIdle: boolean +): void { + let result: DatabaseWorkerPostGcHeapResult; + + if (process.env[WORKER_PROFILING_ENV] !== '1') { + result = unavailableResult( + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.PROFILING_DISABLED + ); + } else if (!isIdle) { + result = unavailableResult( + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.WORKER_BUSY + ); + } else if (typeof globalThis.gc !== 'function') { + result = unavailableResult( + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.GC_UNAVAILABLE + ); + } else { + try { + globalThis.gc(); + const postGcHeapUsedBytes = getHeapStatistics().used_heap_size; + + result = + Number.isSafeInteger(postGcHeapUsedBytes) && + postGcHeapUsedBytes >= 0 + ? { + type: DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE, + postGcHeapUsedBytes, + unavailableReason: null, + } + : unavailableResult( + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.CAPTURE_FAILED + ); + } catch { + result = unavailableResult( + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.CAPTURE_FAILED + ); + } + } + + postResult(message.responsePort, result); +}