fix(portals): a failed close must not leave the page claiming a dead source

When `closeSession()` rejected, `startResolvedPlayback` rejected with it and
never launched — while `switchToSource` had already marked the destination
active and reported the switch as successful. The page then named a source
that nothing was playing.

The close failure is logged and the replacement starts anyway. A close that
rejects usually means the session was already gone, and a possibly-lingering
process is the lesser of the two evils: the alternative is a UI that lies
about what is on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-29 02:41:30 +02:00
1 parent 7c1b433d47
commit caa89e3a6e
2 files changed
+31 -1

No files matched your search

@@ -242,6 +242,24 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
closeSession.mockResolvedValue(undefined);
});
it('still starts the replacement when closing the old player fails', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
closeSession.mockRejectedValue(new Error('close ipc failed'));
openResolvedPlayback.mockClear();
await service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
});
// The switch is already committed — the badge names the new source.
// Bailing out here left the page claiming a source with nothing
// started at all.
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
closeSession.mockResolvedValue(undefined);
});
it('launches only the newest source when two switches overlap', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
// One shared promise: both calls see the same running session, so
@@ -358,7 +358,19 @@ export class VodDetailsPlaybackService {
this.matchedExternalPlayback()
);
if (running) {
await this.externalPlayback.closeSession(running);
try {
await this.externalPlayback.closeSession(running);
} catch (error) {
// The switch is already committed — the badge names the new
// source. Giving up here would leave the page claiming a
// source with nothing started, which is worse than the
// pre-existing risk of a lingering process (and a close that
// rejects usually means the session was gone anyway).
this.logger.warn(
'Closing the previous external player failed; starting the replacement anyway.',
error
);
}
}
// Closing is a round-trip, and a second pick across it would otherwise