fix(renderer): roll back the mute-suppression latch when the IPC fails

CodeRabbit caught a real bug in the previous commit's fix. The latch mirrors
the NATIVE refcount, but it was flipped before the invoke resolved: a rejected
release left it reading "released" while the engine still held the
suppression, so every later release short-circuited and monitor mute stayed
suppressed for good — the same stuck-suppression bug the latch exists to
prevent, just one level up.

The latch now only stays flipped if the call actually landed, and rolls back
otherwise (guarded so a newer call can't be clobbered by a stale rejection). A
downlevel addon with no arbiter leaves the latch untouched instead of
recording a hold it never acquired.

Pins the whole contract with a vm-extracted unit test on the real screen.js
function: unpaired acquires hold at most one native suppression, cycles stay
balanced across 25 song loads, a rejected release retries, and both the
downlevel and sync-throw paths are clean. Fails 3/5 against the original
branch (the refcount leak) and 2/5 against the pre-rollback version.
This commit is contained in:
byrongamatos
2026-07-14 14:52:34 +02:00
parent a332c35c9b
commit e29312f446
2 changed files with 192 additions and 7 deletions
+18 -7
View File
@@ -4390,16 +4390,27 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {};
function aeSetMonitorMuteSuppressed(suppressed) {
const want = !!suppressed;
if (want === aeMonitorMuteSuppressionHeld) return; // idempotent, like the old bool
aeMonitorMuteSuppressionHeld = want;
const api = window.feedBackDesktop?.audio;
// Optional-chained: a downlevel native addon simply ignores this.
// Downlevel addon (no arbiter): nothing is ever acquired, so leave the
// latch alone rather than recording a hold we don't have.
if (typeof api?.setMonitorMuteSuppressed !== 'function') return;
aeMonitorMuteSuppressionHeld = want;
// The latch mirrors the NATIVE refcount, so it may only stay flipped if
// the call actually landed. A rejected release that left the latch at
// "released" would short-circuit every later release while the native
// count stayed held — the same stuck-suppression bug, one level up. Roll
// back on failure so the next call retries (and only if no newer call
// has moved the latch on in the meantime).
const rollback = () => {
if (aeMonitorMuteSuppressionHeld === want) aeMonitorMuteSuppressionHeld = !want;
};
// setMonitorMuteSuppressed is async (ipcRenderer.invoke) — the sync
// try/catch only covers a missing method, so also swallow the
// returned promise's rejection to avoid an unhandled rejection.
// try/catch only covers a throwing call, so handle the returned
// promise's rejection too (which also avoids an unhandled rejection).
try {
const r = api?.setMonitorMuteSuppressed?.(want);
if (r && typeof r.catch === 'function') r.catch(() => {});
} catch (_) { /* downlevel */ }
const r = api.setMonitorMuteSuppressed(want);
if (r && typeof r.catch === 'function') r.catch(rollback);
} catch (_) { rollback(); }
}
// Called by clearChainForNewSong (IIFE 1) and the preload below.
window._aeBeginChainRebuildGuard = function () { aeSetMonitorMuteSuppressed(true); };