From 4c0be3fde54f165aadfc686013c9a4606f002032 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Aug 2026 09:04:28 +0200 Subject: [PATCH] fix(playback): claim radio header ownership before awaiting the IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round-2 P2: leaving the radio route while the header IPC was still in flight left the portal cookie/token installed — ngOnDestroy saw a null scope URL (it was recorded only after the await) and could not clear the override. Ownership is now claimed synchronously before awaiting, destroy invalidates the pending playback continuation, and the apply's stillCurrent verdict is honored. Regression test covers destroy-during-pending-IPC. Co-Authored-By: Claude Fable 5 --- ...alker-live-stream-layout.component.spec.ts | 46 +++++++++++++++++++ .../stalker-live-stream-layout.component.ts | 18 ++++++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index a40f3da13..259726302 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -1168,6 +1168,52 @@ describe('StalkerLiveStreamLayoutComponent', () => { ).componentInstance as StubAudioPlayerComponent; expect(audioPlayer.url()).toBe('http://portal.example/radio_2.mpg'); }); + + it('clears the radio header override when destroyed while the apply IPC is pending', async () => { + // Leaving the radio route mid-apply must not leave the portal + // cookie/token installed: ownership is claimed before awaiting the + // IPC, so ngOnDestroy can always name the stream to clear. + stalkerStore.selectedContentType.set('radio'); + selectedCategoryId.set('radio-all'); + selectedItem.set(null); + selectedItvId.set(undefined); + fixture.detectChanges(); + let resolveApply: (() => void) | undefined; + let signalApplyIssued!: () => void; + const applyIssued = new Promise( + (resolve) => (signalApplyIssued = resolve) + ); + // Once: only the apply call gets the pending promise — the destroy + // clear below also calls the bridge and must not steal the resolver. + ( + window.electron?.setUserAgent as jest.Mock + ).mockImplementationOnce(() => { + signalApplyIssued(); + return new Promise((resolve) => { + resolveApply = () => resolve(true); + }); + }); + resolveRadioPlayback.mockResolvedValue({ + streamUrl: 'http://portal.example/radio_2.mpg', + title: 'Portal FM', + headers: { Cookie: 'mac=00:1A:79:00:00:01' }, + }); + + const playPromise = component.playChannel(radioChannels()[0]); + // The header IPC has been issued but is still pending. + await applyIssued; + + fixture.destroy(); + resolveApply?.(); + await playPromise; + + expect(window.electron?.setUserAgent).toHaveBeenLastCalledWith( + undefined, + undefined, + 'http://portal.example/radio_2.mpg' + ); + expect(component.activePlayback()).toBeNull(); + }); }); function buildProgram(channelId: string, title: string): EpgProgram { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 47a2c1641..a2f0c470f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -560,8 +560,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.unsubscribeRemoteChannelChange?.(); this.unsubscribeRemoteCommand?.(); this.removeScrollListener(); - // Radio credentials must not outlive this layout; the service no-ops - // when a newer playback already owns the override slot. + // Invalidate any playback continuation still awaiting its header + // IPC, then drop the radio credentials — they must not outlive this + // layout. The service no-ops when a newer playback already owns the + // override slot. + this.playbackRequestId += 1; this.streamHeaders.clear(this.radioHeaderScopeUrl); } @@ -598,14 +601,21 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { // WebPlayerViewComponent, so the scoped Electron header // override (portal cookie/token for auth-gated streams) must // be configured here BEFORE the audio element gets the URL. - await this.streamHeaders.apply(playback); + // Ownership is claimed synchronously, before awaiting the + // IPC: if this layout is destroyed while the apply is still + // in flight, ngOnDestroy must already know which stream's + // override to clear — otherwise the credentials would + // outlive the route. + const headerSync = this.streamHeaders.apply(playback); + this.radioHeaderScopeUrl = playback.streamUrl; + const stillCurrent = headerSync ? await headerSync : true; if ( + !stillCurrent || requestId !== this.playbackRequestId || this.selectedChannelId() !== channelId ) { return; } - this.radioHeaderScopeUrl = playback.streamUrl; this.activePlayback.set(playback); return; }