From 919bfabf722dfbad50b1638e00ea6d85ba62a0ec Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 10 Aug 2026 23:02:41 +0200 Subject: [PATCH] fix(playback): re-evaluate the wake lock after a rejection masked a state change In the PWA path a hidden-visible round-trip (or pause/resume) while wakeLock.request() was pending got swallowed by the in-flight guard; if that request then rejected, only the flag was cleared and a continuously playing visible video sat without a wake lock until the next unrelated event. State changes arriving mid-flight now queue one re-evaluation on rejection; permanent denials still don't loop because nothing queues a retry without a fresh interleaved change. Review finding by Codex. Co-Authored-By: Claude Fable 5 --- .../playback-keep-awake.service.spec.ts | 32 +++++++++++++++++++ .../services/playback-keep-awake.service.ts | 25 +++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/services/playback-keep-awake.service.spec.ts b/apps/web/src/app/services/playback-keep-awake.service.spec.ts index b352a7115..7c0f1cd51 100644 --- a/apps/web/src/app/services/playback-keep-awake.service.spec.ts +++ b/apps/web/src/app/services/playback-keep-awake.service.spec.ts @@ -248,6 +248,38 @@ describe('PlaybackKeepAwakeService', () => { expect(request).toHaveBeenCalledTimes(2); }); + it('re-evaluates after a rejection that masked a state change', async () => { + let rejectRequest: ((reason: Error) => void) | undefined; + request.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRequest = reject; + }) + ); + + video.dispatchEvent(new Event('playing')); + // Hidden→visible round-trip while request() is still pending: + // both sync() calls are swallowed by the in-flight guard, and + // the pending request rejects because of the hidden moment. + setVisibility('hidden'); + setVisibility('visible'); + rejectRequest?.(new Error('document was hidden')); + await flush(); + + // The masked state change must trigger a fresh request — the + // video is still playing in a visible document. + expect(request).toHaveBeenCalledTimes(2); + }); + + it('does not retry a plain rejection with no interleaved change', async () => { + request.mockRejectedValueOnce(new Error('denied')); + + video.dispatchEvent(new Event('playing')); + await flush(); + + expect(request).toHaveBeenCalledTimes(1); + }); + it('survives a denied wake lock request', async () => { request.mockRejectedValueOnce(new Error('denied')); diff --git a/apps/web/src/app/services/playback-keep-awake.service.ts b/apps/web/src/app/services/playback-keep-awake.service.ts index 4bbc64895..5c54ec8b8 100644 --- a/apps/web/src/app/services/playback-keep-awake.service.ts +++ b/apps/web/src/app/services/playback-keep-awake.service.ts @@ -54,6 +54,7 @@ export class PlaybackKeepAwakeService { private lastSentToBridge: boolean | null = null; private wakeLock: WakeLockSentinelLike | null = null; private wakeLockRequestInFlight = false; + private wakeLockRetryQueued = false; private readonly onPlaying = (event: Event) => { const target = event.target; @@ -165,7 +166,15 @@ export class PlaybackKeepAwakeService { } private acquireWakeLock(): void { - if (this.wakeLock || this.wakeLockRequestInFlight) { + if (this.wakeLock) { + return; + } + if (this.wakeLockRequestInFlight) { + // A state change arrived while request() is still pending (e.g. + // hidden→visible round-trip). The pending request may reject + // because of the moment it was processed in, so remember to + // re-evaluate once it settles instead of dropping this signal. + this.wakeLockRetryQueued = true; return; } const wakeLock = (navigator as WakeLockNavigator).wakeLock; @@ -177,6 +186,9 @@ export class PlaybackKeepAwakeService { .request('screen') .then((sentinel) => { this.wakeLockRequestInFlight = false; + // The resolve path re-reads current state below, which + // covers whatever change queued the retry. + this.wakeLockRetryQueued = false; // The browser releases the sentinel on its own when the page // hides; forget it so the next sync() can re-request. sentinel.addEventListener?.('release', () => { @@ -191,9 +203,16 @@ export class PlaybackKeepAwakeService { this.wakeLock = sentinel; }) .catch(() => { - // Denied (battery saver, hidden document, …) — a later - // `playing` or visibility event retries via sync(). this.wakeLockRequestInFlight = false; + if (this.wakeLockRetryQueued) { + // A state change was masked by the in-flight request — + // re-evaluate now. Permanent denials don't loop: without + // a fresh interleaved change nothing queues a retry. + this.wakeLockRetryQueued = false; + this.sync(); + } + // Otherwise: denied (battery saver, hidden document, …) — a + // later `playing` or visibility event retries via sync(). }); }