diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index b97950dc1..1f9a22e14 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -34,13 +34,14 @@ jest.mock('electron', () => ({ })); const mainWindowSendMock = jest.fn(); +const mainWindowWebContentsOnMock = jest.fn(); const mainWindowGetNativeWindowHandleMock = jest.fn(() => Buffer.alloc(8) ); const mainWindowMock = { isDestroyed: () => false, getNativeWindowHandle: mainWindowGetNativeWindowHandleMock, - webContents: { send: mainWindowSendMock }, + webContents: { send: mainWindowSendMock, on: mainWindowWebContentsOnMock }, }; jest.mock('../app', () => ({ @@ -131,6 +132,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { mainWindowGetNativeWindowHandleMock.mockReset(); mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8)); mainWindowSendMock.mockReset(); + mainWindowWebContentsOnMock.mockReset(); tempDirs = []; nextBlockerId = 1; @@ -209,6 +211,40 @@ describe('EmbeddedMpvNativeService power blocker', () => { expect(powerSaveBlockerMock.start).not.toHaveBeenCalled(); }); + it('disposes sessions when the renderer reloads or crashes', () => { + // Angular teardown never runs on a renderer crash or hard reload, so + // the main process must reap sessions itself — otherwise native mpv + // handles / frame-copy helper processes leak until app shutdown. + startSession('s1', snapshot('playing')); + addon.getSessionSnapshot.mockReturnValue(snapshot('playing')); + + const handlers = new Map void>( + mainWindowWebContentsOnMock.mock.calls.map( + ([event, handler]: [string, (...args: unknown[]) => void]) => [ + event, + handler, + ] + ) + ); + expect([...handlers.keys()]).toEqual( + expect.arrayContaining(['render-process-gone', 'did-navigate']) + ); + + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(); + handlers.get('did-navigate')?.(); + expect(addon.disposeSession).toHaveBeenCalledWith('s1'); + + // A crash after everything is already disposed must be a no-op. + addon.disposeSession.mockClear(); + handlers.get('render-process-gone')?.(undefined, { + reason: 'crashed', + }); + expect(addon.disposeSession).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + }); + it('keeps the polling timer alive when refreshing a session throws', () => { jest.useFakeTimers(); const consoleErrorSpy = jest diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index 8db3ed13e..78a483f90 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -408,6 +408,7 @@ export class EmbeddedMpvNativeService { }); this.ensurePolling(); + this.ensureRendererLifecycleWatch(); return ( this.refreshSession(sessionId) ?? { id: sessionId, @@ -657,6 +658,50 @@ export class EmbeddedMpvNativeService { }, 500); } + /** + * Sessions are torn down by the renderer's Angular lifecycle, which + * never runs when the renderer crashes or hard-reloads (dev-server HMR, + * Cmd+R). Without this watch, native mpv handles or frame-copy helper + * processes leak until app shutdown. + */ + private rendererWatchInstalled = false; + + private ensureRendererLifecycleWatch(): void { + if ( + this.rendererWatchInstalled || + !App.mainWindow || + App.mainWindow.isDestroyed() + ) { + return; + } + this.rendererWatchInstalled = true; + + const disposeAll = (reason: string) => { + if (this.sessions.size === 0) { + return; + } + console.warn( + `[Embedded MPV] Disposing ${this.sessions.size} session(s): renderer ${reason}` + ); + [...this.sessions.keys()].forEach((sessionId) => { + try { + this.disposeSession(sessionId); + } catch { + // best-effort reaping; polling cleanup handles the rest + } + }); + }; + + App.mainWindow.webContents.on('render-process-gone', (_event, details) => + disposeAll(`process gone (${details.reason})`) + ); + // Full navigations/reloads only — in-app Angular routing emits + // did-navigate-in-page and must not kill the active session. + App.mainWindow.webContents.on('did-navigate', () => + disposeAll('reloaded') + ); + } + private stopPollingIfIdle(): void { if (this.sessions.size > 0 || !this.pollingTimer) { return;