feedBack-desktop/tests/monitor-mute-suppression.test.js
byrongamatos e29312f446 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.
2026-07-14 14:52:34 +02:00

175 lines
7.1 KiB
JavaScript

// PR #107 review: the native monitor-mute arbiter REFCOUNTS suppressions
// (SourceChain::setMonitorMuteSuppressed — true = acquire, false = release),
// but it kept the old boolean signature. The renderer's rebuild guard is
// deliberately unpaired: resolveChainRebuildGuard() leaves the suppression on
// when a rebuild produced an empty chain, and returns early without releasing
// while a provider route is still resolving. Against the old LATCHED BOOL that
// was self-correcting (repeated trues were idempotent, any false reset it);
// against a refcount every unpaired call is a permanent +1, so after a couple
// of song loads the count can never return to zero and monitor mute is silently
// dead for the rest of the session.
//
// aeSetMonitorMuteSuppressed() therefore holds AT MOST ONE native suppression.
// These cases pin that, plus the rollback: the latch mirrors the native
// refcount, so it may only stay flipped if the IPC actually landed.
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const vm = require('node:vm');
const ROOT = path.join(__dirname, '..');
const SCREEN_JS = fs.readFileSync(path.join(ROOT, 'src', 'renderer', 'screen.js'), 'utf8');
function extractFunction(src, name) {
const sig = `function ${name}(`;
const start = src.indexOf(sig);
assert.ok(start !== -1, `function '${name}' not found`);
let i = src.indexOf('{', src.indexOf(')', start));
let depth = 1;
i++;
while (i < src.length && depth > 0) {
if (src[i] === '{') depth++;
else if (src[i] === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces in '${name}'`);
return src.slice(start, i);
}
// Build a sandbox with the real function plus a fake native api that records
// every acquire/release and can be made to fail. `calls` is the ground truth
// for what the native refcount would have done.
function makeHarness({ mode = 'ok' } = {}) {
const calls = [];
const setMonitorMuteSuppressed = (suppressed) => {
if (mode === 'throw') { calls.push({ suppressed, outcome: 'threw' }); throw new Error('sync boom'); }
if (mode === 'reject') {
calls.push({ suppressed, outcome: 'rejected' });
return Promise.reject(new Error('ipc boom'));
}
calls.push({ suppressed, outcome: 'ok' });
return Promise.resolve();
};
const audio = mode === 'downlevel' ? {} : { setMonitorMuteSuppressed };
const ctx = {
window: { feedBackDesktop: { audio } },
// The native refcount, simulated: clamped at 0 exactly like SourceChain's
// compare_exchange loop, so an unpaired release can't underflow.
nativeCount: 0,
};
vm.createContext(ctx);
vm.runInContext(
'let aeMonitorMuteSuppressionHeld = false;\n'
+ extractFunction(SCREEN_JS, 'aeSetMonitorMuteSuppressed')
+ '\nglobalThis.__call = aeSetMonitorMuteSuppressed;'
+ '\nglobalThis.__held = () => aeMonitorMuteSuppressionHeld;',
ctx,
);
return {
calls,
set: (v) => ctx.__call(v),
held: () => ctx.__held(),
// Replay the recorded calls through the native refcount semantics.
nativeCount: () => calls.reduce((n, c) => {
if (c.outcome !== 'ok') return n; // never reached the engine
return c.suppressed ? n + 1 : Math.max(0, n - 1);
}, 0),
};
}
const flush = () => new Promise((r) => setImmediate(r));
test('repeated unpaired acquires hold at most ONE native suppression', async () => {
const h = makeHarness();
// Three song loads whose guard never releases (empty-chain / provider-pending
// branches). Under a raw refcount this would be +3 and never recoverable.
h.set(true); h.set(true); h.set(true);
await flush();
assert.equal(h.calls.filter((c) => c.suppressed).length, 1, 'only one acquire may reach the engine');
assert.equal(h.nativeCount(), 1);
// ...and one release still returns the count to zero, so monitor mute works.
h.set(false);
await flush();
assert.equal(h.nativeCount(), 0, 'a single release must fully un-suppress');
assert.equal(h.held(), false);
});
test('acquire/release cycles stay balanced across many song loads', async () => {
const h = makeHarness();
for (let i = 0; i < 25; i++) {
h.set(true); // clearChainForNewSong + preload both call the guard
h.set(true);
await flush();
h.set(false); // resolveChainRebuildGuard
await flush();
}
assert.equal(h.nativeCount(), 0, 'refcount must not drift across sessions');
assert.equal(h.held(), false);
});
test('a rejected release rolls the latch back so the next release retries', async () => {
// The bug this guards: if the latch flipped to "released" on an IPC that
// never landed, every later release would short-circuit while the native
// count stayed held — stuck suppression, one level up from the C++ leak.
const calls = [];
let failNext = false;
const ctx = {
window: {
feedBackDesktop: {
audio: {
setMonitorMuteSuppressed: (s) => {
if (failNext) { calls.push({ suppressed: s, outcome: 'rejected' }); return Promise.reject(new Error('boom')); }
calls.push({ suppressed: s, outcome: 'ok' });
return Promise.resolve();
},
},
},
},
};
vm.createContext(ctx);
vm.runInContext(
'let aeMonitorMuteSuppressionHeld = false;\n'
+ extractFunction(SCREEN_JS, 'aeSetMonitorMuteSuppressed')
+ '\nglobalThis.__call = aeSetMonitorMuteSuppressed;'
+ '\nglobalThis.__held = () => aeMonitorMuteSuppressionHeld;',
ctx,
);
ctx.__call(true); // acquire lands: native = 1
await flush();
assert.equal(ctx.__held(), true);
failNext = true;
ctx.__call(false); // release REJECTS: native still 1
await flush();
assert.equal(ctx.__held(), true, 'a failed release must not leave the latch "released"');
failNext = false;
ctx.__call(false); // retry must actually be attempted
await flush();
const releases = calls.filter((c) => !c.suppressed);
assert.equal(releases.length, 2, 'the retry must reach the engine, not short-circuit');
assert.equal(releases.at(-1).outcome, 'ok');
assert.equal(ctx.__held(), false);
});
test('a downlevel addon without the arbiter is a clean no-op', async () => {
const h = makeHarness({ mode: 'downlevel' });
assert.doesNotThrow(() => { h.set(true); h.set(false); });
await flush();
assert.equal(h.calls.length, 0);
assert.equal(h.held(), false, 'nothing was acquired, so nothing may be recorded as held');
});
test('a synchronously throwing bridge rolls the latch back', async () => {
const h = makeHarness({ mode: 'throw' });
assert.doesNotThrow(() => h.set(true));
await flush();
assert.equal(h.held(), false, 'a throw means nothing was acquired');
});