From f0765012fa8a40b3324927c790072332aadd872b Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:40:34 +0200 Subject: [PATCH] fix(playback): stop external player position polling that starts after exit (#1720) MPV and VLC wait 2 s and 1.5 s before their first position poll, but the delay timer was never stored, so stopping the poll during that wait could not cancel it. A player that exited, errored or was replaced early left a 5 s (MPV) or 2 s (VLC) interval polling a dead socket or RC port until the next launch or app quit. Keep the delay handle and clear it with the interval. Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .changes/playback-external-poll-after-exit.md | 7 + .../external-player-position-polling.spec.ts | 197 ++++++++++++++++++ .../src/app/events/mpv-session.service.ts | 10 +- .../src/app/events/vlc-session.service.ts | 10 +- 4 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 .changes/playback-external-poll-after-exit.md create mode 100644 apps/electron-backend/src/app/events/external-player-position-polling.spec.ts diff --git a/.changes/playback-external-poll-after-exit.md b/.changes/playback-external-poll-after-exit.md new file mode 100644 index 000000000..87dbd7d37 --- /dev/null +++ b/.changes/playback-external-poll-after-exit.md @@ -0,0 +1,7 @@ +--- +type: fix +area: playback +--- + +Closing MPV or VLC within the first seconds of playback no longer leaves the +app checking the closed player's position in the background until it quits. diff --git a/apps/electron-backend/src/app/events/external-player-position-polling.spec.ts b/apps/electron-backend/src/app/events/external-player-position-polling.spec.ts new file mode 100644 index 000000000..d06332fba --- /dev/null +++ b/apps/electron-backend/src/app/events/external-player-position-polling.spec.ts @@ -0,0 +1,197 @@ +/** + * Position polling of external players must not outlive the player. Both + * services wait before the first poll so the player can open its control + * channel; a player that exits during that wait must not leave an interval + * polling a dead socket or port for the rest of the app session. + */ +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn(), + }, +})); + +jest.mock('child_process', () => ({ + spawn: jest.fn(), +})); + +jest.mock('net', () => ({ + createConnection: jest.fn(), + createServer: jest.fn(), +})); + +jest.mock('../app', () => ({ + __esModule: true, + default: { + mainWindow: null, + }, +})); + +jest.mock('../services/store.service', () => ({ + MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS', + MPV_PLAYER_PATH: 'MPV_PLAYER_PATH', + MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE', + VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS', + VLC_PLAYER_PATH: 'VLC_PLAYER_PATH', + VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE', + store: { + get: jest.fn(), + set: jest.fn(), + }, +})); + +import { spawn, type ChildProcess } from 'child_process'; +import { EventEmitter } from 'events'; +import { createConnection, createServer } from 'net'; +import type { PlayerContentInfo } from '@iptvnator/shared/interfaces'; +import { + MPV_PLAYER_PATH, + MPV_REUSE_INSTANCE, + VLC_PLAYER_PATH, + VLC_REUSE_INSTANCE, + store, +} from '../services/store.service'; +import { openMpvPlayer } from './mpv-session.service'; +import { openVlcPlayer } from './vlc-session.service'; + +const spawnMock = spawn as unknown as jest.Mock; +const createConnectionMock = createConnection as unknown as jest.Mock; +const contentInfo: PlayerContentInfo = { + playlistId: 'playlist-1', + contentXtreamId: 7, + contentType: 'vod', +}; + +function createMockChildProcess(): ChildProcess { + return Object.assign(new EventEmitter(), { + exitCode: null, + killed: false, + kill: jest.fn(() => true), + signalCode: null, + stderr: null, + stdout: null, + unref: jest.fn(), + }) as unknown as ChildProcess; +} + +function exitProcess(proc: ChildProcess): void { + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); +} + +/** A control connection that never answers; each poll just records itself. */ +function installSilentConnection(): void { + createConnectionMock.mockImplementation(() => + Object.assign(new EventEmitter(), { + destroyed: false, + destroy: jest.fn(), + end: jest.fn(), + setEncoding: jest.fn(), + write: jest.fn(() => true), + }) + ); +} + +function mockStoreValues(values: Record): void { + (store.get as unknown as jest.Mock).mockImplementation( + (key: string, fallback?: unknown) => + key in values ? values[key] : fallback + ); +} + +async function openMpv(): Promise { + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + const opening = openMpvPlayer({ + title: 'Movie', + url: 'https://example.com/movie.mp4', + contentInfo, + }); + await jest.advanceTimersByTimeAsync(100); + await opening; + return proc; +} + +async function openVlc(): Promise { + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + const opening = openVlcPlayer({ + title: 'Movie', + url: 'https://example.com/movie.mp4', + contentInfo, + }); + while (spawnMock.mock.calls.length === 0) { + await jest.advanceTimersByTimeAsync(0); + } + proc.emit('spawn'); + await opening; + return proc; +} + +describe('external player position polling lifetime', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.spyOn(console, 'error').mockImplementation(); + installSilentConnection(); + (createServer as unknown as jest.Mock).mockImplementation(() => ({ + unref: jest.fn(), + on: jest.fn(), + listen: (_port: number, _host: string, cb: () => void) => cb(), + address: () => ({ port: 43210 }), + close: (cb?: () => void) => cb?.(), + })); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: false, + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: false, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('polls MPV once the start delay has passed', async () => { + const proc = await openMpv(); + createConnectionMock.mockClear(); + + await jest.advanceTimersByTimeAsync(7_000); + + expect(createConnectionMock).toHaveBeenCalled(); + exitProcess(proc); + }); + + it('never polls MPV when it exits before the first poll', async () => { + const proc = await openMpv(); + exitProcess(proc); + createConnectionMock.mockClear(); + + await jest.advanceTimersByTimeAsync(30_000); + + expect(createConnectionMock).not.toHaveBeenCalled(); + }); + + it('polls VLC once the start delay has passed', async () => { + const proc = await openVlc(); + createConnectionMock.mockClear(); + + await jest.advanceTimersByTimeAsync(4_000); + + expect(createConnectionMock).toHaveBeenCalledWith( + expect.objectContaining({ port: 43210 }) + ); + exitProcess(proc); + }); + + it('never polls VLC when it exits before the first poll', async () => { + const proc = await openVlc(); + exitProcess(proc); + createConnectionMock.mockClear(); + + await jest.advanceTimersByTimeAsync(30_000); + + expect(createConnectionMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/events/mpv-session.service.ts b/apps/electron-backend/src/app/events/mpv-session.service.ts index b9809afcd..f4d67df9a 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.ts @@ -49,6 +49,9 @@ export interface OpenExternalPlayerRequest { const reusableMpvProcess = new MpvReusableProcess(); let positionPollingInterval: NodeJS.Timeout | null = null; +// The first poll waits for MPV to open its IPC socket. The handle is kept so +// a player that exits during that wait does not start an orphaned interval. +let positionPollingDelay: NodeJS.Timeout | null = null; function getMpvPath(options: PlayerPathOptions = {}): string { return ( @@ -120,6 +123,10 @@ async function getMpvProperty( } function stopPositionPolling(): void { + if (positionPollingDelay) { + clearTimeout(positionPollingDelay); + positionPollingDelay = null; + } if (positionPollingInterval) { clearInterval(positionPollingInterval); positionPollingInterval = null; @@ -137,7 +144,8 @@ function startPositionPolling( ): void { stopPositionPolling(); - setTimeout(() => { + positionPollingDelay = setTimeout(() => { + positionPollingDelay = null; positionPollingInterval = setInterval(async () => { try { const position = await getMpvProperty(socketPath, 'time-pos'); diff --git a/apps/electron-backend/src/app/events/vlc-session.service.ts b/apps/electron-backend/src/app/events/vlc-session.service.ts index 7ca52da0f..4c24dbb65 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.ts @@ -54,6 +54,9 @@ export interface OpenVlcPlayerRequest { const reusableVlcProcess = new VlcReusableProcess(); let vlcPollingInterval: NodeJS.Timeout | null = null; +// Kept so a player that exits before the first poll cannot leave an orphaned +// interval behind. +let vlcPollingDelay: NodeJS.Timeout | null = null; function getVlcPath(options: PlayerPathOptions = {}): string { return ( @@ -63,6 +66,10 @@ function getVlcPath(options: PlayerPathOptions = {}): string { } function stopVlcPositionPolling(): void { + if (vlcPollingDelay) { + clearTimeout(vlcPollingDelay); + vlcPollingDelay = null; + } if (vlcPollingInterval) { clearInterval(vlcPollingInterval); vlcPollingInterval = null; @@ -78,7 +85,8 @@ function startVlcPositionPolling( ): void { stopVlcPositionPolling(); - setTimeout(() => { + vlcPollingDelay = setTimeout(() => { + vlcPollingDelay = null; vlcPollingInterval = setInterval(async () => { try { const snapshot = await getVlcPlaybackSnapshot(port);