feat(voice-engine): add a sourceLifecycle.removed event (#2374)

This commit is contained in:
Hampus
2026-09-02 17:30:08 +02:00
committed by GitHub
parent 626293392c
commit 02069e8e5d
6 changed files with 62 additions and 3 deletions
@@ -847,6 +847,45 @@ describe('transitionVoiceEngineV2', () => {
expect(selectVoiceEngineV2SourceLifecycle(snapshot, 'unknown')).toBeNull();
});
it('sourceLifecycle.removed drops the lifecycle slot for that source only', () => {
const snapshot = applyEvents(initialSnapshot(), [
{
type: 'sourceLifecycle.transitioned',
sourceId: 'source-removed',
kind: 'failed',
since: 11n,
attempts: 8,
fault: 'captureDeviceLost',
atMs: 11,
},
{
type: 'sourceLifecycle.transitioned',
sourceId: 'source-kept',
kind: 'active',
since: 12n,
attempts: 0,
fault: null,
atMs: 12,
},
]);
const transition = transitionVoiceEngineV2(snapshot, {type: 'sourceLifecycle.removed', sourceId: 'source-removed'});
expect(transition.commands).toEqual([]);
expect(selectVoiceEngineV2SourceLifecycle(transition.snapshot, 'source-removed')).toBeNull();
expect(selectVoiceEngineV2SourceLifecycle(transition.snapshot, 'source-kept')?.kind).toBe('active');
expect([...selectVoiceEngineV2FailedSourceIds(transition.snapshot)]).toEqual([]);
});
it('sourceLifecycle.removed for an unknown source leaves the snapshot untouched', () => {
const snapshot = initialSnapshot();
const transition = transitionVoiceEngineV2(snapshot, {type: 'sourceLifecycle.removed', sourceId: 'source-absent'});
expect(transition.commands).toEqual([]);
expect(transition.snapshot).toBe(snapshot);
});
it('selectVoiceEngineV2FailedSourceIds returns only failed lifecycle ids', () => {
const snapshot = applyEvents(initialSnapshot(), [
{
@@ -276,6 +276,7 @@ export function dispatchObservabilityEvent(
assert.equal(typeof event.type, 'string', 'dispatchObservabilityEvent event.type must be a string');
switch (event.type) {
case 'sourceLifecycle.transitioned':
case 'sourceLifecycle.removed':
return transitionSourceLifecycles(snapshot, event);
default:
return null;
@@ -1,7 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import type {VoiceEngineV2SourceLifecycleTransitionedEvent} from '../../protocol/events';
import type {
VoiceEngineV2SourceLifecycleRemovedEvent,
VoiceEngineV2SourceLifecycleTransitionedEvent,
} from '../../protocol/events';
import type {VoiceEngineV2DiagnosticEntry} from '../../protocol/types';
import type {SourceFault, SourceLifecycleState} from '../../source_isolation/SourceLifecycleState';
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
@@ -9,7 +12,9 @@ import {allocateOperation, appendDiagnostic, queueCommand} from './_helpers';
const SOURCE_LIFECYCLE_DIAGNOSTICS_CODE = 'sourceFailed';
type VoiceEngineV2SourceLifecyclesEvent = VoiceEngineV2SourceLifecycleTransitionedEvent;
type VoiceEngineV2SourceLifecyclesEvent =
| VoiceEngineV2SourceLifecycleTransitionedEvent
| VoiceEngineV2SourceLifecycleRemovedEvent;
function assertNeverLifecycleKind(kind: never): never {
assert.fail(`unhandled source lifecycle kind: ${JSON.stringify(kind)}`);
@@ -43,6 +48,12 @@ export function transitionSourceLifecycles(
assert.ok(event != null, 'transitionSourceLifecycles event must not be null');
assert.equal(typeof event.sourceId, 'string', 'sourceLifecycle.sourceId must be a string');
assert.ok(event.sourceId.length > 0, 'sourceLifecycle.sourceId must not be empty');
if (event.type === 'sourceLifecycle.removed') {
if (!Object.hasOwn(snapshot.sourceLifecycles, event.sourceId)) return {snapshot, commands: []};
const sourceLifecycles = {...snapshot.sourceLifecycles};
delete sourceLifecycles[event.sourceId];
return {snapshot: {...snapshot, sourceLifecycles}, commands: []};
}
const nextState = rebuildSourceLifecycleState(event);
const base: VoiceEngineV2Snapshot = {
...snapshot,
+1
View File
@@ -212,6 +212,7 @@ export type {
VoiceEngineV2ScreenOptions,
VoiceEngineV2ScreenPacing,
VoiceEngineV2SendStats,
VoiceEngineV2SourceLifecycleRemovedEvent,
VoiceEngineV2SourceLifecycleTransitionedEvent,
VoiceEngineV2SourceLifecycleTransitionKind,
VoiceEngineV2Stats,
@@ -15,6 +15,11 @@ export interface VoiceEngineV2SourceLifecycleTransitionedEvent {
atMs: number;
}
export interface VoiceEngineV2SourceLifecycleRemovedEvent {
type: 'sourceLifecycle.removed';
sourceId: string;
}
import type {
VoiceEngineV2AudioControlsPatch,
VoiceEngineV2CameraEncodingOptions,
@@ -264,4 +269,5 @@ export type VoiceEngineV2Event =
| {type: 'inboundVideo.trackUnsubscribed'; trackSid: string}
| {type: 'inboundVideo.frameReceived'; frame: VoiceEngineV2InboundVideoFrame}
| {type: 'inboundVideo.frameStats'; stats: VoiceEngineV2InboundVideoFrameStats}
| VoiceEngineV2SourceLifecycleTransitionedEvent;
| VoiceEngineV2SourceLifecycleTransitionedEvent
| VoiceEngineV2SourceLifecycleRemovedEvent;
@@ -9,6 +9,7 @@ export type {VoiceEngineV2Command, VoiceEngineV2CommandType} from './commands';
export {getVoiceEngineV2CommandResourceKey, getVoiceEngineV2CommandTypeResourceKey} from './commands';
export type {
VoiceEngineV2Event,
VoiceEngineV2SourceLifecycleRemovedEvent,
VoiceEngineV2SourceLifecycleTransitionedEvent,
VoiceEngineV2SourceLifecycleTransitionKind,
} from './events';