diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge.test.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge.test.ts index 1daf4397e..72c20e1e6 100644 --- a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge.test.ts +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge.test.ts @@ -226,6 +226,71 @@ describe('VoiceEngineV2AppSourceLifecycleBridge', () => { bridge.dispose(); }); + it('unbind dispatches sourceLifecycle.removed so the reducer drops the snapshot slot', () => { + const binding = createMockNativeBinding(); + const dispatched: Array = []; + const bridge = new VoiceEngineV2AppSourceLifecycleBridge({ + dispatch: (event) => dispatched.push(event), + subscribe: binding.subscribe, + }); + + bridge.bind({captureId: 'cap-removed', sourceId: 'src-removed'}); + dispatched.length = 0; + + expect(bridge.unbind('cap-removed')).toBe(true); + + expect(dispatched).toHaveLength(1); + expect(dispatched[0]?.type).toBe('sourceLifecycle.removed'); + if (dispatched[0]?.type === 'sourceLifecycle.removed') { + expect(dispatched[0].sourceId).toBe('src-removed'); + } + expect(bridge.unbind('cap-removed')).toBe(false); + bridge.dispose(); + }); + + it('dispose dispatches sourceLifecycle.removed for every remaining binding', () => { + const binding = createMockNativeBinding(); + const dispatched: Array = []; + const bridge = new VoiceEngineV2AppSourceLifecycleBridge({ + dispatch: (event) => dispatched.push(event), + subscribe: binding.subscribe, + }); + + bridge.bind({captureId: 'cap-1', sourceId: 'src-1'}); + bridge.bind({captureId: 'cap-2', sourceId: 'src-2'}); + dispatched.length = 0; + + bridge.dispose(); + + expect(dispatched.map((event) => event.type)).toEqual(['sourceLifecycle.removed', 'sourceLifecycle.removed']); + expect(dispatched.flatMap((event) => (event.type === 'sourceLifecycle.removed' ? [event.sourceId] : []))).toEqual([ + 'src-1', + 'src-2', + ]); + }); + + it('dispose is re-entrant safe and does not re-dispatch removals', () => { + const binding = createMockNativeBinding(); + const dispatched: Array = []; + let reentrant: VoiceEngineV2AppSourceLifecycleBridge | null = null; + const bridge = new VoiceEngineV2AppSourceLifecycleBridge({ + dispatch: (event) => { + dispatched.push(event); + if (event.type === 'sourceLifecycle.removed') reentrant?.dispose(); + }, + subscribe: binding.subscribe, + }); + + bridge.bind({captureId: 'cap-reentrant', sourceId: 'src-reentrant'}); + dispatched.length = 0; + reentrant = bridge; + + bridge.dispose(); + + expect(dispatched.map((event) => event.type)).toEqual(['sourceLifecycle.removed']); + expect(binding.listenerCount()).toBe(0); + }); + it('dispose unsubscribes from the native binding', () => { const binding = createMockNativeBinding(); const bridge = new VoiceEngineV2AppSourceLifecycleBridge({ diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge.ts index 5650e28e9..35a52459d 100644 --- a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge.ts +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge.ts @@ -172,12 +172,17 @@ export class VoiceEngineV2AppSourceLifecycleBridge { unbind(captureId: string): boolean { assert.ok(!this.disposed, 'bridge must not be disposed'); assertValidString('captureId', captureId, MAX_SOURCE_ID_LENGTH); + return this.removeBinding(captureId); + } + + private removeBinding(captureId: string): boolean { const binding = this.bindingsByCapture.get(captureId); if (!binding) return false; this.bindingsByCapture.delete(captureId); this.closedCleanLastAtMs.delete(captureId); const removed = this.registry.remove(binding.sourceId); assert.equal(removed, true, 'unbind must remove the registry entry'); + this.dispatch({type: 'sourceLifecycle.removed', sourceId: binding.sourceId}); return true; } @@ -238,15 +243,15 @@ export class VoiceEngineV2AppSourceLifecycleBridge { } dispose(): void { - const wasDisposed = this.disposed; - if (wasDisposed) return; + if (this.disposed) return; this.disposed = true; + for (const captureId of [...this.bindingsByCapture.keys()]) { + this.removeBinding(captureId); + } try { this.unsubscribe(); } catch {} - this.bindingsByCapture.clear(); this.closedCleanLastAtMs.clear(); - assert.equal(this.disposed, true, 'disposed flag must be true after dispose'); assert.equal(this.bindingsByCapture.size, 0, 'bindings must be cleared after dispose'); }