mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
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;
|
||||
|
||||
Reference in new issue
Block a user