fix(playback): claim radio header ownership before awaiting the IPC

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-02 09:04:28 +02:00
1 parent c2d1bff89d
commit 4c0be3fde5
2 files changed
+60 -4

No files matched your search

@@ -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<void>(
(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<boolean>((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 {
@@ -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;
}