fix(embedded-mpv): reap sessions when the renderer reloads or crashes

Root cause of the stray idle helper found during live testing: session
teardown lives in the renderer's Angular lifecycle, which never runs on a
renderer crash or hard reload — the main process kept the session (and its
frame-copy helper process / native mpv handle) alive until app shutdown.
EmbeddedMpvNativeService now watches the main window's webContents for
render-process-gone and did-navigate (full reloads only; in-app Angular
routing emits did-navigate-in-page) and disposes every session. Applies to
both engines. Verified live: location.reload() during frame-copy playback
logs 'Disposing 1 session(s): renderer reloaded' and the helper exits
cleanly. Regression test drives both events against the service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-15 18:20:45 +02:00
1 parent e36cc44d44
commit 5a3ea5a3c1
2 files changed
+82 -1

No files matched your search

@@ -34,13 +34,14 @@ jest.mock('electron', () => ({
}));
const mainWindowSendMock = jest.fn();
const mainWindowWebContentsOnMock = jest.fn();
const mainWindowGetNativeWindowHandleMock = jest.fn<Buffer, []>(() =>
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<string, (...args: unknown[]) => 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
@@ -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;