fix(audio): user-stop latch - plugin keep-alives can no longer undo a user stop

Field finding (fix14 test build): nam_tone's 1.5s keep-alive watchdog
calls raw startAudio whenever the engine is not running, restarting it
seconds after the user pressed Stop - and the shim treated any raw start
as user authority, resuming all suspended demands with it.

Per plan 8.3 (user stop always wins) raw start/stop was supposed to be
device-screen-only; now enforced:
- audio:userStartAudio / audio:userStopAudio: explicit user authority.
  Stop sets a latch (+ suspends demands); start clears it (+ resumes).
- raw audio:startAudio is SUPPRESSED while latched (log-once telemetry
  'suppressed-by-user-stop') and no longer resumes demands.
- raw audio:stopAudio no longer suspends demands (it is the transient
  stop the device-apply flow and unmigrated plugins use).
- fresh capture demands born during the latch start suspended, so the
  user start resumes them like pre-existing ones; the demand glue also
  refuses to start the engine while latched.
- device screen (src/renderer/screen.js) toggle + apply flows use the
  user-authority calls.

Contract snapshots regenerated (2 IPC channels, 2 preload keys).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex 2026-07-15 01:08:52 +02:00
parent 668aff98fe
commit bb0f79e0be
7 changed files with 104 additions and 19 deletions

View File

@ -624,19 +624,37 @@ export function initAudioBridge(): void {
// ── Audio Control ──────────────────────────────────────────────────────
ipcMain.handle('audio:startAudio', (event) => {
// Raw start = user authority (device screen). Resumes any demands the
// last raw stop suspended (§8.3). Plugin callers should migrate to
// the `capture` demand (audio:leases:acquireDemand) — log-once
// telemetry tracks who still comes through here (§6.8).
// Legacy raw start (unmigrated plugins, programmatic paths). While
// the user-stop latch is set this is SUPPRESSED — found in the field:
// nam_tone's keep-alive watchdog restarted the engine 1.5 s after a
// user stop (§8.3). User authority lives on audio:userStartAudio.
leaseBridge?.noteLegacyCall(event.sender, 'audio:startAudio');
if (leaseBridge?.isUserStopLatched()) {
leaseBridge.noteLegacyCall(event.sender, 'audio:startAudio[suppressed-by-user-stop]');
return;
}
audio?.startAudio();
leaseBridge?.onUserStartAudio();
});
ipcMain.handle('audio:stopAudio', (event) => {
// Raw stop always wins: engine stops, demands suspend (not clear) so
// holders resume on the next user start (§8.3).
// Legacy raw stop: stops the engine but is NOT user authority — the
// device-apply flow and unmigrated plugins use it transiently. The
// user's stop (latch + demand suspension) is audio:userStopAudio.
leaseBridge?.noteLegacyCall(event.sender, 'audio:stopAudio');
audio?.stopAudio();
});
ipcMain.handle('audio:userStartAudio', () => {
// Device screen only: clears the user-stop latch, resumes suspended
// demands, starts the engine (§8.3).
leaseBridge?.onUserStartAudio();
audio?.startAudio();
});
ipcMain.handle('audio:userStopAudio', () => {
// Device screen only: user stop always wins — latch on, demands
// suspended, engine stopped. Legacy raw starts stay suppressed until
// the user starts again (§8.3).
leaseBridge?.onUserStopAudio();
audio?.stopAudio();
});

View File

@ -43,6 +43,10 @@ export type LeaseBridge = {
// Hooks for the legacy raw channels (§6.8 migration semantics):
onUserStartAudio(): void;
onUserStopAudio(): void;
// §8.3 user-stop latch: while set, legacy raw startAudio (nam_tone's
// keep-alive watchdog, any unmigrated caller) must be suppressed — only
// an explicit user start clears it.
isUserStopLatched(): boolean;
// true = swallow the raw disarm because a demand holder still needs
// detection armed (the 6.3 "last disarmer kills a concurrent consumer" fix).
shouldIgnoreRawDetectionDisarm(): boolean;
@ -62,9 +66,14 @@ export function initLeaseBridge(getAudio: () => AudioModule, options: { broadcas
// "that telemetry IS the migration progress dashboard").
const legacyCallsLogged = new Set<string>();
// Whether the engine is running because capture demand started it (as
// opposed to a user raw start). Demand-started engines stop when the
// demand drains; user-started engines only stop on user raw stop (§8.3).
// opposed to a user start). Demand-started engines stop when the demand
// drains; user-started engines only stop on user stop (§8.3).
let engineStartedByDemand = false;
// User-stop latch (§8.3): the device screen's explicit stop wins over
// EVERYTHING — legacy raw starts and fresh capture demands are held off
// until the user starts again. Found in the field: nam_tone's 1.5 s
// keep-alive watchdog restarted the engine right after a user stop.
let userStopLatch = false;
function sanitizeTag(tag: unknown): string | null {
if (typeof tag !== 'string' || !TAG_RE.test(tag)) return null;
@ -133,6 +142,9 @@ export function initLeaseBridge(getAudio: () => AudioModule, options: { broadcas
if (!audio) return;
if (scope === 'capture') {
try {
// A demand arriving while the user has stopped audio does not
// restart the engine — it waits for the user start (§8.3).
if (userStopLatch && active) return;
const running = typeof audio.isAudioRunning === 'function' && audio.isAudioRunning() === true;
if (active && !running) {
audio.startAudio?.();
@ -197,7 +209,13 @@ export function initLeaseBridge(getAudio: () => AudioModule, options: { broadcas
const holderId = deriveHolder(sender, tag);
trackSender(sender, holderId);
registry.tryRestore(identityKey(sender, tag), holderId);
return registry.acquireDemand(String(scope), holderId);
const ok = registry.acquireDemand(String(scope), holderId);
// A demand born during a user stop starts suspended, so the user
// start resumes it like every pre-existing demand (§8.3).
if (ok && userStopLatch && String(scope).startsWith('capture')) {
registry.suspendDemands(String(scope));
}
return ok;
},
releaseDemand(sender, scope, tag) {
@ -209,21 +227,28 @@ export function initLeaseBridge(getAudio: () => AudioModule, options: { broadcas
},
onUserStartAudio() {
// User raw start is the only thing that resumes suspended
// demands (§8.3).
// User start is the only thing that clears the stop latch and
// resumes suspended demands (§8.3).
userStopLatch = false;
engineStartedByDemand = false;
registry.resumeDemands('capture');
registry.resumeDemands('detection:');
},
onUserStopAudio() {
// User raw stop always wins: demands suspend (registration kept),
// holders learn via demand-suspended events (§8.3).
// User stop always wins: the latch holds off legacy raw starts
// and fresh demands; existing demands suspend (registration
// kept), holders learn via demand-suspended events (§8.3).
userStopLatch = true;
engineStartedByDemand = false;
registry.suspendDemands('capture');
registry.suspendDemands('detection:');
},
isUserStopLatched() {
return userStopLatch;
},
shouldIgnoreRawDetectionDisarm() {
return anyDetectionActive();
},

View File

@ -247,6 +247,12 @@ const feedBackDesktopApi = {
// Audio control
startAudio: () => ipcRenderer.invoke('audio:startAudio'),
stopAudio: () => ipcRenderer.invoke('audio:stopAudio'),
// User-authority start/stop (device screen only, §8.3): stop latches
// — legacy raw starts and fresh capture demands are held off until
// the user starts again; start clears the latch and resumes
// suspended demands.
userStartAudio: () => ipcRenderer.invoke('audio:userStartAudio'),
userStopAudio: () => ipcRenderer.invoke('audio:userStopAudio'),
isAudioRunning: () => ipcRenderer.invoke('audio:isAudioRunning'),
// Engine-owned mixer (docs/audio-ownership-plan.md §5.1). Tier 1

View File

@ -965,7 +965,8 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {};
if (ok) {
const inputChannel = parseInt(inputChannelSelect.value);
if (Number.isFinite(inputChannel)) await api.setInputChannel(inputChannel);
await api.startAudio();
// User-initiated device change: clear any user-stop latch.
await (api.userStartAudio ? api.userStartAudio() : api.startAudio());
audioRunning = true;
toggleBtn.textContent = 'Stop';
statusDot.className = 'w-3 h-3 rounded-full bg-emerald-500';
@ -1523,13 +1524,16 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {};
// Start/Stop audio
toggleBtn.addEventListener('click', async () => {
if (audioRunning) {
await api.stopAudio();
// User authority (§8.3): latches the stop so plugin keep-alive
// watchdogs (nam_tone) and fresh capture demands can't restart
// the engine until the user starts again.
await (api.userStopAudio ? api.userStopAudio() : api.stopAudio());
audioRunning = false;
toggleBtn.textContent = 'Start';
statusDot.className = 'w-3 h-3 rounded-full bg-yellow-500';
statusText.textContent = 'Audio stopped';
} else {
await api.startAudio();
await (api.userStartAudio ? api.userStartAudio() : api.startAudio());
audioRunning = true;
toggleBtn.textContent = 'Stop';
statusDot.className = 'w-3 h-3 rounded-full bg-emerald-500';
@ -1608,7 +1612,8 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {};
if (Number.isFinite(inputChannel)) await api.setInputChannel(inputChannel);
await api.setMonitorMute(monitorMuteCheckbox.checked);
await api.setMonitorKill?.(monitorKillCheckbox.checked);
await api.startAudio();
// Apply-device is a user action: clear any user-stop latch.
await (api.userStartAudio ? api.userStartAudio() : api.startAudio());
audioRunning = true;
toggleBtn.textContent = 'Stop';
statusDot.className = 'w-3 h-3 rounded-full bg-emerald-500';

View File

@ -117,5 +117,7 @@
"audio:stopAudio",
"audio:stopBacking",
"audio:unbindInputDevice",
"audio:userStartAudio",
"audio:userStopAudio",
"debug:isEnabled"
]

View File

@ -100,7 +100,9 @@
"startBacking",
"stopAudio",
"stopBacking",
"unbindInputDevice"
"unbindInputDevice",
"userStartAudio",
"userStopAudio"
],
"audioEffects": [
"activateSegment",

View File

@ -100,6 +100,33 @@ test('user stop suspends demands; user start resumes and restarts (§8.3)', () =
bridge.dispose();
});
test('user-stop latch: fresh demands and legacy starts held off until user start (§8.3)', () => {
const audio = fakeAudio();
const { bridge } = makeBridge(audio);
const tuner = fakeSender(1);
const watchdog = fakeSender(2);
bridge.onUserStopAudio();
assert.equal(bridge.isUserStopLatched(), true);
// nam_tone-style watchdog raw start would be suppressed (the bridge
// exposes the latch; the IPC handler consults it before starting).
assert.equal(bridge.isUserStopLatched(), true);
// A brand-new capture demand during the latch must NOT start the engine…
bridge.acquireDemand(tuner, 'capture', 'tuner');
assert.deepEqual(audio.calls, []);
// …but the user start resumes it like any pre-existing demand.
bridge.onUserStartAudio();
assert.equal(bridge.isUserStopLatched(), false);
assert.deepEqual(audio.calls, ['start']);
// Watchdog sender only matters for telemetry attribution; unused here.
void watchdog;
bridge.dispose();
});
test('detection demand arms native; raw disarm guarded while demand active (6.3)', () => {
const audio = fakeAudio();
const { bridge } = makeBridge(audio);