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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-10 23:02:41 +02:00
1 parent 64f150c91c
commit 919bfabf72
2 files changed
+54 -3

No files matched your search

@@ -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'));
@@ -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().
});
}