From 275af63b61d31fdc8132128d9e7368d1fc50a2dc Mon Sep 17 00:00:00 2001 From: "Neevash Ramdial (Nash)" Date: Thu, 27 Aug 2026 17:47:41 -0400 Subject: [PATCH 1/7] feat: add server-side RTC APIs Expose call lifecycle, tracks, subscriptions, events, stats, and typed native errors through the Node SDK. --- index.ts | 59 +++++++ src/StreamCall.ts | 302 ++++++++++++++++++++++++++++++++ src/StreamClient.ts | 6 + src/rtc/callLifecycle.ts | 264 ++++++++++++++++++++++++++++ src/rtc/clientCredentials.ts | 24 +++ src/rtc/contracts.ts | 125 +++++++++++++ src/rtc/errors.ts | 215 +++++++++++++++++++++++ src/rtc/events.ts | 68 ++++++++ src/rtc/native.ts | 327 +++++++++++++++++++++++++++++++++++ src/rtc/state.ts | 107 ++++++++++++ src/rtc/tracks.ts | 254 +++++++++++++++++++++++++++ src/rtc/types.ts | 196 +++++++++++++++++++++ 12 files changed, 1947 insertions(+) create mode 100644 src/rtc/callLifecycle.ts create mode 100644 src/rtc/clientCredentials.ts create mode 100644 src/rtc/contracts.ts create mode 100644 src/rtc/errors.ts create mode 100644 src/rtc/events.ts create mode 100644 src/rtc/native.ts create mode 100644 src/rtc/state.ts create mode 100644 src/rtc/tracks.ts create mode 100644 src/rtc/types.ts diff --git a/index.ts b/index.ts index 71c9a50e..2575cf66 100644 --- a/index.ts +++ b/index.ts @@ -10,3 +10,62 @@ export { InvalidWebhookError, InvalidWebhookErrorMessages, } from './src/utils/webhook'; + +// Server-side RTC. Importing these never loads the native addon; the addon is +// resolved lazily on the first track construction or call join. +export { + LocalAudioTrack, + LocalVideoTrack, + RemoteTrack, +} from './src/rtc/tracks'; +export type { + EncodedAudioWriteOptions, + EncodedVideoWriteOptions, + PcmWriteOptions, + RtpAudioWriteOptions, + VideoFrameWriteOptions, +} from './src/rtc/tracks'; +export { + RtcClosedError, + RtcConnectionError, + RtcError, + RtcIllegalStateError, + RtcJoinError, + RtcMediaError, + RtcNativeUnavailableError, + RtcNativeVersionMismatchError, + RtcNegotiationError, + RtcPermissionDeniedError, + RtcQueueOverflowError, + RtcSizeLimitError, + RtcTimeoutError, + RtcUnsupportedLayeringError, + RtcUnsupportedPlatformError, +} from './src/rtc/errors'; +export type { RtcErrorCode, RtcErrorDetails } from './src/rtc/errors'; +export { StreamCallState } from './src/rtc/state'; +export type { + JoinCallOptions, + PcmFrame, + RtcCallEvent, + RtcCallEventHandler, + RtcCallEventMap, + RtcCallEventName, + RtcCallStateSnapshot, + RtcCallingStateChangedEvent, + RtcCallingState, + RtcErrorEvent, + RtcQueueOverflowEvent, + RtcParticipant, + RtcStats, + RtcTrackUnpublishedEvent, + RtcTrackType, + RtcVideoCodec, + RtpExtension, + RtpPacket, + SubscriptionConfig, + SubscriptionTarget, + VideoDimension, + VideoFrame, + VideoTrackOptions, +} from './src/rtc/types'; diff --git a/src/StreamCall.ts b/src/StreamCall.ts index f09bd4bb..75e5f658 100644 --- a/src/StreamCall.ts +++ b/src/StreamCall.ts @@ -7,9 +7,55 @@ import { import { CallApi } from './gen/video/CallApi'; import { StreamClient } from './StreamClient'; import { OmitTypeId } from './types'; +import { toRtcError } from './rtc/errors'; +import { RtcCallLifecycle } from './rtc/callLifecycle'; +import { parseRtcStats } from './rtc/contracts'; +import { RtcEventDispatcher } from './rtc/events'; +import { nativeRtcCall, type NativeCall } from './rtc/native'; +import { StreamCallState } from './rtc/state'; +import { + LocalAudioTrack, + LocalVideoTrack, + nativeAudioTrack, + nativeVideoTrack, +} from './rtc/tracks'; +import type { + JoinCallOptions, + RtcCallEvent, + RtcCallEventHandler, + RtcCallEventName, + RtcStats, + RtcTrackType, + RtcVideoCodec, + SubscriptionConfig, + SubscriptionTarget, + VideoDimension, +} from './rtc/types'; + +/** + * The SFU forwards no video unless the subscription carries a dimension hint, + * so fill one in rather than leaving the caller with a silently black call. + * Matches the Rust core's own audio+video convenience defaults. + */ +const DEFAULT_INCOMING_VIDEO_DIMENSION: VideoDimension = { + width: 1280, + height: 720, +}; + +const withVideoDimension = (config: SubscriptionConfig): SubscriptionConfig => + (config.video || config.screenShare) && !config.videoDimension + ? { ...config, videoDimension: DEFAULT_INCOMING_VIDEO_DIMENSION } + : { ...config }; export class StreamCall extends CallApi { data?: CallResponse; + readonly state = new StreamCallState(); + + private readonly rtcEvents = new RtcEventDispatcher(); + private readonly rtcLifecycle: RtcCallLifecycle; + private disconnectionTimeoutSeconds?: number; + private preferredVideoCodec?: RtcVideoCodec; + private subscriptionConfig: SubscriptionConfig = { audio: true }; constructor( videoApi: VideoApi, @@ -18,12 +64,253 @@ export class StreamCall extends CallApi { private readonly streamClient: StreamClient, ) { super(videoApi, type, id); + this.rtcLifecycle = new RtcCallLifecycle({ + state: this.state, + createNativeCall: () => + nativeRtcCall(this.streamClient, this.type, this.id), + prepareNativeCall: (native, options) => { + const codec = options.preferredVideoCodec ?? this.preferredVideoCodec; + if (codec) native.updatePublishOptions(codec); + if (this.disconnectionTimeoutSeconds !== undefined) { + native.setDisconnectionTimeout(this.disconnectionTimeoutSeconds); + } + }, + configureJoinedCall: (native) => + native.updateSubscriptions(JSON.stringify(this.subscriptionConfig)), + emit: (event, payload) => this.rtcEvents.dispatch(event, payload), + }); } get cid() { return `${this.type}:${this.id}`; } + join = (options: JoinCallOptions): Promise => + this.rtcLifecycle.join(options); + + leave = (): Promise => this.rtcLifecycle.leave(); + + on( + event: E, + handler: RtcCallEventHandler, + ): () => void; + on(event: string, handler: (event: RtcCallEvent) => void): () => void; + on(event: string, handler: (event: never) => void) { + return this.rtcEvents.on(event, handler as (event: unknown) => void); + } + + off( + event: E, + handler: RtcCallEventHandler, + ): void; + off(event: string, handler: (event: RtcCallEvent) => void): void; + off(event: string, handler: (event: never) => void) { + this.rtcEvents.off(event, handler as (event: unknown) => void); + } + + requestPermissions = async (data: { permissions: string[] }) => { + try { + return JSON.parse( + await this.requireNativeCall('requestPermissions').requestPermissions( + data.permissions, + ), + ) as unknown; + } catch (error) { + throw toRtcError(error); + } + }; + + grantPermissions = (userId: string, permissions: string[]) => + this.updateUserPermissions({ + user_id: userId, + grant_permissions: permissions, + revoke_permissions: [], + }); + + revokePermissions = (userId: string, permissions: string[]) => + this.updateUserPermissions({ + user_id: userId, + grant_permissions: [], + revoke_permissions: permissions, + }); + + setDisconnectionTimeout = (timeoutSeconds: number) => { + if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) { + throw new RangeError( + 'timeoutSeconds must be a finite, non-negative number', + ); + } + this.disconnectionTimeoutSeconds = timeoutSeconds; + try { + this.rtcLifecycle.currentNativeCall?.setDisconnectionTimeout( + timeoutSeconds, + ); + } catch (error) { + throw toRtcError(error); + } + }; + + updatePublishOptions = (options: { preferredVideoCodec?: RtcVideoCodec }) => { + this.preferredVideoCodec = options.preferredVideoCodec; + try { + this.rtcLifecycle.currentNativeCall?.updatePublishOptions( + options.preferredVideoCodec, + ); + } catch (error) { + throw toRtcError(error); + } + }; + + updateSubscriptions = async (config: SubscriptionConfig) => { + this.subscriptionConfig = withVideoDimension(config); + try { + await this.requireNativeCall('updateSubscriptions').updateSubscriptions( + JSON.stringify(this.subscriptionConfig), + ); + } catch (error) { + throw toRtcError(error); + } + }; + + updateSubscriptionTargets = async (targets: SubscriptionTarget[]) => { + try { + await this.requireNativeCall( + 'updateSubscriptionTargets', + ).updateSubscriptionTargets(JSON.stringify(targets)); + } catch (error) { + throw toRtcError(error); + } + }; + + setIncomingVideoEnabled = async (enabled: boolean) => { + this.subscriptionConfig = withVideoDimension({ + ...this.subscriptionConfig, + video: enabled, + videoDimension: enabled + ? this.subscriptionConfig.videoDimension + : undefined, + }); + const native = this.requireNativeCall('setIncomingVideoEnabled'); + try { + // Flips the video flag and drops any per-participant targets. + await native.setIncomingVideoEnabled(enabled); + if (enabled) { + // That toggle deliberately clears the dimension hint, and the SFU + // forwards no video without one, so restate the policy. + await native.updateSubscriptions( + JSON.stringify(this.subscriptionConfig), + ); + } + } catch (error) { + throw toRtcError(error); + } + }; + + setPreferredIncomingVideoResolution = async ( + resolution?: VideoDimension, + sessionIds?: string[], + ) => { + if (sessionIds?.length) { + await this.updateSubscriptionTargets( + sessionIds.map((sessionId) => ({ + sessionId, + trackType: 'video', + dimension: resolution, + })), + ); + return; + } + + await this.updateSubscriptions({ + ...this.subscriptionConfig, + video: resolution ? true : this.subscriptionConfig.video, + videoDimension: resolution, + }); + }; + + publishAudio = async (track: LocalAudioTrack) => { + try { + await this.requireNativeCall('publishAudio').publishAudio( + nativeAudioTrack(track), + ); + } catch (error) { + throw toRtcError(error); + } + }; + + publishVideo = async (track: LocalVideoTrack) => { + try { + await this.requireNativeCall('publishVideo').publishVideo( + nativeVideoTrack(track), + ); + } catch (error) { + throw toRtcError(error); + } + }; + + publishScreenShare = async (track: LocalVideoTrack) => { + try { + await this.requireNativeCall('publishScreenShare').publishScreenShare( + nativeVideoTrack(track), + ); + } catch (error) { + throw toRtcError(error); + } + }; + + publishScreenShareAudio = async (track: LocalAudioTrack) => { + try { + await this.requireNativeCall( + 'publishScreenShareAudio', + ).publishScreenShareAudio(nativeAudioTrack(track)); + } catch (error) { + throw toRtcError(error); + } + }; + + stopPublish = async ( + track: LocalAudioTrack | LocalVideoTrack, + trackType: RtcTrackType = track instanceof LocalAudioTrack + ? 'audio' + : 'video', + ) => { + try { + const native = this.requireNativeCall('stopPublish'); + if (track instanceof LocalAudioTrack) { + await native.stopPublishAudio(nativeAudioTrack(track), trackType); + } else { + await native.stopPublishVideo(nativeVideoTrack(track), trackType); + } + } catch (error) { + throw toRtcError(error); + } + }; + + muteTrack = (trackType: RtcTrackType) => + this.runNative('muteTrack', (native) => native.muteTrack(trackType)); + + unmuteTrack = (trackType: RtcTrackType) => + this.runNative('unmuteTrack', (native) => native.unmuteTrack(trackType)); + + startNoiseCancellation = () => + this.runNative('startNoiseCancellation', (native) => + native.startNoiseCancellation(), + ); + + stopNoiseCancellation = () => + this.runNative('stopNoiseCancellation', (native) => + native.stopNoiseCancellation(), + ); + + getStats = async (): Promise => { + try { + const json = await this.requireNativeCall('getStats').statsJson(); + return json ? parseRtcStats(json) : undefined; + } catch (error) { + throw toRtcError(error); + } + }; + create = (request?: GetOrCreateCallRequest) => this.getOrCreate(request); queryMembers = (request?: OmitTypeId) => { @@ -71,4 +358,19 @@ export class StreamCall extends CallApi { .replace('{token}', token), }; }; + + private requireNativeCall = (operation: string) => { + return this.rtcLifecycle.requireNativeCall(operation); + }; + + private runNative = async ( + operation: string, + run: (native: NativeCall) => Promise, + ) => { + try { + await run(this.requireNativeCall(operation)); + } catch (error) { + throw toRtcError(error); + } + }; } diff --git a/src/StreamClient.ts b/src/StreamClient.ts index 1cd8efba..1ff84424 100644 --- a/src/StreamClient.ts +++ b/src/StreamClient.ts @@ -20,6 +20,7 @@ import { StreamModerationClient } from './StreamModerationClient'; import { ApiClient } from './ApiClient'; import { StreamFeedsClient } from './StreamFeedsClient'; import { File } from 'buffer'; +import { registerRtcClientCredentials } from './rtc/clientCredentials'; export interface StreamClientOptions { timeout?: number; @@ -84,6 +85,11 @@ export class StreamClient extends CommonApi { streamClient: this, apiClient: videoApiClient, }); + registerRtcClientCredentials(this, { + apiKey, + apiSecret: secret, + baseUrl: videoBaseUrl, + }); this.chat = new StreamChatClient(this.apiClient); this.moderation = new StreamModerationClient(chatApiClient); this.feeds = new StreamFeedsClient(feedsApiClient); diff --git a/src/rtc/callLifecycle.ts b/src/rtc/callLifecycle.ts new file mode 100644 index 00000000..a52b203b --- /dev/null +++ b/src/rtc/callLifecycle.ts @@ -0,0 +1,264 @@ +import { + RtcClosedError, + RtcError, + RtcIllegalStateError, + toRtcError, +} from './errors'; +import { parseRtcCallEvent, parseRtcCallState } from './contracts'; +import type { NativeCall } from './native'; +import { StreamCallState } from './state'; +import { RemoteTrack } from './tracks'; +import type { + JoinCallOptions, + RtcCallingState, + RtcErrorEvent, + RtcTrackUnpublishedEvent, +} from './types'; + +export interface RtcCallLifecycleOptions { + state: StreamCallState; + createNativeCall: () => NativeCall; + prepareNativeCall: (native: NativeCall, options: JoinCallOptions) => void; + configureJoinedCall: (native: NativeCall) => Promise; + emit: (event: string, payload: unknown) => void; +} + +export class RtcCallLifecycle { + private nativeCall?: NativeCall; + private joinPromise?: Promise; + private leavePromise?: Promise; + private generation = 0; + private readonly teardowns = new WeakMap>(); + + constructor(private readonly options: RtcCallLifecycleOptions) {} + + get currentNativeCall() { + return this.nativeCall; + } + + join = (options: JoinCallOptions): Promise => { + if (!options.userId) { + return Promise.reject( + new RtcIllegalStateError('join requires a non-empty userId'), + ); + } + if (this.joinPromise || this.leavePromise || this.nativeCall) { + return Promise.reject( + new RtcIllegalStateError( + `Cannot join while the call is ${this.options.state.callingState}`, + ), + ); + } + + const generation = ++this.generation; + const pending = this.runJoin(generation, options); + const tracked = pending.finally(() => { + if (this.joinPromise === tracked) this.joinPromise = undefined; + }); + this.joinPromise = tracked; + return tracked; + }; + + leave = (): Promise => { + this.generation += 1; + if (this.leavePromise) return this.leavePromise; + + const native = this.nativeCall; + const pendingJoin = this.joinPromise; + this.nativeCall = undefined; + + const pending = this.runLeave(native, pendingJoin); + const tracked = pending.finally(() => { + if (this.leavePromise === tracked) this.leavePromise = undefined; + }); + this.leavePromise = tracked; + return tracked; + }; + + requireNativeCall = (operation: string) => { + if (!this.nativeCall) { + throw new RtcIllegalStateError( + `${operation} requires an active or joining call`, + ); + } + return this.nativeCall; + }; + + private runJoin = async (generation: number, options: JoinCallOptions) => { + let native: NativeCall | undefined; + try { + native = this.options.createNativeCall(); + this.nativeCall = native; + this.options.prepareNativeCall(native, options); + this.options.state.clearRemoteTracks(); + this.setCallingState('joining'); + this.assertCurrent(native, generation); + + this.startEventPump(native, generation); + this.startRemoteTrackPump(native, generation); + await native.join(JSON.stringify(options)); + this.assertCurrent(native, generation); + + if (!(await this.refreshState(native, generation))) { + throw this.supersededJoinError(); + } + await this.options.configureJoinedCall(native); + this.assertCurrent(native, generation); + } catch (error) { + let cleanupError: unknown; + if (native) { + try { + await this.teardown(native); + } catch (caught) { + cleanupError = caught; + } + } + + if (this.isCurrent(native, generation)) { + this.nativeCall = undefined; + this.options.state.clearRemoteTracks(); + this.setCallingState('idle'); + } + + const joinError = toRtcError(error); + if (!cleanupError) throw joinError; + + const cleanup = toRtcError(cleanupError); + throw new RtcError( + `${joinError.message}; native cleanup failed: ${cleanup.message}`, + joinError.code, + { + ...joinError.details, + cleanupError: { code: cleanup.code, message: cleanup.message }, + }, + { cause: new AggregateError([joinError, cleanup]) }, + ); + } + }; + + private runLeave = async ( + native: NativeCall | undefined, + pendingJoin: Promise | undefined, + ) => { + let leaveError: unknown; + if (native) { + try { + await this.teardown(native); + } catch (error) { + leaveError = error; + } + } + if (!leaveError && pendingJoin) { + await pendingJoin.catch(() => undefined); + } + + this.options.state.clearRemoteTracks(); + this.setCallingState('left'); + if (leaveError) throw toRtcError(leaveError); + }; + + private teardown = (native: NativeCall) => { + let teardown = this.teardowns.get(native); + if (!teardown) { + try { + teardown = Promise.resolve(native.leave()); + } catch (error) { + teardown = Promise.reject(toRtcError(error)); + } + this.teardowns.set(native, teardown); + } + return teardown; + }; + + private startEventPump = (native: NativeCall, generation: number) => { + void (async () => { + while (this.isCurrent(native, generation)) { + let json: string | undefined | null; + try { + json = await native.nextEvent(); + } catch (error) { + if (this.isCurrent(native, generation)) this.emitError(error); + return; + } + if (!json || !this.isCurrent(native, generation)) return; + + try { + const event = parseRtcCallEvent(json); + if (!(await this.refreshState(native, generation))) return; + if (event.type === 'trackUnpublished') { + const unpublished = event as RtcTrackUnpublishedEvent; + this.options.state.removeRemoteTrack( + unpublished.sessionId, + unpublished.trackType, + ); + } + this.options.emit(event.type, event); + if ( + event.type === 'callEnded' || + (event.type === 'callingStateChanged' && + event.callingState === 'left') + ) { + return; + } + } catch (error) { + if (!this.isCurrent(native, generation)) return; + this.emitError(error); + } + } + })(); + }; + + private startRemoteTrackPump = (native: NativeCall, generation: number) => { + void (async () => { + try { + while (this.isCurrent(native, generation)) { + const handle = await native.nextRemoteTrack(); + if (!handle || !this.isCurrent(native, generation)) return; + const track = new RemoteTrack(handle); + this.options.state.addRemoteTrack(track); + this.options.emit('remoteTrack', track); + } + } catch (error) { + if (this.isCurrent(native, generation)) this.emitError(error); + } + })(); + }; + + private refreshState = async (native: NativeCall, generation: number) => { + const json = await native.stateJson(); + if (!this.isCurrent(native, generation)) return false; + const snapshot = parseRtcCallState(json); + if (!this.isCurrent(native, generation)) return false; + this.options.state.update(snapshot); + return true; + }; + + private isCurrent = (native: NativeCall | undefined, generation: number) => + this.generation === generation && this.nativeCall === native; + + private assertCurrent = (native: NativeCall, generation: number) => { + if (!this.isCurrent(native, generation)) { + throw this.supersededJoinError(); + } + }; + + private supersededJoinError = () => + new RtcClosedError('Join was superseded by leave or a newer generation'); + + private setCallingState = (callingState: RtcCallingState) => { + if (this.options.state.callingState === callingState) return; + this.options.state.setCallingState(callingState); + this.options.emit('callingStateChanged', { + type: 'callingStateChanged', + callingState, + }); + }; + + private emitError = (error: unknown) => { + const event: RtcErrorEvent = { + type: 'error', + error: toRtcError(error), + }; + this.options.emit('error', event); + }; +} diff --git a/src/rtc/clientCredentials.ts b/src/rtc/clientCredentials.ts new file mode 100644 index 00000000..56a75d52 --- /dev/null +++ b/src/rtc/clientCredentials.ts @@ -0,0 +1,24 @@ +import type { StreamClient } from '../StreamClient'; + +export interface RtcClientCredentials { + apiKey: string; + apiSecret: string; + baseUrl: string; +} + +const credentials = new WeakMap(); + +export const registerRtcClientCredentials = ( + client: StreamClient, + value: RtcClientCredentials, +) => credentials.set(client, value); + +export const rtcClientCredentials = (client: StreamClient) => { + const value = credentials.get(client); + if (!value) { + throw new Error( + 'RTC credentials were not registered for this StreamClient', + ); + } + return value; +}; diff --git a/src/rtc/contracts.ts b/src/rtc/contracts.ts new file mode 100644 index 00000000..e1208a21 --- /dev/null +++ b/src/rtc/contracts.ts @@ -0,0 +1,125 @@ +import { RtcNativeVersionMismatchError } from './errors'; +import { + RTC_BINDING_API_VERSION, + type RtcCallEvent, + type RtcCallStateSnapshot, + type RtcCallingState, + type RtcQueueOverflowEvent, + type RtcStats, + type RtcTrackType, +} from './types'; + +const callingStates = new Set([ + 'idle', + 'joining', + 'joined', + 'reconnecting', + 'migrating', + 'reconnecting-failed', + 'left', + 'offline', +]); + +const trackTypes = new Set([ + 'audio', + 'video', + 'screenshare', + 'screenshare_audio', +]); + +const contractError = (value: string) => + new RtcNativeVersionMismatchError( + `RTC binding API ${RTC_BINDING_API_VERSION} returned invalid ${value}`, + { bindingApiVersion: RTC_BINDING_API_VERSION, value }, + ); + +const parseObject = (json: string, value: string) => { + let parsed: unknown; + try { + parsed = JSON.parse(json) as unknown; + } catch (error) { + throw new RtcNativeVersionMismatchError( + `RTC binding API ${RTC_BINDING_API_VERSION} returned malformed ${value} JSON`, + { bindingApiVersion: RTC_BINDING_API_VERSION, value }, + { cause: error }, + ); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw contractError(value); + } + return parsed as Record; +}; + +const isNonNegativeInteger = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + +export const parseRtcCallEvent = (json: string): RtcCallEvent => { + const event = parseObject(json, 'call event'); + if (typeof event.type !== 'string' || event.type.length === 0) { + throw contractError('call event type'); + } + + if (event.type === 'callingStateChanged') { + if ( + typeof event.callingState !== 'string' || + !callingStates.has(event.callingState as RtcCallingState) + ) { + throw contractError('callingStateChanged event'); + } + } else if (event.type === 'trackUnpublished') { + if ( + typeof event.userId !== 'string' || + typeof event.sessionId !== 'string' || + typeof event.trackType !== 'string' || + !trackTypes.has(event.trackType as RtcTrackType) || + !isNonNegativeInteger(event.trackTypeCode) + ) { + throw contractError('trackUnpublished event'); + } + } else if (event.type === 'queueOverflow') { + if ( + event.queue !== 'events' || + !isNonNegativeInteger(event.dropped) || + !isNonNegativeInteger(event.totalDropped) + ) { + throw contractError('queueOverflow event'); + } + return event as unknown as RtcQueueOverflowEvent; + } + + return event as RtcCallEvent; +}; + +export const parseRtcCallState = (json: string): RtcCallStateSnapshot => { + const snapshot = parseObject(json, 'call state'); + if ( + typeof snapshot.callingState !== 'string' || + !callingStates.has(snapshot.callingState as RtcCallingState) || + (snapshot.sessionId !== undefined && + snapshot.sessionId !== null && + typeof snapshot.sessionId !== 'string') || + !Array.isArray(snapshot.participants) || + !isNonNegativeInteger(snapshot.participantCount) || + !isNonNegativeInteger(snapshot.anonymousParticipantCount) || + !Array.isArray(snapshot.pins) || + (snapshot.startedAt !== undefined && + snapshot.startedAt !== null && + typeof snapshot.startedAt !== 'string') || + typeof snapshot.e2eeEnabled !== 'boolean' || + !Array.isArray(snapshot.ownCapabilities) || + !snapshot.ownCapabilities.every( + (capability) => typeof capability === 'string', + ) + ) { + throw contractError('call state'); + } + return snapshot as unknown as RtcCallStateSnapshot; +}; + +export const parseRtcStats = (json: string): RtcStats => { + const stats = parseObject(json, 'stats'); + if (!isNonNegativeInteger(stats.droppedRemoteTracks)) { + throw contractError('stats'); + } + return stats as unknown as RtcStats; +}; diff --git a/src/rtc/errors.ts b/src/rtc/errors.ts new file mode 100644 index 00000000..2bedd364 --- /dev/null +++ b/src/rtc/errors.ts @@ -0,0 +1,215 @@ +export type RtcErrorCode = + | 'RTC_NATIVE_UNAVAILABLE' + | 'RTC_NATIVE_VERSION_MISMATCH' + | 'RTC_UNSUPPORTED_PLATFORM' + | 'RTC_ILLEGAL_STATE' + | 'RTC_PERMISSION_DENIED' + | 'RTC_JOIN' + | 'RTC_TIMEOUT' + | 'RTC_CONNECTION' + | 'RTC_NEGOTIATION' + | 'RTC_MEDIA' + | 'RTC_UNSUPPORTED_LAYERING' + | 'RTC_QUEUE_OVERFLOW' + | 'RTC_SIZE_LIMIT' + | 'RTC_CLOSED' + | 'RTC_UNKNOWN'; + +export interface RtcErrorDetails { + [key: string]: unknown; +} + +export class RtcError extends Error { + constructor( + message: string, + readonly code: RtcErrorCode, + readonly details: RtcErrorDetails = {}, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'RtcError'; + } +} + +export class RtcNativeUnavailableError extends RtcError { + constructor( + message: string, + details: RtcErrorDetails = {}, + options?: ErrorOptions, + ) { + super(message, 'RTC_NATIVE_UNAVAILABLE', details, options); + this.name = 'RtcNativeUnavailableError'; + } +} + +export class RtcNativeVersionMismatchError extends RtcError { + constructor( + message: string, + details: RtcErrorDetails = {}, + options?: ErrorOptions, + ) { + super(message, 'RTC_NATIVE_VERSION_MISMATCH', details, options); + this.name = 'RtcNativeVersionMismatchError'; + } +} + +export class RtcUnsupportedPlatformError extends RtcError { + constructor( + message: string, + details: RtcErrorDetails = {}, + options?: ErrorOptions, + ) { + super(message, 'RTC_UNSUPPORTED_PLATFORM', details, options); + this.name = 'RtcUnsupportedPlatformError'; + } +} + +export class RtcIllegalStateError extends RtcError { + constructor( + message: string, + details: RtcErrorDetails = {}, + options?: ErrorOptions, + ) { + super(message, 'RTC_ILLEGAL_STATE', details, options); + this.name = 'RtcIllegalStateError'; + } +} + +const defineRtcError = ( + name: string, + code: TCode, +) => + class extends RtcError { + constructor( + message: string, + details: RtcErrorDetails = {}, + options?: ErrorOptions, + ) { + super(message, code, details, options); + this.name = name; + } + }; + +export class RtcPermissionDeniedError extends defineRtcError( + 'RtcPermissionDeniedError', + 'RTC_PERMISSION_DENIED', +) {} +export class RtcJoinError extends defineRtcError('RtcJoinError', 'RTC_JOIN') {} +export class RtcTimeoutError extends defineRtcError( + 'RtcTimeoutError', + 'RTC_TIMEOUT', +) {} +export class RtcConnectionError extends defineRtcError( + 'RtcConnectionError', + 'RTC_CONNECTION', +) {} +export class RtcNegotiationError extends defineRtcError( + 'RtcNegotiationError', + 'RTC_NEGOTIATION', +) {} +export class RtcMediaError extends defineRtcError( + 'RtcMediaError', + 'RTC_MEDIA', +) {} +export class RtcUnsupportedLayeringError extends defineRtcError( + 'RtcUnsupportedLayeringError', + 'RTC_UNSUPPORTED_LAYERING', +) {} +export class RtcQueueOverflowError extends defineRtcError( + 'RtcQueueOverflowError', + 'RTC_QUEUE_OVERFLOW', +) {} +export class RtcSizeLimitError extends defineRtcError( + 'RtcSizeLimitError', + 'RTC_SIZE_LIMIT', +) {} +export class RtcClosedError extends defineRtcError( + 'RtcClosedError', + 'RTC_CLOSED', +) {} + +type NativeError = Error & { + code?: unknown; + details?: unknown; +}; + +const rtcCodes = new Set([ + 'RTC_NATIVE_UNAVAILABLE', + 'RTC_NATIVE_VERSION_MISMATCH', + 'RTC_UNSUPPORTED_PLATFORM', + 'RTC_ILLEGAL_STATE', + 'RTC_PERMISSION_DENIED', + 'RTC_JOIN', + 'RTC_TIMEOUT', + 'RTC_CONNECTION', + 'RTC_NEGOTIATION', + 'RTC_MEDIA', + 'RTC_UNSUPPORTED_LAYERING', + 'RTC_QUEUE_OVERFLOW', + 'RTC_SIZE_LIMIT', + 'RTC_CLOSED', + 'RTC_UNKNOWN', +]); + +export const toRtcError = (error: unknown): RtcError => { + if (error instanceof RtcError) return error; + + const native = error instanceof Error ? (error as NativeError) : undefined; + let encoded: { code?: unknown; message?: unknown; details?: unknown } = {}; + if (native?.message.trimStart().startsWith('{')) { + try { + encoded = JSON.parse(native.message) as typeof encoded; + } catch { + // Keep the original native message when it is not an encoded RTC error. + } + } + const code = + typeof (encoded.code ?? native?.code) === 'string' && + rtcCodes.has((encoded.code ?? native?.code) as RtcErrorCode) + ? ((encoded.code ?? native?.code) as RtcErrorCode) + : 'RTC_UNKNOWN'; + const details = + (encoded.details ?? native?.details) && + typeof (encoded.details ?? native?.details) === 'object' && + !Array.isArray(encoded.details ?? native?.details) + ? ((encoded.details ?? native?.details) as RtcErrorDetails) + : {}; + const message = + typeof encoded.message === 'string' + ? encoded.message + : (native?.message ?? String(error)); + + const options = { cause: error }; + switch (code) { + case 'RTC_NATIVE_UNAVAILABLE': + return new RtcNativeUnavailableError(message, details, options); + case 'RTC_NATIVE_VERSION_MISMATCH': + return new RtcNativeVersionMismatchError(message, details, options); + case 'RTC_UNSUPPORTED_PLATFORM': + return new RtcUnsupportedPlatformError(message, details, options); + case 'RTC_ILLEGAL_STATE': + return new RtcIllegalStateError(message, details, options); + case 'RTC_PERMISSION_DENIED': + return new RtcPermissionDeniedError(message, details, options); + case 'RTC_JOIN': + return new RtcJoinError(message, details, options); + case 'RTC_TIMEOUT': + return new RtcTimeoutError(message, details, options); + case 'RTC_CONNECTION': + return new RtcConnectionError(message, details, options); + case 'RTC_NEGOTIATION': + return new RtcNegotiationError(message, details, options); + case 'RTC_MEDIA': + return new RtcMediaError(message, details, options); + case 'RTC_UNSUPPORTED_LAYERING': + return new RtcUnsupportedLayeringError(message, details, options); + case 'RTC_QUEUE_OVERFLOW': + return new RtcQueueOverflowError(message, details, options); + case 'RTC_SIZE_LIMIT': + return new RtcSizeLimitError(message, details, options); + case 'RTC_CLOSED': + return new RtcClosedError(message, details, options); + default: + return new RtcError(message, code, details, options); + } +}; diff --git a/src/rtc/events.ts b/src/rtc/events.ts new file mode 100644 index 00000000..4bedeb81 --- /dev/null +++ b/src/rtc/events.ts @@ -0,0 +1,68 @@ +import { toRtcError } from './errors'; +import type { RtcErrorEvent } from './types'; + +export type RtcEventListener = (event: unknown) => unknown; + +export class RtcEventDispatcher { + private readonly listeners = new Map>(); + + on = (event: string, listener: RtcEventListener) => { + let listeners = this.listeners.get(event); + if (!listeners) { + listeners = new Set(); + this.listeners.set(event, listeners); + } + listeners.add(listener); + return () => this.off(event, listener); + }; + + off = (event: string, listener: RtcEventListener) => { + const listeners = this.listeners.get(event); + listeners?.delete(listener); + if (listeners?.size === 0) this.listeners.delete(event); + }; + + dispatch = (event: string, payload: unknown) => { + const failures = this.dispatchOnce(event, payload); + if (event === 'error') return; + + for (const failure of failures) { + this.dispatchListenerFailure(event, failure); + } + }; + + private dispatchOnce = (event: string, payload: unknown) => { + const failures: unknown[] = []; + const notify = (listener: RtcEventListener) => { + try { + void Promise.resolve(listener(payload)).catch((error: unknown) => { + if (event !== 'error') this.dispatchListenerFailure(event, error); + }); + } catch (error) { + failures.push(error); + } + }; + + for (const listener of [...(this.listeners.get(event) ?? [])]) { + notify(listener); + } + if (event !== 'all') { + for (const listener of [...(this.listeners.get('all') ?? [])]) { + notify(listener); + } + } + return failures; + }; + + private dispatchListenerFailure = ( + sourceEventType: string, + error: unknown, + ) => { + const errorEvent: RtcErrorEvent = { + type: 'error', + error: toRtcError(error), + sourceEventType, + }; + this.dispatchOnce('error', errorEvent); + }; +} diff --git a/src/rtc/native.ts b/src/rtc/native.ts new file mode 100644 index 00000000..db294a89 --- /dev/null +++ b/src/rtc/native.ts @@ -0,0 +1,327 @@ +import { isAbsolute } from 'node:path'; +import { createRequire } from 'node:module'; +import type { StreamClient } from '../StreamClient'; +import { rtcClientCredentials } from './clientCredentials'; +import { + RTC_BINDING_API_VERSION, + type PcmFrame, + type RtpPacket, + type VideoFrame, +} from './types'; +import { + RtcNativeUnavailableError, + RtcNativeVersionMismatchError, + RtcUnsupportedPlatformError, +} from './errors'; + +export interface NativeLocalAudioTrack { + writePcm(data: Buffer, sampleRate: number, channels: number): Promise; + writeEncoded( + data: Buffer, + durationMs: number, + audioLevel?: number, + ): Promise; + writeRtp(packet: RtpPacket, audioLevel?: number): Promise; + flush(): void; +} + +export interface NativeLocalVideoTrack { + writeI420( + data: Buffer, + width: number, + height: number, + durationMs: number, + ): Promise; + writeEncoded(data: Buffer, durationMs: number): Promise; + writeRtp(packet: RtpPacket): Promise; +} + +export interface NativeRemoteTrack { + readonly userId: string; + readonly sessionId: string; + readonly trackLookupPrefix: string; + readonly trackType: string; + readonly mimeType: string; + readonly payloadType: number; + readonly clockRate: number; + readonly channels: number; + readonly ssrc: number; + nextPcm(): Promise; + nextVideoFrame(): Promise; + readRtp(): Promise; + drainRtp(): Promise; + requestKeyframe(): Promise; +} + +export interface NativeCall { + join(optionsJson: string): Promise; + leave(): Promise; + stateJson(): Promise; + statsJson(): Promise; + nextEvent(): Promise; + nextRemoteTrack(): Promise; + requestPermissions(permissions: string[]): Promise; + setDisconnectionTimeout(timeoutSeconds: number): void; + updatePublishOptions(preferredVideoCodec?: string): void; + updateSubscriptions(configJson: string): Promise; + updateSubscriptionTargets(targetsJson: string): Promise; + setIncomingVideoEnabled(enabled: boolean): Promise; + publishAudio(track: NativeLocalAudioTrack): Promise; + publishVideo(track: NativeLocalVideoTrack): Promise; + publishScreenShare(track: NativeLocalVideoTrack): Promise; + publishScreenShareAudio(track: NativeLocalAudioTrack): Promise; + stopPublishAudio( + track: NativeLocalAudioTrack, + trackType: string, + ): Promise; + stopPublishVideo( + track: NativeLocalVideoTrack, + trackType: string, + ): Promise; + muteTrack(trackType: string): Promise; + unmuteTrack(trackType: string): Promise; + startNoiseCancellation(): Promise; + stopNoiseCancellation(): Promise; +} + +interface NativeStreamClient { + call(type: string, id: string): NativeCall; +} + +interface NativeConstructor { + new (...args: A): T; +} + +export interface RtcNativeBinding { + bindingApiVersion: number | (() => number); + NativeCall: { prototype: NativeCall }; + NativeStreamClient: NativeConstructor< + NativeStreamClient, + [apiKey: string, apiSecret: string, baseUrl?: string] + >; + NativeLocalAudioTrack: { + prototype: NativeLocalAudioTrack; + opus(): NativeLocalAudioTrack; + }; + NativeLocalVideoTrack: { + prototype: NativeLocalVideoTrack; + vp8(optionsJson?: string): NativeLocalVideoTrack; + vp9(optionsJson?: string): NativeLocalVideoTrack; + h264(optionsJson?: string): NativeLocalVideoTrack; + }; + NativeRemoteTrack: { prototype: NativeRemoteTrack }; +} + +const require = createRequire(`${process.cwd()}/package.json`); +const supportedPlatforms = new Set(['darwin', 'linux']); + +let loadedBinding: RtcNativeBinding | undefined; +const nativeClients = new WeakMap(); + +const method = (value: unknown, name: string) => + value !== null && + (typeof value === 'object' || typeof value === 'function') && + typeof (value as Record)[name] === 'function'; + +const validateClass = ( + value: unknown, + name: string, + instanceMethods: string[], + staticMethods: string[] = [], +) => { + const prototype = + typeof value === 'function' + ? (value as unknown as { prototype: unknown }).prototype + : undefined; + if ( + typeof value !== 'function' || + !prototype || + typeof prototype !== 'object' || + !instanceMethods.every((entry) => method(prototype, entry)) || + !staticMethods.every((entry) => method(value, entry)) + ) { + throw new RtcNativeUnavailableError( + `The RTC native module has an invalid ${name} export`, + { export: name }, + ); + } +}; + +const nativeCallMethods = [ + 'join', + 'leave', + 'stateJson', + 'statsJson', + 'nextEvent', + 'nextRemoteTrack', + 'requestPermissions', + 'setDisconnectionTimeout', + 'updatePublishOptions', + 'updateSubscriptions', + 'updateSubscriptionTargets', + 'setIncomingVideoEnabled', + 'publishAudio', + 'publishVideo', + 'publishScreenShare', + 'publishScreenShareAudio', + 'stopPublishAudio', + 'stopPublishVideo', + 'muteTrack', + 'unmuteTrack', + 'startNoiseCancellation', + 'stopNoiseCancellation', +]; + +const validateBinding = (candidate: unknown): RtcNativeBinding => { + if (!candidate || typeof candidate !== 'object') { + throw new RtcNativeUnavailableError( + 'The RTC native module did not export a binding object', + ); + } + + const exports = candidate as Record; + const versionExport = exports.bindingApiVersion; + if ( + typeof versionExport !== 'number' && + typeof versionExport !== 'function' + ) { + throw new RtcNativeUnavailableError( + 'The RTC native module has an invalid bindingApiVersion export', + ); + } + + let actual: unknown; + try { + actual = + typeof versionExport === 'function' + ? (versionExport as () => unknown)() + : versionExport; + } catch (error) { + throw new RtcNativeUnavailableError( + 'The RTC native module could not report its binding API version', + {}, + { cause: error }, + ); + } + if (actual !== RTC_BINDING_API_VERSION) { + throw new RtcNativeVersionMismatchError( + `RTC binding API ${String(actual)} is incompatible with the required API ${RTC_BINDING_API_VERSION}`, + { actual, expected: RTC_BINDING_API_VERSION }, + ); + } + + validateClass(exports.NativeStreamClient, 'NativeStreamClient', ['call']); + validateClass(exports.NativeCall, 'NativeCall', nativeCallMethods); + validateClass( + exports.NativeLocalAudioTrack, + 'NativeLocalAudioTrack', + ['writePcm', 'writeEncoded', 'writeRtp', 'flush'], + ['opus'], + ); + validateClass( + exports.NativeLocalVideoTrack, + 'NativeLocalVideoTrack', + ['writeI420', 'writeEncoded', 'writeRtp'], + ['vp8', 'vp9', 'h264'], + ); + validateClass(exports.NativeRemoteTrack, 'NativeRemoteTrack', [ + 'nextPcm', + 'nextVideoFrame', + 'readRtp', + 'drainRtp', + 'requestKeyframe', + ]); + + return candidate as RtcNativeBinding; +}; + +export const loadRtcNativeBinding = (): RtcNativeBinding => { + if (loadedBinding) return loadedBinding; + + if (!supportedPlatforms.has(process.platform)) { + throw new RtcUnsupportedPlatformError( + `Server-side RTC is not available on ${process.platform}/${process.arch}; this preview supports macOS and Linux`, + { arch: process.arch, platform: process.platform }, + ); + } + + const configuredPath = process.env.STREAM_NODE_RTC_NATIVE_PATH; + if (configuredPath && !isAbsolute(configuredPath)) { + throw new RtcNativeUnavailableError( + 'STREAM_NODE_RTC_NATIVE_PATH must be an absolute path', + { path: configuredPath }, + ); + } + + const candidates = configuredPath + ? [configuredPath] + : ['@stream-io/node-rtc']; + let cause: unknown; + + for (const candidate of candidates) { + let resolved: unknown; + try { + resolved = require(candidate); + } catch (error) { + // Only a resolution failure is worth trying the next candidate for. + cause = error; + continue; + } + // A module that loaded but is not a usable binding is a hard error: saying + // "could not load" would send the caller after the wrong problem. + loadedBinding = validateBinding(resolved); + return loadedBinding; + } + + throw new RtcNativeUnavailableError( + 'Server-side RTC could not load its native addon. Build the Rust binding and set STREAM_NODE_RTC_NATIVE_PATH to the absolute .node file.', + { path: configuredPath, platform: process.platform, arch: process.arch }, + { cause }, + ); +}; + +export const nativeRtcClient = (client: StreamClient) => { + const existing = nativeClients.get(client); + if (existing) return existing; + + const binding = loadRtcNativeBinding(); + const value = rtcClientCredentials(client); + const nativeClient = new binding.NativeStreamClient( + value.apiKey, + value.apiSecret, + value.baseUrl, + ); + nativeClients.set(client, nativeClient); + return nativeClient; +}; + +export const nativeRtcCall = ( + client: StreamClient, + callType: string, + id: string, +) => { + const nativeCall = nativeRtcClient(client).call(callType, id); + if ( + !nativeCall || + typeof nativeCall !== 'object' || + !nativeCallMethods.every((name) => method(nativeCall, name)) + ) { + throw new RtcNativeUnavailableError( + 'The RTC native module returned an invalid NativeCall handle', + ); + } + return nativeCall; +}; + +/** + * Drop the memoized addon so the next RTC use resolves it again. + * + * Only the loader's own tests need this — they point + * `STREAM_NODE_RTC_NATIVE_PATH` at different modules across cases. There is + * deliberately no public API for injecting a substitute binding. + * + * @internal + */ +export const resetRtcNativeBindingCache = () => { + loadedBinding = undefined; +}; diff --git a/src/rtc/state.ts b/src/rtc/state.ts new file mode 100644 index 00000000..990ad824 --- /dev/null +++ b/src/rtc/state.ts @@ -0,0 +1,107 @@ +import type { RemoteTrack } from './tracks'; +import type { + RtcCallingState, + RtcCallStateSnapshot, + RtcParticipant, +} from './types'; + +const initialSnapshot = (): RtcCallStateSnapshot => ({ + callingState: 'idle', + participants: [], + participantCount: 0, + anonymousParticipantCount: 0, + pins: [], + e2eeEnabled: false, + ownCapabilities: [], +}); + +export class StreamCallState { + private snapshot = initialSnapshot(); + private tracks = new Map(); + + get callingState(): RtcCallingState { + return this.snapshot.callingState; + } + + get sessionId() { + return this.snapshot.sessionId; + } + + get participants(): readonly RtcParticipant[] { + return this.snapshot.participants; + } + + get localParticipant() { + return this.snapshot.participants.find( + (participant) => participant.sessionId === this.snapshot.sessionId, + ); + } + + get remoteParticipants() { + return this.snapshot.participants.filter( + (participant) => participant.sessionId !== this.snapshot.sessionId, + ); + } + + get participantCount() { + return this.snapshot.participantCount; + } + + get anonymousParticipantCount() { + return this.snapshot.anonymousParticipantCount; + } + + get ownCapabilities(): readonly string[] { + return this.snapshot.ownCapabilities; + } + + get currentGrants() { + return this.snapshot.currentGrants; + } + + get pins(): readonly unknown[] { + return this.snapshot.pins; + } + + get startedAt() { + return this.snapshot.startedAt; + } + + get e2eeEnabled() { + return this.snapshot.e2eeEnabled; + } + + get remoteTracks(): readonly RemoteTrack[] { + return [...this.tracks.values()]; + } + + /** @internal */ + update = (snapshot: RtcCallStateSnapshot) => { + this.snapshot = { + ...snapshot, + participants: [...snapshot.participants], + pins: [...snapshot.pins], + ownCapabilities: [...snapshot.ownCapabilities], + }; + }; + + /** @internal */ + setCallingState = (callingState: RtcCallingState) => { + this.snapshot = { ...this.snapshot, callingState }; + }; + + /** @internal */ + addRemoteTrack = (track: RemoteTrack) => { + this.tracks.set(trackKey(track), track); + }; + + /** @internal */ + removeRemoteTrack = (sessionId: string, type: string) => { + this.tracks.delete(`${sessionId}:${type}`); + }; + + /** @internal */ + clearRemoteTracks = () => this.tracks.clear(); +} + +const trackKey = (track: RemoteTrack) => `${track.sessionId}:${track.type}`; diff --git a/src/rtc/tracks.ts b/src/rtc/tracks.ts new file mode 100644 index 00000000..ae826adf --- /dev/null +++ b/src/rtc/tracks.ts @@ -0,0 +1,254 @@ +import { RtcIllegalStateError, toRtcError } from './errors'; +import { + loadRtcNativeBinding, + type NativeLocalAudioTrack, + type NativeLocalVideoTrack, + type NativeRemoteTrack, +} from './native'; +import type { + PcmFrame, + RtpPacket, + RtcTrackType, + RtcVideoCodec, + VideoFrame, + VideoTrackOptions, +} from './types'; + +export interface PcmWriteOptions { + sampleRate: number; + channels: number; +} + +export interface EncodedAudioWriteOptions { + durationMs: number; + audioLevel?: number; +} + +export interface RtpAudioWriteOptions { + audioLevel?: number; +} + +export interface VideoFrameWriteOptions { + width: number; + height: number; + durationMs: number; +} + +export interface EncodedVideoWriteOptions { + durationMs: number; +} + +const audioHandles = new WeakMap(); +const videoHandles = new WeakMap(); + +export class LocalAudioTrack { + private constructor(handle: NativeLocalAudioTrack) { + audioHandles.set(this, handle); + } + + static opus = () => { + try { + return new LocalAudioTrack( + loadRtcNativeBinding().NativeLocalAudioTrack.opus(), + ); + } catch (error) { + throw toRtcError(error); + } + }; + + writePcm = async (data: Buffer, options: PcmWriteOptions) => { + try { + await nativeAudioTrack(this).writePcm( + data, + options.sampleRate, + options.channels, + ); + } catch (error) { + throw toRtcError(error); + } + }; + + writeEncoded = async (data: Buffer, options: EncodedAudioWriteOptions) => { + try { + await nativeAudioTrack(this).writeEncoded( + data, + options.durationMs, + options.audioLevel, + ); + } catch (error) { + throw toRtcError(error); + } + }; + + writeRtp = async (packet: RtpPacket, options?: RtpAudioWriteOptions) => { + try { + await nativeAudioTrack(this).writeRtp(packet, options?.audioLevel); + } catch (error) { + throw toRtcError(error); + } + }; + + flush = () => { + try { + nativeAudioTrack(this).flush(); + } catch (error) { + throw toRtcError(error); + } + }; +} + +export class LocalVideoTrack { + readonly codec: RtcVideoCodec; + + private constructor(handle: NativeLocalVideoTrack, codec: RtcVideoCodec) { + this.codec = codec; + videoHandles.set(this, handle); + } + + private static create = ( + codec: RtcVideoCodec, + options?: VideoTrackOptions, + ) => { + try { + const constructors = loadRtcNativeBinding().NativeLocalVideoTrack; + const handle = constructors[codec]( + options ? JSON.stringify(options) : undefined, + ); + return new LocalVideoTrack(handle, codec); + } catch (error) { + throw toRtcError(error); + } + }; + + static vp8 = (options?: VideoTrackOptions) => this.create('vp8', options); + static vp9 = (options?: VideoTrackOptions) => this.create('vp9', options); + static h264 = (options?: VideoTrackOptions) => this.create('h264', options); + + writeI420 = async (data: Buffer, options: VideoFrameWriteOptions) => { + try { + await nativeVideoTrack(this).writeI420( + data, + options.width, + options.height, + options.durationMs, + ); + } catch (error) { + throw toRtcError(error); + } + }; + + writeEncoded = async (data: Buffer, options: EncodedVideoWriteOptions) => { + try { + await nativeVideoTrack(this).writeEncoded(data, options.durationMs); + } catch (error) { + throw toRtcError(error); + } + }; + + writeRtp = async (packet: RtpPacket) => { + try { + await nativeVideoTrack(this).writeRtp(packet); + } catch (error) { + throw toRtcError(error); + } + }; +} + +type ReadMode = 'decoded' | 'rtp'; + +export class RemoteTrack { + private readMode?: ReadMode; + + constructor(private readonly native: NativeRemoteTrack) {} + + get userId() { + return this.native.userId; + } + + get sessionId() { + return this.native.sessionId; + } + + get trackLookupPrefix() { + return this.native.trackLookupPrefix; + } + + get type() { + return this.native.trackType as RtcTrackType; + } + + get mimeType() { + return this.native.mimeType; + } + + get payloadType() { + return this.native.payloadType; + } + + get clockRate() { + return this.native.clockRate; + } + + get channels() { + return this.native.channels; + } + + get ssrc() { + return this.native.ssrc; + } + + nextPcm = () => + this.read( + 'decoded', + async () => (await this.native.nextPcm()) ?? undefined, + ); + + nextVideoFrame = () => + this.read( + 'decoded', + async () => (await this.native.nextVideoFrame()) ?? undefined, + ); + + readRtp = () => + this.read('rtp', async () => (await this.native.readRtp()) ?? undefined); + + drainRtp = () => this.read('rtp', () => this.native.drainRtp()); + + requestKeyframe = async () => { + try { + await this.native.requestKeyframe(); + } catch (error) { + throw toRtcError(error); + } + }; + + private read = async (mode: ReadMode, operation: () => Promise) => { + if (this.readMode && this.readMode !== mode) { + throw new RtcIllegalStateError( + 'A remote track cannot switch between decoded and raw RTP reads', + { requestedMode: mode, selectedMode: this.readMode }, + ); + } + + this.readMode = mode; + try { + return await operation(); + } catch (error) { + throw toRtcError(error); + } + }; +} + +export const nativeAudioTrack = (track: LocalAudioTrack) => { + const native = audioHandles.get(track); + if (!native) throw new RtcIllegalStateError('Invalid local audio track'); + return native; +}; + +export const nativeVideoTrack = (track: LocalVideoTrack) => { + const native = videoHandles.get(track); + if (!native) throw new RtcIllegalStateError('Invalid local video track'); + return native; +}; + +export type { PcmFrame, RtpPacket, VideoFrame }; diff --git a/src/rtc/types.ts b/src/rtc/types.ts new file mode 100644 index 00000000..0b9b5fbd --- /dev/null +++ b/src/rtc/types.ts @@ -0,0 +1,196 @@ +import type { CallRequest } from '../gen/models'; +import type { RtcError } from './errors'; +import type { RemoteTrack } from './tracks'; + +export const RTC_BINDING_API_VERSION = 1; + +export type RtcCallingState = + | 'idle' + | 'joining' + | 'joined' + | 'reconnecting' + | 'migrating' + | 'reconnecting-failed' + | 'left' + | 'offline'; + +export type RtcTrackType = + 'audio' | 'video' | 'screenshare' | 'screenshare_audio'; + +export type RtcVideoCodec = 'vp8' | 'vp9' | 'h264'; + +export interface JoinCallOptions { + userId: string; + create?: boolean; + data?: CallRequest; + ring?: boolean; + notify?: boolean; + video?: boolean; + location?: string; + preferredVideoCodec?: RtcVideoCodec; + maxJoinRetries?: number; + joinResponseTimeoutMs?: number; + rpcRequestTimeoutMs?: number; +} + +export interface RtcParticipant { + userId: string; + sessionId: string; + trackLookupPrefix: string; + publishedTracks: RtcTrackType[]; + joinedAt?: string | null; + connectionQuality: string; + isSpeaking: boolean; + isDominantSpeaker: boolean; + audioLevel: number; + name: string; + image: string; + custom?: Record | null; + roles: string[]; + source: string; + pausedTracks: RtcTrackType[]; +} + +export interface RtcCallStateSnapshot { + callingState: RtcCallingState; + sessionId?: string | null; + participants: RtcParticipant[]; + participantCount: number; + anonymousParticipantCount: number; + pins: unknown[]; + startedAt?: string | null; + e2eeEnabled: boolean; + ownCapabilities: string[]; + currentGrants?: unknown; +} + +export interface RtcStats { + publisher: unknown; + subscriber: unknown; + droppedRemoteTracks: number; +} + +export interface SubscriptionConfig { + audio?: boolean; + video?: boolean; + screenShare?: boolean; + videoDimension?: VideoDimension; +} + +export interface SubscriptionTarget { + sessionId: string; + trackType: RtcTrackType; + dimension?: VideoDimension; +} + +export interface VideoDimension { + width: number; + height: number; +} + +export interface VideoTrackOptions { + targetBitrateBps?: number; + allowFrameSkipping?: boolean; + layering?: + | { mode: 'single' } + | { + mode: 'server-managed'; + maxSpatialLayers?: number; + maxTemporalLayers?: number; + }; +} + +export interface PcmFrame { + data: Buffer; + sampleRate: number; + channels: number; + durationMs: number; +} + +export interface VideoFrame { + data: Buffer; + width: number; + height: number; + rtpTimestamp: number; +} + +export interface RtpExtension { + id: number; + payload: Buffer; +} + +export interface RtpPacket { + version: number; + padding: boolean; + extension: boolean; + marker: boolean; + payloadType: number; + sequenceNumber: number; + timestamp: number; + ssrc: number; + csrc: number[]; + extensionProfile: number; + extensions: RtpExtension[]; + extensionsPadding: number; + payload: Buffer; +} + +export interface RtcCallEvent { + type: string; + [key: string]: unknown; +} + +export interface RtcCallingStateChangedEvent extends RtcCallEvent { + type: 'callingStateChanged'; + callingState: RtcCallingState; +} + +export interface RtcTrackUnpublishedEvent extends RtcCallEvent { + type: 'trackUnpublished'; + userId: string; + sessionId: string; + trackType: RtcTrackType; + trackTypeCode: number; +} + +export interface RtcQueueOverflowEvent extends RtcCallEvent { + type: 'queueOverflow'; + queue: 'events'; + dropped: number; + totalDropped: number; +} + +export interface RtcErrorEvent extends RtcCallEvent { + type: 'error'; + error?: RtcError; + sourceEventType?: string; +} + +export interface RtcCallEventMap { + all: RtcCallEvent | RemoteTrack; + participantJoined: RtcCallEvent; + participantLeft: RtcCallEvent; + participantUpdated: RtcCallEvent; + trackPublished: RtcCallEvent; + trackUnpublished: RtcTrackUnpublishedEvent; + dominantSpeakerChanged: RtcCallEvent; + audioLevelChanged: RtcCallEvent; + connectionQualityChanged: RtcCallEvent; + participantCountChanged: RtcCallEvent; + pinsUpdated: RtcCallEvent; + inboundStateChanged: RtcCallEvent; + publishOptionsChanged: RtcCallEvent; + publishQualityChanged: RtcCallEvent; + callGrantsUpdated: RtcCallEvent; + iceRestarted: RtcCallEvent; + callEnded: RtcCallEvent; + callingStateChanged: RtcCallingStateChangedEvent; + queueOverflow: RtcQueueOverflowEvent; + error: RtcErrorEvent; + remoteTrack: RemoteTrack; +} + +export type RtcCallEventName = keyof RtcCallEventMap; +export type RtcCallEventHandler = ( + event: RtcCallEventMap[E], +) => void; From 89f6febda5201a15c39b2d5fdb135e8abb68bd68 Mon Sep 17 00:00:00 2001 From: "Neevash Ramdial (Nash)" Date: Thu, 27 Aug 2026 17:47:41 -0400 Subject: [PATCH 2/7] test: cover server-side RTC behavior Add deterministic unit coverage, opt-in live scenarios, and scheduled CI for lifecycle, media, permissions, and native loading. --- .github/workflows/rtc-live.yml | 65 +++++ __tests__/rtc/agent-scenario.test.ts | 274 ++++++++++++++++++ __tests__/rtc/call-lifecycle.test.ts | 277 ++++++++++++++++++ __tests__/rtc/errors.test.ts | 185 ++++++++++++ __tests__/rtc/events.unit.test.ts | 156 ++++++++++ __tests__/rtc/lifecycle.unit.test.ts | 416 +++++++++++++++++++++++++++ __tests__/rtc/live.ts | 229 +++++++++++++++ __tests__/rtc/media-effects.test.ts | 69 +++++ __tests__/rtc/media.test.ts | 412 ++++++++++++++++++++++++++ __tests__/rtc/native-loader.test.ts | 239 +++++++++++++++ __tests__/rtc/permissions.test.ts | 149 ++++++++++ __tests__/rtc/test-helpers.ts | 114 ++++++++ __tests__/rtc/tracks.unit.test.ts | 98 +++++++ package.json | 4 + vite.config.mts | 6 + vitest.rtc-live.config.mts | 31 ++ 16 files changed, 2724 insertions(+) create mode 100644 .github/workflows/rtc-live.yml create mode 100644 __tests__/rtc/agent-scenario.test.ts create mode 100644 __tests__/rtc/call-lifecycle.test.ts create mode 100644 __tests__/rtc/errors.test.ts create mode 100644 __tests__/rtc/events.unit.test.ts create mode 100644 __tests__/rtc/lifecycle.unit.test.ts create mode 100644 __tests__/rtc/live.ts create mode 100644 __tests__/rtc/media-effects.test.ts create mode 100644 __tests__/rtc/media.test.ts create mode 100644 __tests__/rtc/native-loader.test.ts create mode 100644 __tests__/rtc/permissions.test.ts create mode 100644 __tests__/rtc/test-helpers.ts create mode 100644 __tests__/rtc/tracks.unit.test.ts create mode 100644 vitest.rtc-live.config.mts diff --git a/.github/workflows/rtc-live.yml b/.github/workflows/rtc-live.yml new file mode 100644 index 00000000..98bca0ce --- /dev/null +++ b/.github/workflows/rtc-live.yml @@ -0,0 +1,65 @@ +name: RTC Live + +on: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + inputs: + rust_ref: + description: stream-video-rust branch, tag, or SHA + required: true + default: main + +permissions: + contents: read + +concurrency: + group: ${{ github.repository }}-rtc-live + cancel-in-progress: false + +env: + RUN_STREAM_RTC_LIVE: "1" + STREAM_API_KEY: ${{ vars.TEST_API_KEY }} + STREAM_SECRET: ${{ secrets.TEST_SECRET }} + STREAM_NODE_RTC_NATIVE_PATH: ${{ github.workspace }}/stream-video-rust/bindings/node/stream-node-rtc.node + +jobs: + live: + name: Node RTC live SFU + runs-on: ubuntu-latest + timeout-minutes: 75 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: GetStream/stream-video-rust + ref: ${{ inputs.rust_ref || 'main' }} + path: stream-video-rust + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: yarn + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends \ + build-essential clang cmake libvpx-dev pkg-config + - name: Require live credentials + run: | + if [[ -z "${STREAM_API_KEY:-}" || -z "${STREAM_SECRET:-}" ]]; then + echo "TEST_API_KEY and TEST_SECRET are required for RTC live tests." >&2 + exit 1 + fi + - name: Install native binding dependencies + working-directory: stream-video-rust/bindings/node + run: npm ci --ignore-scripts + - name: Build native binding + working-directory: stream-video-rust/bindings/node + run: npm run build:local + - name: Install Node SDK dependencies + run: yarn install --immutable + - name: Run credentialed RTC live tests + run: yarn test:rtc:live diff --git a/__tests__/rtc/agent-scenario.test.ts b/__tests__/rtc/agent-scenario.test.ts new file mode 100644 index 00000000..e0aa18f7 --- /dev/null +++ b/__tests__/rtc/agent-scenario.test.ts @@ -0,0 +1,274 @@ +import 'dotenv/config'; +import { randomUUID } from 'node:crypto'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { StreamClient } from '../../src/StreamClient'; +import type { StreamCall } from '../../src/StreamCall'; +import { LocalAudioTrack, LocalVideoTrack } from '../../src/rtc/tracks'; +import type { RemoteTrack } from '../../src/rtc/tracks'; +import { + createNeonTimeSlice, + createRobotVoice, +} from '../../examples/rtc-neon-effects.mjs'; + +/* + * The provider-free agent scenario: a publisher sends audio and video, a + * backend agent joins, receives both, transforms them deterministically, and + * publishes the result back where the publisher can observe it. + * + * Needs live credentials and a locally built addon: + * export RUN_STREAM_RTC_LIVE=1 + * export STREAM_API_KEY=... STREAM_SECRET=... + * export STREAM_NODE_RTC_NATIVE_PATH=/abs/path/to/stream-node-rtc.node + */ +const apiKey = process.env.STREAM_API_KEY; +const secret = process.env.STREAM_SECRET; +const live = Boolean( + process.env.RUN_STREAM_RTC_LIVE === '1' && + apiKey && + secret && + process.env.STREAM_NODE_RTC_NATIVE_PATH, +); + +const SAMPLE_RATE = 48_000; +const AUDIO_FRAME_SAMPLES = SAMPLE_RATE / 50; // 20ms +const WIDTH = 320; +const HEIGHT = 240; +const LUMA_SIZE = WIDTH * HEIGHT; +const SOURCE_LUMA = 200; +const SOURCE_AMPLITUDE = 12_000; +const MEDIA_DURATION_MS = 12_000; + +/** A deterministic 440Hz tone at a known amplitude. */ +const toneFrame = (index: number) => { + const data = Buffer.alloc(AUDIO_FRAME_SAMPLES * 2); + for (let i = 0; i < AUDIO_FRAME_SAMPLES; i += 1) { + const t = (index * AUDIO_FRAME_SAMPLES + i) / SAMPLE_RATE; + const value = Math.sin(2 * Math.PI * 440 * t) * SOURCE_AMPLITUDE; + data.writeInt16LE(Math.round(value), i * 2); + } + return data; +}; + +/** A flat I420 frame: constant luma, neutral chroma. */ +const videoFrame = (luma: number) => { + const data = Buffer.alloc(LUMA_SIZE * 1.5); + data.fill(luma, 0, LUMA_SIZE); + data.fill(128, LUMA_SIZE); + return data; +}; + +const rms = (buffer: Buffer) => { + let sum = 0; + const count = buffer.length / 2; + for (let i = 0; i < count; i += 1) { + const sample = buffer.readInt16LE(i * 2); + sum += sample * sample; + } + return Math.sqrt(sum / Math.max(1, count)); +}; + +const meanLuma = (data: Buffer) => { + let sum = 0; + let count = 0; + for (let i = 0; i < LUMA_SIZE; i += 64) { + sum += data[i]; + count += 1; + } + return sum / count; +}; + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))); + +describe.runIf(live)('provider-free agent scenario', () => { + // Built inside the test: `describe.runIf` still evaluates this body when the + // suite is skipped, and the credentials are absent then. + const callId = `rtc-agent-${randomUUID()}`; + const publisherId = `publisher-${randomUUID().slice(0, 8)}`; + const agentId = `agent-${randomUUID().slice(0, 8)}`; + + let publisherCall: StreamCall; + let agentCall: StreamCall; + + afterAll(async () => { + await agentCall?.leave().catch(() => {}); + await publisherCall?.leave().catch(() => {}); + await publisherCall?.end().catch(() => {}); + }); + + it( + 'receives, transforms, and republishes both audio and video', + { timeout: 120_000 }, + async () => { + const client = new StreamClient(apiKey!, secret!, { timeout: 30_000 }); + + await client.upsertUsers([ + { id: publisherId, name: 'Publisher' }, + { id: agentId, name: 'Agent' }, + ]); + + publisherCall = client.video.call('default', callId); + agentCall = client.video.call('default', callId); + await publisherCall.create({ data: { created_by_id: publisherId } }); + + // Pin the codec so both sides negotiate the same encoder. + publisherCall.updatePublishOptions({ preferredVideoCodec: 'vp9' }); + agentCall.updatePublishOptions({ preferredVideoCodec: 'vp9' }); + + const sourceAudio = LocalAudioTrack.opus(); + const sourceVideo = LocalVideoTrack.vp9({ targetBitrateBps: 600_000 }); + const agentAudio = LocalAudioTrack.opus(); + const agentVideo = LocalVideoTrack.vp9({ targetBitrateBps: 600_000 }); + + const agentAudioIn = { frames: 0 }; + const agentVideoIn = { frames: 0, luma: [] as number[] }; + + const transformAudio = createRobotVoice(); + const transformVideo = createNeonTimeSlice(); + + // The agent runs the same transforms as the runnable Neon example. + agentCall.on('remoteTrack', (track: RemoteTrack) => { + void (async () => { + if (track.type === 'audio') { + for (;;) { + const frame = await track.nextPcm(); + if (!frame) break; + agentAudioIn.frames += 1; + const output = transformAudio(frame); + await agentAudio.writePcm(output.data, { + sampleRate: output.sampleRate, + channels: output.channels, + }); + } + } else if (track.type === 'video') { + for (;;) { + const frame = await track.nextVideoFrame(); + if (!frame) break; + agentVideoIn.frames += 1; + agentVideoIn.luma.push(meanLuma(frame.data)); + const output = transformVideo(frame); + await agentVideo.writeI420(output.data, { + width: output.width, + height: output.height, + durationMs: 33, + }); + } + } + })(); + }); + + // The publisher verifies the agent's response. + const back = { + audio: 0, + video: 0, + peakRms: 0, + minLuma: 255, + maxLuma: 0, + responders: new Set(), + }; + publisherCall.on('remoteTrack', (track: RemoteTrack) => { + void (async () => { + back.responders.add(track.userId); + if (track.type === 'audio') { + for (;;) { + const frame = await track.nextPcm(); + if (!frame) break; + back.audio += 1; + back.peakRms = Math.max(back.peakRms, rms(frame.data)); + } + } else if (track.type === 'video') { + for (;;) { + const frame = await track.nextVideoFrame(); + if (!frame) break; + back.video += 1; + const lumaSize = frame.width * frame.height; + for (let index = 0; index < lumaSize; index += 97) { + back.minLuma = Math.min(back.minLuma, frame.data[index]); + back.maxLuma = Math.max(back.maxLuma, frame.data[index]); + } + } + } + })(); + }); + + await publisherCall.join({ userId: publisherId }); + await agentCall.join({ userId: agentId }); + + expect(publisherCall.state.callingState).toBe('joined'); + expect(agentCall.state.callingState).toBe('joined'); + expect(publisherCall.state.sessionId).toBeTruthy(); + + const subscription = { audio: true, video: true } as const; + await agentCall.updateSubscriptions(subscription); + await publisherCall.updateSubscriptions(subscription); + + await publisherCall.publishAudio(sourceAudio); + await publisherCall.publishVideo(sourceVideo); + await agentCall.publishAudio(agentAudio); + await agentCall.publishVideo(agentVideo); + + // Feed media at wall-clock rate: writes queue, so the producer paces. + const started = Date.now(); + let audioIndex = 0; + let videoIndex = 0; + while (Date.now() < started + MEDIA_DURATION_MS) { + await sourceAudio.writePcm(toneFrame(audioIndex++), { + sampleRate: SAMPLE_RATE, + channels: 1, + }); + if (Date.now() - started >= videoIndex * 33) { + await sourceVideo.writeI420(videoFrame(SOURCE_LUMA), { + width: WIDTH, + height: HEIGHT, + durationMs: 33, + }); + videoIndex += 1; + } + await sleep(started + audioIndex * 20 - Date.now()); + } + + // Each participant sees the other. + expect( + publisherCall.state.participants.map((p) => p.userId).sort(), + ).toEqual([agentId, publisherId].sort()); + expect(agentCall.state.remoteParticipants.map((p) => p.userId)).toContain( + publisherId, + ); + + // The agent received both kinds of media. + expect(agentAudioIn.frames).toBeGreaterThan(100); + expect(agentVideoIn.frames).toBeGreaterThan(20); + expect(agentCall.state.remoteTracks.map((t) => t.type).sort()).toEqual([ + 'audio', + 'video', + ]); + + // I420 round-trips exactly for a flat frame, so the source luma is intact. + const averageLuma = + agentVideoIn.luma.reduce((sum, value) => sum + value, 0) / + agentVideoIn.luma.length; + expect(averageLuma).toBeGreaterThan(SOURCE_LUMA - 12); + expect(averageLuma).toBeLessThan(SOURCE_LUMA + 12); + + // The publisher heard and saw the agent's response. + expect(back.responders).toContain(agentId); + expect(back.audio).toBeGreaterThan(100); + expect(back.video).toBeGreaterThan(20); + + // The transforms are observable after both codec round trips. + const sourceRms = SOURCE_AMPLITUDE / Math.SQRT2; + expect(back.peakRms).toBeGreaterThan(sourceRms * 0.2); + expect(back.peakRms).toBeLessThan(sourceRms * 1.05); + expect(back.minLuma).toBeLessThan(60); + expect(back.maxLuma).toBeGreaterThan(180); + + // Leaving releases every pending reader and reaches the terminal state. + await agentCall.leave(); + await publisherCall.leave(); + expect(agentCall.state.callingState).toBe('left'); + expect(publisherCall.state.callingState).toBe('left'); + expect(agentCall.state.remoteTracks).toHaveLength(0); + }, + ); +}); diff --git a/__tests__/rtc/call-lifecycle.test.ts b/__tests__/rtc/call-lifecycle.test.ts new file mode 100644 index 00000000..c6c666f0 --- /dev/null +++ b/__tests__/rtc/call-lifecycle.test.ts @@ -0,0 +1,277 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { RtcError, RtcIllegalStateError } from '../../src/rtc/errors'; +import { LocalAudioTrack } from '../../src/rtc/tracks'; +import type { RtcCallEvent } from '../../src/rtc/types'; +import { + canRunLive, + createLiveClient, + LiveCallRegistry, + uniqueId, + waitFor, +} from './live'; + +/* + * Join, state, and event behaviour against a real SFU. These exercise the + * protocol, so they are integration tests rather than mocked ones. + */ +describe.runIf(canRunLive)('RTC call lifecycle (live)', () => { + const registry = new LiveCallRegistry(); + afterEach(() => registry.cleanup()); + + /** A fresh call plus the user that owns it. */ + const setUpCall = async () => { + const client = createLiveClient(); + const userId = uniqueId('user'); + await client.upsertUsers([{ id: userId, name: 'Live Test' }]); + + const call = client.video.call('default', uniqueId('call')); + await call.create({ data: { created_by_id: userId } }); + registry.track(call, { created: true }); + return { client, call, userId }; + }; + + it('starts idle and reaches joined with a session id', async () => { + const { call, userId } = await setUpCall(); + expect(call.state.callingState).toBe('idle'); + expect(call.state.sessionId).toBeUndefined(); + + await call.join({ userId }); + + expect(call.state.callingState).toBe('joined'); + expect(call.state.sessionId).toBeTruthy(); + expect(call.state.localParticipant?.userId).toBe(userId); + expect(call.state.remoteParticipants).toHaveLength(0); + expect(call.state.ownCapabilities.length).toBeGreaterThan(0); + }); + + it('leaves cleanly and reports the terminal state', async () => { + const { call, userId } = await setUpCall(); + await call.join({ userId }); + + await call.leave(); + + expect(call.state.callingState).toBe('left'); + expect(call.state.remoteTracks).toHaveLength(0); + }); + + it('supports join, leave, and rejoin on the same call handle', async () => { + const { call, userId } = await setUpCall(); + + await call.join({ userId }); + const firstSession = call.state.sessionId; + await call.leave(); + + await call.join({ userId }); + const secondSession = call.state.sessionId; + + expect(call.state.callingState).toBe('joined'); + expect(secondSession).toBeTruthy(); + // A rejoin is a new SFU session, not a resumed one. + expect(secondSession).not.toBe(firstSession); + }); + + it('survives repeated join/leave cycles', async () => { + const { call, userId } = await setUpCall(); + + for (let cycle = 0; cycle < 3; cycle += 1) { + await call.join({ userId }); + expect(call.state.callingState).toBe('joined'); + await call.leave(); + expect(call.state.callingState).toBe('left'); + } + }); + + it('rejects a concurrent join and a duplicate join', async () => { + const { call, userId } = await setUpCall(); + + const join = call.join({ userId }); + await expect(call.join({ userId })).rejects.toBeInstanceOf( + RtcIllegalStateError, + ); + + await join; + await expect(call.join({ userId })).rejects.toBeInstanceOf( + RtcIllegalStateError, + ); + }); + + it('rejects an empty userId before touching the network', async () => { + const { call } = await setUpCall(); + await expect(call.join({ userId: '' })).rejects.toBeInstanceOf( + RtcIllegalStateError, + ); + expect(call.state.callingState).toBe('idle'); + }); + + it('returns to idle when the SFU rejects the join', async () => { + const client = createLiveClient(); + const userId = uniqueId('user'); + await client.upsertUsers([{ id: userId }]); + + // Never created, and create is not requested: the coordinator refuses. + const missing = client.video.call('default', uniqueId('absent')); + + let error: unknown; + try { + await missing.join({ userId }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(RtcError); + expect(missing.state.callingState).toBe('idle'); + }); + + it('leaving without joining is a no-op that reaches the left state', async () => { + const { call } = await setUpCall(); + await call.leave(); + expect(call.state.callingState).toBe('left'); + }); + + it('requires an active call for participant operations', async () => { + const { call } = await setUpCall(); + + await expect( + call.publishAudio(LocalAudioTrack.opus()), + ).rejects.toBeInstanceOf(RtcIllegalStateError); + await expect(call.getStats()).rejects.toBeInstanceOf(RtcIllegalStateError); + await expect(call.setIncomingVideoEnabled(true)).rejects.toBeInstanceOf( + RtcIllegalStateError, + ); + }); + + it('reports stats once connected', async () => { + const { call, userId } = await setUpCall(); + await call.join({ userId }); + + const stats = await call.getStats(); + + expect(stats).toBeDefined(); + expect(stats!.droppedRemoteTracks).toBe(0); + }); + + it('sees another participant join and leave', async () => { + const { client, call, userId } = await setUpCall(); + const peerId = uniqueId('peer'); + await client.upsertUsers([{ id: peerId }]); + const peerCall = registry.track(client.video.call('default', call.id)); + + await call.join({ userId }); + await peerCall.join({ userId: peerId }); + + await waitFor( + () => call.state.remoteParticipants.some((p) => p.userId === peerId), + { label: 'peer to appear in state' }, + ); + // Each side sees the other. + expect(peerCall.state.remoteParticipants.map((p) => p.userId)).toContain( + userId, + ); + + // participantCount is the SFU's own periodic total and lags the roster by + // a few seconds, so it is waited on rather than read immediately. + await waitFor(() => call.state.participantCount >= 2, { + label: 'SFU participant count to catch up', + }); + + await peerCall.leave(); + await waitFor( + () => call.state.remoteParticipants.every((p) => p.userId !== peerId), + { label: 'peer to disappear from state' }, + ); + }); + + it('updates state before the event handler runs', async () => { + const { client, call, userId } = await setUpCall(); + const peerId = uniqueId('peer'); + await client.upsertUsers([{ id: peerId }]); + const peerCall = registry.track(client.video.call('default', call.id)); + + await call.join({ userId }); + + // Captured inside the handler: state must already reflect the event. + const observed: string[][] = []; + call.on('participantJoined', () => { + observed.push(call.state.participants.map((p) => p.userId)); + }); + + await peerCall.join({ userId: peerId }); + await waitFor(() => observed.length > 0, { label: 'participantJoined' }); + + expect(observed[0]).toContain(peerId); + }); + + it('delivers events to typed and wildcard listeners and stops after unsubscribe', async () => { + const { client, call, userId } = await setUpCall(); + const peerId = uniqueId('peer'); + await client.upsertUsers([{ id: peerId }]); + const peerCall = registry.track(client.video.call('default', call.id)); + + const typed: RtcCallEvent[] = []; + const all: unknown[] = []; + const unsubscribe = call.on('participantJoined', (e) => void typed.push(e)); + call.on('all', (e) => void all.push(e)); + + await call.join({ userId }); + await peerCall.join({ userId: peerId }); + await waitFor(() => typed.length > 0, { label: 'participantJoined' }); + + expect(all.length).toBeGreaterThan(0); + + const seenBefore = typed.length; + unsubscribe(); + await peerCall.leave(); + await peerCall.join({ userId: peerId }); + await waitFor( + () => call.state.remoteParticipants.some((p) => p.userId === peerId), + { label: 'peer to rejoin' }, + ); + + // The unsubscribed handler stops receiving; the wildcard keeps going. + expect(typed).toHaveLength(seenBefore); + }); + + it('emits callingStateChanged as the call progresses', async () => { + const { call, userId } = await setUpCall(); + const states: string[] = []; + call.on( + 'callingStateChanged', + (event) => void states.push(String(event.callingState)), + ); + + await call.join({ userId }); + await waitFor(() => states.includes('joined'), { label: 'joined state' }); + + await call.leave(); + await waitFor(() => states.includes('left'), { label: 'left state' }); + }); + + it('ignores events from a previous join generation', async () => { + const { client, call, userId } = await setUpCall(); + const peerId = uniqueId('peer'); + await client.upsertUsers([{ id: peerId }]); + const peerCall = registry.track(client.video.call('default', call.id)); + + await call.join({ userId }); + await call.leave(); + await call.join({ userId }); + + const afterRejoin: unknown[] = []; + call.on('all', (event) => void afterRejoin.push(event)); + + // Activity on the live generation still lands; nothing from the old one + // can resurrect, and state stays consistent with the current session. + await peerCall.join({ userId: peerId }); + await waitFor( + () => call.state.remoteParticipants.some((p) => p.userId === peerId), + { label: 'peer visible after rejoin' }, + ); + + expect(call.state.callingState).toBe('joined'); + expect(call.state.sessionId).toBeTruthy(); + expect( + call.state.participants.filter((p) => p.userId === userId), + ).toHaveLength(1); + }); +}); diff --git a/__tests__/rtc/errors.test.ts b/__tests__/rtc/errors.test.ts new file mode 100644 index 00000000..c21fa158 --- /dev/null +++ b/__tests__/rtc/errors.test.ts @@ -0,0 +1,185 @@ +import 'dotenv/config'; +import { describe, expect, it } from 'vitest'; + +import { + RtcClosedError, + RtcConnectionError, + RtcError, + RtcIllegalStateError, + RtcNativeUnavailableError, + RtcNativeVersionMismatchError, + RtcNegotiationError, + RtcPermissionDeniedError, + RtcQueueOverflowError, + RtcSizeLimitError, + RtcTimeoutError, + RtcUnsupportedPlatformError, + toRtcError, +} from '../../src/rtc/errors'; +import { LocalAudioTrack, LocalVideoTrack } from '../../src/rtc/tracks'; +import { canLoadNative } from './live'; + +/* + * Error decoding is pure local logic over an Error object, so it is unit + * tested. The construction cases below use the real addon — its validation is + * exactly what we want to assert, and faking it would prove nothing. + */ +const encoded = ( + code: string, + message: string, + details: Record = {}, +) => new Error(JSON.stringify({ code, message, details })); + +describe('RTC error decoding', () => { + it('decodes a structured native error into its code and details', () => { + const error = toRtcError( + encoded('RTC_PERMISSION_DENIED', 'missing send-audio', { + capability: 'send-audio', + }), + ); + + expect(error).toBeInstanceOf(RtcError); + expect(error).toBeInstanceOf(RtcPermissionDeniedError); + expect(error.code).toBe('RTC_PERMISSION_DENIED'); + expect(error.message).toBe('missing send-audio'); + expect(error.details).toEqual({ capability: 'send-audio' }); + }); + + it('maps an illegal-state code to its dedicated class', () => { + expect( + toRtcError(encoded('RTC_ILLEGAL_STATE', 'already joined')), + ).toBeInstanceOf(RtcIllegalStateError); + }); + + it('maps queue overflow and size-limit codes', () => { + expect(toRtcError(encoded('RTC_QUEUE_OVERFLOW', 'full'))).toBeInstanceOf( + RtcQueueOverflowError, + ); + expect(toRtcError(encoded('RTC_SIZE_LIMIT', 'too big'))).toBeInstanceOf( + RtcSizeLimitError, + ); + }); + + it('maps timeout, connection, negotiation, and closed terminal errors', () => { + expect(toRtcError(encoded('RTC_TIMEOUT', 'timed out'))).toBeInstanceOf( + RtcTimeoutError, + ); + expect( + toRtcError(encoded('RTC_CONNECTION', 'connection lost')), + ).toBeInstanceOf(RtcConnectionError); + expect( + toRtcError(encoded('RTC_NEGOTIATION', 'negotiation failed')), + ).toBeInstanceOf(RtcNegotiationError); + expect(toRtcError(encoded('RTC_CLOSED', 'call left'))).toBeInstanceOf( + RtcClosedError, + ); + }); + + it('maps encoded loader failures to their dedicated classes', () => { + expect( + toRtcError(encoded('RTC_NATIVE_UNAVAILABLE', 'missing addon')), + ).toBeInstanceOf(RtcNativeUnavailableError); + expect( + toRtcError(encoded('RTC_NATIVE_VERSION_MISMATCH', 'wrong version')), + ).toBeInstanceOf(RtcNativeVersionMismatchError); + expect( + toRtcError(encoded('RTC_UNSUPPORTED_PLATFORM', 'windows')), + ).toBeInstanceOf(RtcUnsupportedPlatformError); + }); + + it('falls back to RTC_UNKNOWN for an unrecognized code', () => { + expect(toRtcError(encoded('NOT_A_REAL_CODE', 'hm')).code).toBe( + 'RTC_UNKNOWN', + ); + }); + + it('preserves a plain native error message and keeps the cause', () => { + const cause = new Error('libvpx exploded'); + const error = toRtcError(cause); + + expect(error.code).toBe('RTC_UNKNOWN'); + expect(error.message).toBe('libvpx exploded'); + expect(error.cause).toBe(cause); + }); + + it('tolerates a message that only looks like JSON', () => { + expect(toRtcError(new Error('{not json')).message).toBe('{not json'); + }); + + it('passes an existing RtcError through unchanged', () => { + const original = new RtcIllegalStateError('nope'); + expect(toRtcError(original)).toBe(original); + }); +}); + +describe.runIf(canLoadNative)('local track construction (real addon)', () => { + it('builds every supported codec without a system libvpx', () => { + expect(LocalAudioTrack.opus()).toBeInstanceOf(LocalAudioTrack); + expect(LocalVideoTrack.vp8().codec).toBe('vp8'); + expect(LocalVideoTrack.vp9().codec).toBe('vp9'); + expect(LocalVideoTrack.h264().codec).toBe('h264'); + }); + + it('accepts bitrate and layering options', () => { + expect( + LocalVideoTrack.vp9({ + targetBitrateBps: 600_000, + layering: { + mode: 'server-managed', + maxSpatialLayers: 3, + maxTemporalLayers: 3, + }, + }).codec, + ).toBe('vp9'); + expect(LocalVideoTrack.vp8({ layering: { mode: 'single' } }).codec).toBe( + 'vp8', + ); + }); + + /* + * Native validation must surface as a typed RtcError, not as a raw napi + * GenericFailure carrying JSON in its message. + */ + it('reports invalid layering as a typed RTC error', () => { + let error: unknown; + try { + LocalVideoTrack.vp8({ + layering: { mode: 'server-managed', maxSpatialLayers: 9 }, + }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(RtcError); + expect((error as RtcError).code).toBe('RTC_MEDIA'); + expect((error as RtcError).message).toContain('maxSpatialLayers'); + expect((error as RtcError).message).not.toContain('{'); + }); + + it('rejects a PCM buffer that is not whole int16 samples', async () => { + const track = LocalAudioTrack.opus(); + let error: unknown; + try { + await track.writePcm(Buffer.from([1, 2, 3]), { + sampleRate: 48_000, + channels: 1, + }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(RtcError); + expect((error as RtcError).code).toBe('RTC_MEDIA'); + }); + + it('rejects a zero sample rate and a non-positive duration', async () => { + const audio = LocalAudioTrack.opus(); + await expect( + audio.writePcm(Buffer.alloc(2), { sampleRate: 0, channels: 1 }), + ).rejects.toBeInstanceOf(RtcError); + + await expect( + audio.writeEncoded(Buffer.alloc(4), { durationMs: 0 }), + ).rejects.toBeInstanceOf(RtcError); + }); +}); diff --git a/__tests__/rtc/events.unit.test.ts b/__tests__/rtc/events.unit.test.ts new file mode 100644 index 00000000..0dbaef71 --- /dev/null +++ b/__tests__/rtc/events.unit.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + parseRtcCallEvent, + parseRtcCallState, + parseRtcStats, +} from '../../src/rtc/contracts'; +import { RtcEventDispatcher } from '../../src/rtc/events'; +import type { RtcErrorEvent, RtcQueueOverflowEvent } from '../../src/rtc/types'; + +describe('RTC event dispatch', () => { + it('isolates listener failures from peers, wildcards, and later events', () => { + const dispatcher = new RtcEventDispatcher(); + const healthy = vi.fn(); + const wildcard = vi.fn(); + const errors: RtcErrorEvent[] = []; + dispatcher.on('participantJoined', () => { + throw new Error('user listener failed'); + }); + dispatcher.on('participantJoined', healthy); + dispatcher.on('all', wildcard); + dispatcher.on('error', (event) => { + errors.push(event as RtcErrorEvent); + }); + + const event = { type: 'participantJoined' }; + expect(() => dispatcher.dispatch(event.type, event)).not.toThrow(); + expect(() => dispatcher.dispatch(event.type, event)).not.toThrow(); + + expect(healthy).toHaveBeenCalledTimes(2); + expect(wildcard).toHaveBeenCalledTimes(4); + expect(errors).toHaveLength(2); + expect(errors[0]).toMatchObject({ + type: 'error', + sourceEventType: 'participantJoined', + error: { code: 'RTC_UNKNOWN', message: 'user listener failed' }, + }); + }); + + it('does not recurse when an error listener throws', () => { + const dispatcher = new RtcEventDispatcher(); + const healthyErrorListener = vi.fn(); + dispatcher.on('error', () => { + throw new Error('broken error listener'); + }); + dispatcher.on('error', healthyErrorListener); + + expect(() => + dispatcher.dispatch('error', { + type: 'error', + error: new Error('native failure'), + }), + ).not.toThrow(); + expect(healthyErrorListener).toHaveBeenCalledTimes(1); + }); + + it('isolates rejected async listeners and reports their errors', async () => { + const dispatcher = new RtcEventDispatcher(); + const healthy = vi.fn(); + const errors: RtcErrorEvent[] = []; + dispatcher.on('participantJoined', () => + Promise.reject(new Error('async listener failed')), + ); + dispatcher.on('participantJoined', healthy); + dispatcher.on('error', (event) => { + errors.push(event as RtcErrorEvent); + }); + + dispatcher.dispatch('participantJoined', { type: 'participantJoined' }); + + expect(healthy).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(errors).toHaveLength(1)); + expect(errors[0]).toMatchObject({ + type: 'error', + sourceEventType: 'participantJoined', + error: { code: 'RTC_UNKNOWN', message: 'async listener failed' }, + }); + }); +}); + +describe('RTC JSON contracts', () => { + it('preserves the camel-case typed queue overflow contract', () => { + const event: RtcQueueOverflowEvent = parseRtcCallEvent( + JSON.stringify({ + type: 'queueOverflow', + queue: 'events', + dropped: 2, + totalDropped: 5, + }), + ) as RtcQueueOverflowEvent; + + expect(event).toEqual({ + type: 'queueOverflow', + queue: 'events', + dropped: 2, + totalDropped: 5, + }); + expect( + parseRtcStats( + JSON.stringify({ + publisher: [], + subscriber: [], + droppedRemoteTracks: 4, + }), + ).droppedRemoteTracks, + ).toBe(4); + }); + + it('accepts recovery and terminal states from the native state machine', () => { + for (const callingState of [ + 'reconnecting', + 'migrating', + 'reconnecting-failed', + 'left', + ] as const) { + const state = parseRtcCallState( + JSON.stringify({ + callingState, + participants: [], + participantCount: 0, + anonymousParticipantCount: 0, + pins: [], + e2eeEnabled: false, + ownCapabilities: [], + }), + ); + expect(state.callingState).toBe(callingState); + expect( + parseRtcCallEvent( + JSON.stringify({ type: 'callingStateChanged', callingState }), + ), + ).toEqual({ type: 'callingStateChanged', callingState }); + } + }); + + it('rejects malformed overflow, state, and stats payloads', () => { + expect(() => + parseRtcCallEvent( + JSON.stringify({ + type: 'queueOverflow', + queue: 'remote_tracks', + dropped: -1, + total_dropped: 1, + }), + ), + ).toThrow(/invalid queueOverflow event/); + expect(() => + parseRtcCallState( + JSON.stringify({ calling_state: 'joined', participants: [] }), + ), + ).toThrow(/invalid call state/); + expect(() => + parseRtcStats(JSON.stringify({ dropped_remote_tracks: 3 })), + ).toThrow(/invalid stats/); + }); +}); diff --git a/__tests__/rtc/lifecycle.unit.test.ts b/__tests__/rtc/lifecycle.unit.test.ts new file mode 100644 index 00000000..b4c55ab7 --- /dev/null +++ b/__tests__/rtc/lifecycle.unit.test.ts @@ -0,0 +1,416 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { RtcCallLifecycle } from '../../src/rtc/callLifecycle'; +import { RtcEventDispatcher } from '../../src/rtc/events'; +import type { NativeCall, NativeRemoteTrack } from '../../src/rtc/native'; +import { StreamCallState } from '../../src/rtc/state'; +import type { RtcCallEvent } from '../../src/rtc/types'; +import { + callStateJson, + deferred, + nativeCallFixture, + PullQueue, + remoteTrackFixture, +} from './test-helpers'; + +const encodedError = (code: string, message: string) => + new Error(JSON.stringify({ code, message })); + +const lifecycleFixture = ({ + calls, + prepare = () => undefined, + configure = () => Promise.resolve(), +}: { + calls: NativeCall[]; + prepare?: () => void; + configure?: (native: NativeCall) => Promise; +}) => { + const state = new StreamCallState(); + const dispatcher = new RtcEventDispatcher(); + const createNativeCall = vi.fn(() => { + const native = calls.shift(); + if (!native) throw new Error('no native call fixture'); + return native; + }); + const lifecycle = new RtcCallLifecycle({ + state, + createNativeCall, + prepareNativeCall: prepare, + configureJoinedCall: configure, + emit: dispatcher.dispatch, + }); + return { createNativeCall, dispatcher, lifecycle, state }; +}; + +describe('RTC call lifecycle', () => { + it('owns one in-flight join and one concurrent leave teardown', async () => { + const joinGate = deferred(); + const leaveGate = deferred(); + const leave = vi.fn(() => { + joinGate.reject(encodedError('RTC_CLOSED', 'left during join')); + return leaveGate.promise; + }); + const native = nativeCallFixture({ + join: vi.fn(() => joinGate.promise), + leave, + }); + const { lifecycle, state } = lifecycleFixture({ calls: [native] }); + + const joinResult = lifecycle + .join({ userId: 'agent' }) + .catch((error: unknown) => error); + await expect(lifecycle.join({ userId: 'agent' })).rejects.toMatchObject({ + code: 'RTC_ILLEGAL_STATE', + }); + + const firstLeave = lifecycle.leave(); + const secondLeave = lifecycle.leave(); + expect(secondLeave).toBe(firstLeave); + expect(leave).toHaveBeenCalledTimes(1); + + leaveGate.resolve(); + await firstLeave; + await expect(joinResult).resolves.toMatchObject({ code: 'RTC_CLOSED' }); + expect(state.callingState).toBe('left'); + }); + + it('tears down exactly once after a native join failure', async () => { + const order: string[] = []; + const join = vi.fn(() => { + order.push('join'); + return Promise.reject(encodedError('RTC_JOIN', 'join failed')); + }); + const leave = vi.fn(() => { + order.push('leave'); + return Promise.resolve(); + }); + const native = nativeCallFixture({ + join, + leave, + }); + const { lifecycle, state } = lifecycleFixture({ calls: [native] }); + + await expect(lifecycle.join({ userId: 'agent' })).rejects.toMatchObject({ + code: 'RTC_JOIN', + }); + + expect(leave).toHaveBeenCalledTimes(1); + expect(order).toEqual(['join', 'leave']); + expect(state.callingState).toBe('idle'); + }); + + it('preserves both the join error and a cleanup failure', async () => { + const native = nativeCallFixture({ + join: vi.fn(() => + Promise.reject(encodedError('RTC_JOIN', 'join failed')), + ), + leave: vi.fn(() => + Promise.reject(encodedError('RTC_CLOSED', 'cleanup failed')), + ), + }); + const { lifecycle, state } = lifecycleFixture({ calls: [native] }); + + await expect(lifecycle.join({ userId: 'agent' })).rejects.toMatchObject({ + code: 'RTC_JOIN', + message: 'join failed; native cleanup failed: cleanup failed', + details: { + cleanupError: { code: 'RTC_CLOSED', message: 'cleanup failed' }, + }, + }); + expect(state.callingState).toBe('idle'); + }); + + it('tears down when pre-join or post-join setup fails', async () => { + const beforeNativeJoin = vi.fn(() => Promise.resolve()); + const beforeLeave = vi.fn(() => Promise.resolve()); + const beforeJoin = nativeCallFixture({ + join: beforeNativeJoin, + leave: beforeLeave, + }); + const before = lifecycleFixture({ + calls: [beforeJoin], + prepare: () => { + throw new Error('publish options failed'); + }, + }); + + await expect(before.lifecycle.join({ userId: 'agent' })).rejects.toThrow( + 'publish options failed', + ); + expect(beforeNativeJoin).not.toHaveBeenCalled(); + expect(beforeLeave).toHaveBeenCalledTimes(1); + expect(before.state.callingState).toBe('idle'); + + const order: string[] = []; + const afterLeave = vi.fn(() => { + order.push('leave'); + return Promise.resolve(); + }); + const afterJoin = nativeCallFixture({ + join: vi.fn(() => { + order.push('join'); + return Promise.resolve(); + }), + stateJson: vi.fn(() => { + order.push('state'); + return Promise.resolve(callStateJson()); + }), + leave: afterLeave, + }); + const after = lifecycleFixture({ + calls: [afterJoin], + configure: () => { + order.push('configure'); + return Promise.reject(new Error('subscription setup failed')); + }, + }); + + await expect(after.lifecycle.join({ userId: 'agent' })).rejects.toThrow( + 'subscription setup failed', + ); + expect(afterLeave).toHaveBeenCalledTimes(1); + expect(order).toEqual(['join', 'state', 'configure', 'leave']); + expect(after.state.callingState).toBe('idle'); + }); + + it('invalidates stale event and track generations before leave settles', async () => { + const oldEvents = new PullQueue(); + const oldTracks = new PullQueue(); + const firstStateJson = vi.fn(() => Promise.resolve(callStateJson())); + const first = nativeCallFixture({ + nextEvent: oldEvents.next, + nextRemoteTrack: oldTracks.next, + stateJson: firstStateJson, + }); + const second = nativeCallFixture(); + const { dispatcher, lifecycle, state } = lifecycleFixture({ + calls: [first, second], + }); + const delivered: string[] = []; + const remoteTracks = vi.fn(); + dispatcher.on('all', (event) => { + delivered.push((event as RtcCallEvent).type); + }); + dispatcher.on('remoteTrack', remoteTracks); + + await lifecycle.join({ userId: 'agent' }); + await lifecycle.leave(); + oldEvents.push( + JSON.stringify({ + type: 'participantJoined', + userId: 'stale', + sessionId: 'stale-session', + }), + ); + oldTracks.push({} as NativeRemoteTrack); + await lifecycle.join({ userId: 'agent' }); + await Promise.resolve(); + + expect(delivered).not.toContain('participantJoined'); + expect(remoteTracks).not.toHaveBeenCalled(); + expect(firstStateJson).toHaveBeenCalledTimes(1); + expect(state.callingState).toBe('joined'); + }); + + it('stops protocol events as soon as leave begins', async () => { + const events = new PullQueue(); + const leaveGate = deferred(); + const native = nativeCallFixture({ + leave: vi.fn(() => leaveGate.promise), + nextEvent: events.next, + }); + const { dispatcher, lifecycle, state } = lifecycleFixture({ + calls: [native], + }); + const participantJoined = vi.fn(); + dispatcher.on('participantJoined', participantJoined); + + await lifecycle.join({ userId: 'agent' }); + const leave = lifecycle.leave(); + events.push( + JSON.stringify({ + type: 'participantJoined', + userId: 'late-peer', + sessionId: 'late-session', + }), + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(participantJoined).not.toHaveBeenCalled(); + + leaveGate.resolve(); + await leave; + expect(state.callingState).toBe('left'); + }); + + it('allows a fresh join after a failed leave reaches its terminal state', async () => { + const first = nativeCallFixture({ + leave: vi.fn(() => + Promise.reject(encodedError('RTC_CLOSED', 'teardown failed')), + ), + }); + const second = nativeCallFixture(); + const { createNativeCall, lifecycle, state } = lifecycleFixture({ + calls: [first, second], + }); + + await lifecycle.join({ userId: 'agent' }); + await expect(lifecycle.leave()).rejects.toMatchObject({ + code: 'RTC_CLOSED', + message: 'teardown failed', + }); + expect(state.callingState).toBe('left'); + + await expect(lifecycle.join({ userId: 'agent' })).resolves.toBeUndefined(); + expect(createNativeCall).toHaveBeenCalledTimes(2); + expect(state.callingState).toBe('joined'); + await lifecycle.leave(); + }); + + it('updates state before dispatching an event', async () => { + const events = new PullQueue(); + const peer = { + userId: 'peer', + sessionId: 'peer-session', + trackLookupPrefix: 'peer-prefix', + publishedTracks: [], + connectionQuality: 'good', + isSpeaking: false, + isDominantSpeaker: false, + audioLevel: 0, + name: 'Peer', + image: '', + roles: [], + source: 'webrtc', + pausedTracks: [], + }; + const native = nativeCallFixture({ + nextEvent: events.next, + stateJson: vi + .fn() + .mockResolvedValueOnce(callStateJson()) + .mockResolvedValueOnce( + callStateJson('joined', { + participants: [peer], + participantCount: 1, + }), + ), + }); + const { dispatcher, lifecycle, state } = lifecycleFixture({ + calls: [native], + }); + const observed: string[][] = []; + dispatcher.on('participantJoined', () => { + observed.push( + state.participants.map((participant) => participant.userId), + ); + }); + + await lifecycle.join({ userId: 'agent' }); + events.push( + JSON.stringify({ + type: 'participantJoined', + userId: peer.userId, + sessionId: peer.sessionId, + }), + ); + + await vi.waitFor(() => expect(observed).toEqual([['peer']])); + }); + + it('keeps event and track pumps alive after listener failures', async () => { + const events = new PullQueue(); + const tracks = new PullQueue(); + const native = nativeCallFixture({ + nextEvent: events.next, + nextRemoteTrack: tracks.next, + stateJson: vi.fn(() => Promise.resolve(callStateJson())), + }); + const { dispatcher, lifecycle } = lifecycleFixture({ calls: [native] }); + const healthyEvents = vi.fn(); + const overflow = vi.fn(); + const healthyTracks = vi.fn(); + dispatcher.on('participantJoined', () => { + throw new Error('event listener failed'); + }); + dispatcher.on('participantJoined', healthyEvents); + dispatcher.on('queueOverflow', overflow); + dispatcher.on('remoteTrack', () => { + throw new Error('track listener failed'); + }); + dispatcher.on('remoteTrack', healthyTracks); + + await lifecycle.join({ userId: 'agent' }); + events.push( + JSON.stringify({ + type: 'participantJoined', + userId: 'peer', + sessionId: 'peer-session', + }), + ); + events.push( + JSON.stringify({ + type: 'queueOverflow', + queue: 'events', + dropped: 1, + totalDropped: 1, + }), + ); + tracks.push(remoteTrackFixture()); + tracks.push(remoteTrackFixture({ sessionId: 'second-session' })); + + await vi.waitFor(() => { + expect(overflow).toHaveBeenCalledTimes(1); + expect(healthyTracks).toHaveBeenCalledTimes(2); + }); + expect(healthyEvents).toHaveBeenCalledTimes(1); + }); + + it('reports a refresh failure and continues with the next typed event', async () => { + const events = new PullQueue(); + const native = nativeCallFixture({ + nextEvent: events.next, + stateJson: vi + .fn() + .mockResolvedValueOnce(callStateJson()) + .mockRejectedValueOnce(new Error('state unavailable')) + .mockResolvedValueOnce(callStateJson()), + }); + const { dispatcher, lifecycle } = lifecycleFixture({ calls: [native] }); + const delivered: string[] = []; + dispatcher.on('all', (event) => { + delivered.push((event as RtcCallEvent).type); + }); + + await lifecycle.join({ userId: 'agent' }); + events.push( + JSON.stringify({ + type: 'participantJoined', + userId: 'peer', + sessionId: 'peer-session', + }), + ); + events.push( + JSON.stringify({ + type: 'queueOverflow', + queue: 'events', + dropped: 3, + totalDropped: 7, + }), + ); + + await vi.waitFor(() => expect(delivered).toContain('queueOverflow')); + expect(delivered).toContain('error'); + expect(delivered).not.toContain('participantJoined'); + }); + + it('invalidates the generation even when leave has no native handle', async () => { + const { lifecycle, state } = lifecycleFixture({ calls: [] }); + + await expect(lifecycle.leave()).resolves.toBeUndefined(); + await expect(lifecycle.leave()).resolves.toBeUndefined(); + + expect(state.callingState).toBe('left'); + }); +}); diff --git a/__tests__/rtc/live.ts b/__tests__/rtc/live.ts new file mode 100644 index 00000000..0b8c2ccb --- /dev/null +++ b/__tests__/rtc/live.ts @@ -0,0 +1,229 @@ +import 'dotenv/config'; +import { randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; + +import { StreamClient } from '../../src/StreamClient'; +import type { StreamCall } from '../../src/StreamCall'; +import type { LocalAudioTrack, LocalVideoTrack } from '../../src/rtc/tracks'; +import type { PcmFrame, VideoFrame } from '../../src/rtc/types'; + +/* + * Harness for live RTC integration tests. + * + * The RTC path spans JavaScript, a native addon, and Stream's SFU; a mocked + * addon proves only that our own glue is self-consistent. Protocol behaviour is + * therefore tested against a real call, and only genuinely local behaviour + * (native-module resolution, error decoding) is unit tested. + * + * Needs RUN_STREAM_RTC_LIVE=1, STREAM_API_KEY, STREAM_SECRET, and + * STREAM_NODE_RTC_NATIVE_PATH. Credentials alone never opt a developer's + * default test run into network calls or billable RTC resources. + */ +export const liveCredentials = { + apiKey: process.env.STREAM_API_KEY, + secret: process.env.STREAM_SECRET, + nativePath: process.env.STREAM_NODE_RTC_NATIVE_PATH, +}; + +export const canLoadNative = Boolean( + liveCredentials.nativePath && existsSync(liveCredentials.nativePath), +); + +export const canRunLive = Boolean( + process.env.RUN_STREAM_RTC_LIVE === '1' && + liveCredentials.apiKey && + liveCredentials.secret && + liveCredentials.nativePath, +); + +export const AUDIO_SAMPLE_RATE = 48_000; +export const AUDIO_FRAME_SAMPLES = AUDIO_SAMPLE_RATE / 50; // 20ms +export const VIDEO_WIDTH = 320; +export const VIDEO_HEIGHT = 240; +export const VIDEO_LUMA_SIZE = VIDEO_WIDTH * VIDEO_HEIGHT; + +/** + * Codecs are negotiated per track type, not per call: the default call type + * advertises VP9 for camera video but VP8 for screen share. Publishing the + * wrong one fails with RTC_MEDIA naming the codecs actually available. + */ +export const VIDEO_CODEC = 'vp9' as const; +export const SCREEN_SHARE_CODEC = 'vp8' as const; + +export const createLiveClient = () => + new StreamClient(liveCredentials.apiKey!, liveCredentials.secret!, { + timeout: 30_000, + }); + +export const uniqueId = (prefix: string) => + `${prefix}-${randomUUID().slice(0, 12)}`; + +/** A deterministic 440Hz tone at a known amplitude. */ +export const toneFrame = (index: number, amplitude = 12_000) => { + const data = Buffer.alloc(AUDIO_FRAME_SAMPLES * 2); + for (let i = 0; i < AUDIO_FRAME_SAMPLES; i += 1) { + const t = (index * AUDIO_FRAME_SAMPLES + i) / AUDIO_SAMPLE_RATE; + data.writeInt16LE( + Math.round(Math.sin(2 * Math.PI * 440 * t) * amplitude), + i * 2, + ); + } + return data; +}; + +/** A flat I420 frame: constant luma, neutral chroma. */ +export const i420Frame = (luma: number) => { + const data = Buffer.alloc(VIDEO_LUMA_SIZE * 1.5); + data.fill(luma, 0, VIDEO_LUMA_SIZE); + data.fill(128, VIDEO_LUMA_SIZE); + return data; +}; + +export const rms = (buffer: Buffer) => { + let sum = 0; + const count = buffer.length / 2; + for (let i = 0; i < count; i += 1) { + const sample = buffer.readInt16LE(i * 2); + sum += sample * sample; + } + return Math.sqrt(sum / Math.max(1, count)); +}; + +export const meanLuma = (data: Buffer) => { + let sum = 0; + let count = 0; + for (let i = 0; i < VIDEO_LUMA_SIZE; i += 64) { + sum += data[i]; + count += 1; + } + return sum / count; +}; + +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))); + +/** + * Poll until `predicate` holds. Live media takes an unpredictable moment to + * flow, so tests wait on the condition rather than on a fixed sleep. + */ +export const waitFor = async ( + predicate: () => boolean, + { timeoutMs = 20_000, label = 'condition' } = {}, +) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await sleep(100); + } + throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`); +}; + +/** + * Feed media at wall-clock rate in the background until stopped. + * + * Writes queue rather than block, so the producer must pace itself or it + * overruns the bounded native queue. Returns a stop function; callers wait on + * an observable condition instead of a fixed duration. + */ +export const startPump = ({ + audio, + video, + maxDurationMs = 30_000, +}: { + audio?: Pick; + video?: Pick; + maxDurationMs?: number; +}) => { + let running = true; + const stop = () => { + running = false; + }; + + void (async () => { + const started = Date.now(); + let audioIndex = 0; + let videoIndex = 0; + + while (running && Date.now() < started + maxDurationMs) { + try { + if (audio) { + await audio.writePcm(toneFrame(audioIndex), { + sampleRate: AUDIO_SAMPLE_RATE, + channels: 1, + }); + } + audioIndex += 1; + + if (video && Date.now() - started >= videoIndex * 33) { + await video.writeI420(i420Frame(200), { + width: VIDEO_WIDTH, + height: VIDEO_HEIGHT, + durationMs: 33, + }); + videoIndex += 1; + } + } catch { + // The call was left mid-pump; nothing left to feed. + return; + } + await sleep(started + audioIndex * 20 - Date.now()); + } + })(); + + return stop; +}; + +/** Drain a track's PCM into `sink` until the track ends. */ +export const collectPcm = ( + track: { nextPcm: () => Promise }, + sink: { frames: number; peakRms: number }, +) => + void (async () => { + for (;;) { + const frame = await track.nextPcm(); + if (!frame) return; + sink.frames += 1; + sink.peakRms = Math.max(sink.peakRms, rms(frame.data)); + } + })(); + +/** Drain a track's video frames into `sink` until the track ends. */ +export const collectVideo = ( + track: { nextVideoFrame: () => Promise }, + sink: { frames: number; luma: number[] }, +) => + void (async () => { + for (;;) { + const frame = await track.nextVideoFrame(); + if (!frame) return; + sink.frames += 1; + sink.luma.push(meanLuma(frame.data)); + } + })(); + +/** + * Tracks every call joined during a test so they are left and ended on + * success, failure, and timeout alike. + */ +export class LiveCallRegistry { + private readonly joined: StreamCall[] = []; + private readonly created: StreamCall[] = []; + + track(call: StreamCall, { created = false } = {}) { + this.joined.push(call); + if (created) this.created.push(call); + return call; + } + + async cleanup() { + for (const call of this.joined.reverse()) { + await call.leave().catch(() => {}); + } + this.joined.length = 0; + // End the call server-side so the test leaves nothing running. + for (const call of this.created.reverse()) { + await call.end().catch(() => {}); + } + this.created.length = 0; + } +} diff --git a/__tests__/rtc/media-effects.test.ts b/__tests__/rtc/media-effects.test.ts new file mode 100644 index 00000000..1839cb26 --- /dev/null +++ b/__tests__/rtc/media-effects.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { + createNeonTimeSlice, + createRobotVoice, +} from '../../examples/rtc-neon-effects.mjs'; + +const i420 = (width: number, height: number, y = 120) => { + const data = Buffer.alloc((width * height * 3) / 2, 128); + data.fill(y, 0, width * height); + return { data, width, height, rtpTimestamp: 0 }; +}; + +describe('RTC example media effects', () => { + it('creates a stateful robot voice without mutating input PCM', () => { + const input = Buffer.alloc(960 * 2); + for (let index = 0; index < 960; index += 1) { + input.writeInt16LE(12_000, index * 2); + } + const original = Buffer.from(input); + const transform = createRobotVoice(); + + const first = transform({ + data: input, + sampleRate: 48_000, + channels: 1, + durationMs: 20, + }); + const second = transform({ + data: input, + sampleRate: 48_000, + channels: 1, + durationMs: 20, + }); + + expect(input).toEqual(original); + expect(first.data).not.toEqual(input); + expect(second.data).not.toEqual(first.data); + }); + + it('adds a persistent badge, border, scanline, and temporal chroma shift', () => { + const input = i420(320, 240); + const original = Buffer.from(input.data); + const transform = createNeonTimeSlice({ trailFrames: 2 }); + const first = transform(input); + const secondInput = i420(320, 240, 180); + const second = transform(secondInput); + + expect(input.data).toEqual(original); + expect(first.data).not.toEqual(input.data); + expect(second.data).not.toEqual(secondInput.data); + expect(second.data.subarray(320 * 240)).not.toEqual( + secondInput.data.subarray(320 * 240), + ); + }); + + it('rejects malformed media and invalid effect options', () => { + expect(() => createRobotVoice({ tremoloHz: 0 })).toThrow(RangeError); + expect(() => createNeonTimeSlice({ trailFrames: 0 })).toThrow(RangeError); + expect(() => + createNeonTimeSlice()({ + data: Buffer.alloc(10), + width: 3, + height: 2, + rtpTimestamp: 0, + }), + ).toThrow(RangeError); + }); +}); diff --git a/__tests__/rtc/media.test.ts b/__tests__/rtc/media.test.ts new file mode 100644 index 00000000..4ac4695c --- /dev/null +++ b/__tests__/rtc/media.test.ts @@ -0,0 +1,412 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import type { StreamCall } from '../../src/StreamCall'; +import { RtcIllegalStateError } from '../../src/rtc/errors'; +import { LocalAudioTrack, LocalVideoTrack } from '../../src/rtc/tracks'; +import type { RemoteTrack } from '../../src/rtc/tracks'; +import type { RtpPacket } from '../../src/rtc/types'; +import { + AUDIO_SAMPLE_RATE, + canRunLive, + collectPcm, + collectVideo, + createLiveClient, + LiveCallRegistry, + sleep, + startPump, + toneFrame, + uniqueId, + SCREEN_SHARE_CODEC, + VIDEO_CODEC, + waitFor, +} from './live'; + +/* + * Media flow through the SFU: publish, subscribe, decode, forward. Every case + * depends on real codec negotiation and real forwarding, so none of it is + * meaningfully testable against a mock. + */ +describe.runIf(canRunLive)('RTC media (live)', () => { + const registry = new LiveCallRegistry(); + const stops: Array<() => void> = []; + + afterEach(async () => { + for (const stop of stops) stop(); + stops.length = 0; + await registry.cleanup(); + }); + + /** Pump media for the rest of the test, stopped automatically on teardown. */ + const pump = (options: Parameters[0]) => { + const stop = startPump(options); + stops.push(stop); + return stop; + }; + + /** Two participants joined to the same fresh call. */ + const twoParticipants = async () => { + const client = createLiveClient(); + const senderId = uniqueId('sender'); + const receiverId = uniqueId('receiver'); + await client.upsertUsers([{ id: senderId }, { id: receiverId }]); + + const callId = uniqueId('call'); + const sender = client.video.call('default', callId); + const receiver = client.video.call('default', callId); + await sender.create({ data: { created_by_id: senderId } }); + registry.track(sender, { created: true }); + registry.track(receiver); + + sender.updatePublishOptions({ preferredVideoCodec: VIDEO_CODEC }); + receiver.updatePublishOptions({ preferredVideoCodec: VIDEO_CODEC }); + + await sender.join({ userId: senderId }); + await receiver.join({ userId: receiverId }); + return { client, sender, senderId, receiver, receiverId }; + }; + + /** + * Await the first remote track of a given kind. + * + * The SFU only creates an inbound track once media is actually flowing, so + * callers must already be pumping. Rejects rather than hanging so a missing + * track fails with a useful message instead of the global test timeout. + */ + const firstTrack = (call: StreamCall, type: string, timeoutMs = 25_000) => + new Promise((resolve, reject) => { + let unsubscribe = () => {}; + const done = (track: RemoteTrack) => { + clearTimeout(timer); + unsubscribe(); + resolve(track); + }; + const timer = setTimeout(() => { + unsubscribe(); + reject(new Error(`no remote ${type} track within ${timeoutMs}ms`)); + }, timeoutMs); + for (const existing of call.state.remoteTracks) { + if (existing.type === type) return done(existing); + } + unsubscribe = call.on('remoteTrack', (track) => { + if (track.type === type) done(track); + }); + }); + + it('delivers published PCM to a subscriber', async () => { + const { sender, receiver } = await twoParticipants(); + const audio = LocalAudioTrack.opus(); + await sender.publishAudio(audio); + pump({ audio }); + + const track = await firstTrack(receiver, 'audio'); + expect(track.mimeType.toLowerCase()).toContain('opus'); + expect(track.clockRate).toBe(48_000); + + const heard = { frames: 0, peakRms: 0 }; + collectPcm(track, heard); + await waitFor(() => heard.frames > 50, { label: 'decoded PCM frames' }); + + // A 12000-amplitude tone lands near 12000/sqrt(2) after Opus. + expect(heard.peakRms).toBeGreaterThan(3_000); + }); + + it('delivers published I420 video to a subscriber', async () => { + const { sender, receiver } = await twoParticipants(); + const video = LocalVideoTrack[VIDEO_CODEC]({ targetBitrateBps: 600_000 }); + await receiver.updateSubscriptions({ audio: false, video: true }); + await sender.publishVideo(video); + pump({ video }); + + const track = await firstTrack(receiver, 'video'); + const seen = { frames: 0, luma: [] as number[] }; + collectVideo(track, seen); + await waitFor(() => seen.frames > 10, { label: 'decoded video frames' }); + + // A flat I420 frame survives encode/decode essentially intact. + const average = seen.luma.reduce((a, b) => a + b, 0) / seen.luma.length; + expect(average).toBeGreaterThan(188); + expect(average).toBeLessThan(212); + }); + + it('publishes screen share as a track distinct from camera video', async () => { + const { sender, receiver } = await twoParticipants(); + const screen = LocalVideoTrack[SCREEN_SHARE_CODEC]({ + targetBitrateBps: 600_000, + }); + await receiver.updateSubscriptions({ audio: false, screenShare: true }); + await sender.publishScreenShare(screen); + pump({ video: screen }); + + const track = await firstTrack(receiver, 'screenshare'); + expect(track.type).toBe('screenshare'); + + const seen = { frames: 0, luma: [] as number[] }; + collectVideo(track, seen); + await waitFor(() => seen.frames > 5, { label: 'screen share frames' }); + + expect( + receiver.state.remoteTracks.filter((t) => t.type === 'video'), + ).toHaveLength(0); + }); + + it('forwards raw RTP and delivers audible media', async () => { + const { sender, receiver } = await twoParticipants(); + + const source = LocalAudioTrack.opus(); + await sender.publishAudio(source); + pump({ audio: source }); + + const relay = LocalAudioTrack.opus(); + await receiver.publishAudio(relay); + const inbound = await firstTrack(receiver, 'audio'); + const relayedInbound = await firstTrack(sender, 'audio'); + const heard = { frames: 0, peakRms: 0 }; + collectPcm(relayedInbound, heard); + + const seen: RtpPacket[] = []; + let forwarded = 0; + let writeError: unknown; + void (async () => { + for (;;) { + const packet: RtpPacket | undefined = await inbound.readRtp(); + if (!packet) return; + if (seen.length < 10) seen.push(packet); + try { + await relay.writeRtp(packet); + forwarded += 1; + } catch (error) { + writeError ??= error; + return; + } + // Mutating after the write must not corrupt what native received. + packet.payload.fill(0); + } + })(); + + await waitFor(() => forwarded > 30, { label: 'RTP packets forwarded' }); + await waitFor(() => heard.frames > 20, { label: 'forwarded RTP audio' }); + + expect(writeError).toBeUndefined(); + expect(heard.peakRms).toBeGreaterThan(3_000); + for (const packet of seen) { + expect(packet.payloadType).toBeGreaterThan(0); + expect(packet.ssrc).toBeGreaterThan(0); + expect(packet.payload.length).toBeGreaterThan(0); + expect(packet.version).toBe(2); + } + // Sequence numbers advance rather than repeating. + expect(new Set(seen.map((p) => p.sequenceNumber)).size).toBe(seen.length); + }); + + it('drains raw RTP without decoding', async () => { + const { sender, receiver } = await twoParticipants(); + const audio = LocalAudioTrack.opus(); + await sender.publishAudio(audio); + pump({ audio }); + + const track = await firstTrack(receiver, 'audio'); + await expect(track.drainRtp()).resolves.toBe(true); + }); + + it('republishes pre-encoded Opus without decoding in JavaScript', async () => { + const { sender, receiver } = await twoParticipants(); + + const source = LocalAudioTrack.opus(); + await sender.publishAudio(source); + pump({ audio: source }); + + const inbound = await firstTrack(receiver, 'audio'); + const encodedOut = LocalAudioTrack.opus(); + await receiver.publishAudio(encodedOut); + + let republished = 0; + void (async () => { + for (;;) { + const packet = await inbound.readRtp(); + if (!packet) return; + await encodedOut.writeEncoded(packet.payload, { durationMs: 20 }); + republished += 1; + } + })(); + + const heard = { frames: 0, peakRms: 0 }; + collectPcm(await firstTrack(sender, 'audio'), heard); + await waitFor(() => heard.frames > 20, { + label: 'republished encoded audio', + }); + + expect(republished).toBeGreaterThan(20); + }); + + it('refuses to mix decoded and raw reads on one track', async () => { + const { sender, receiver } = await twoParticipants(); + const audio = LocalAudioTrack.opus(); + await sender.publishAudio(audio); + pump({ audio }); + + const track = await firstTrack(receiver, 'audio'); + + const decoded = track.nextPcm(); + await expect(track.readRtp()).rejects.toBeInstanceOf(RtcIllegalStateError); + await decoded; + + const raw = track.readRtp(); + await expect(track.nextPcm()).rejects.toBeInstanceOf(RtcIllegalStateError); + await raw; + }); + + it('releases pending reads when the call is left', async () => { + const { sender, receiver } = await twoParticipants(); + const audio = LocalAudioTrack.opus(); + await sender.publishAudio(audio); + const stop = pump({ audio }); + + const track = await firstTrack(receiver, 'audio'); + await track.nextPcm(); + stop(); + + // A read issued before leaving must not hang. Frames already queued are + // still delivered, so the guarantee is that the stream terminates: keep + // reading until it yields undefined rather than assuming the next one does. + const pending = track.nextPcm(); + await receiver.leave(); + await expect(pending).resolves.toBeDefined(); + + let reads = 0; + let last: unknown = null; + while (reads < 500) { + last = await track.nextPcm(); + reads += 1; + if (last === undefined) break; + } + + expect(last).toBeUndefined(); + expect(receiver.state.remoteTracks).toHaveLength(0); + }); + + it('withholds video until it is subscribed', async () => { + const { sender, receiver } = await twoParticipants(); + const video = LocalVideoTrack[VIDEO_CODEC]({ targetBitrateBps: 600_000 }); + await sender.publishVideo(video); + pump({ video }); + + // The default policy is audio-only, so nothing should arrive. + await sleep(6_000); + expect( + receiver.state.remoteTracks.filter((t) => t.type === 'video'), + ).toHaveLength(0); + + await receiver.updateSubscriptions({ audio: false, video: true }); + const track = await firstTrack(receiver, 'video'); + expect(track.type).toBe('video'); + }); + + it('accepts a keyframe request on a video track', async () => { + const { sender, receiver } = await twoParticipants(); + const video = LocalVideoTrack[VIDEO_CODEC]({ targetBitrateBps: 600_000 }); + await receiver.updateSubscriptions({ audio: false, video: true }); + await sender.publishVideo(video); + pump({ video }); + + const track = await firstTrack(receiver, 'video'); + await expect(track.requestKeyframe()).resolves.toBeUndefined(); + }); + + it('stops publishing, mutes, and unmutes without leaving', async () => { + const { sender } = await twoParticipants(); + const audio = LocalAudioTrack.opus(); + await sender.publishAudio(audio); + + await expect(sender.muteTrack('audio')).resolves.toBeUndefined(); + await expect(sender.unmuteTrack('audio')).resolves.toBeUndefined(); + await expect(sender.stopPublish(audio)).resolves.toBeUndefined(); + + expect(sender.state.callingState).toBe('joined'); + }); + + it('reports queue overflow when a producer outruns the PCM queue', async () => { + const { sender } = await twoParticipants(); + const audio = LocalAudioTrack.opus(); + await sender.publishAudio(audio); + + // Deliberately unpaced: the bounded queue must report the overrun rather + // than dropping silently or growing without limit. + let overflow: unknown; + try { + for (let i = 0; i < 2_000; i += 1) { + await audio.writePcm(toneFrame(i), { + sampleRate: AUDIO_SAMPLE_RATE, + channels: 1, + }); + } + } catch (error) { + overflow = error; + } + + expect(overflow).toBeDefined(); + expect((overflow as { code: string }).code).toBe('RTC_QUEUE_OVERFLOW'); + expect((overflow as { details: object }).details).toHaveProperty( + 'droppedSamples', + ); + }); + + it('rejects a video codec the SFU did not advertise', async () => { + const { sender } = await twoParticipants(); + // The call negotiated VP9; VP8 must fail with a message naming the codecs + // that are actually available. + const mismatched = VIDEO_CODEC === 'vp9' ? 'vp8' : 'vp9'; + const track = LocalVideoTrack[mismatched]({ targetBitrateBps: 300_000 }); + + await expect(sender.publishVideo(track)).rejects.toMatchObject({ + code: 'RTC_MEDIA', + }); + }); + + it('exposes remote track metadata for routing', async () => { + const { sender, senderId, receiver } = await twoParticipants(); + const audio = LocalAudioTrack.opus(); + await sender.publishAudio(audio); + pump({ audio }); + + const track = await firstTrack(receiver, 'audio'); + + expect(track.userId).toBe(senderId); + expect(track.sessionId).toBe(sender.state.sessionId); + expect(track.trackLookupPrefix).toBeTruthy(); + expect(track.ssrc).toBeGreaterThan(0); + expect(track.payloadType).toBeGreaterThan(0); + }); + + it('applies a per-participant resolution preference', async () => { + const { sender, receiver } = await twoParticipants(); + const video = LocalVideoTrack[VIDEO_CODEC]({ targetBitrateBps: 600_000 }); + await receiver.updateSubscriptions({ audio: false, video: true }); + await sender.publishVideo(video); + pump({ video }); + await firstTrack(receiver, 'video'); + + await expect( + receiver.setPreferredIncomingVideoResolution( + { width: 160, height: 120 }, + [sender.state.sessionId!], + ), + ).resolves.toBeUndefined(); + }); + + it('enables and disables incoming video', async () => { + const { sender, receiver } = await twoParticipants(); + const video = LocalVideoTrack[VIDEO_CODEC]({ targetBitrateBps: 600_000 }); + await sender.publishVideo(video); + pump({ video }); + + await receiver.setIncomingVideoEnabled(true); + const track = await firstTrack(receiver, 'video'); + const seen = { frames: 0, luma: [] as number[] }; + collectVideo(track, seen); + await waitFor(() => seen.frames > 0, { label: 'video after enable' }); + + await expect( + receiver.setIncomingVideoEnabled(false), + ).resolves.toBeUndefined(); + }); +}); diff --git a/__tests__/rtc/native-loader.test.ts b/__tests__/rtc/native-loader.test.ts new file mode 100644 index 00000000..16cb1add --- /dev/null +++ b/__tests__/rtc/native-loader.test.ts @@ -0,0 +1,239 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + loadRtcNativeBinding, + resetRtcNativeBindingCache, +} from '../../src/rtc/native'; +import { + RtcNativeUnavailableError, + RtcNativeVersionMismatchError, + RtcUnsupportedPlatformError, +} from '../../src/rtc/errors'; +import { LocalAudioTrack } from '../../src/rtc/tracks'; +import type { RtpPacket } from '../../src/rtc/types'; + +const NATIVE_PATH = 'STREAM_NODE_RTC_NATIVE_PATH'; + +/** + * Write a CommonJS stand-in for the addon. `loadCount` on globalThis proves how + * many times the loader actually pulled it off disk. + */ +const writeFixture = (source: string) => { + const directory = mkdtempSync(join(tmpdir(), 'stream-rtc-')); + const file = join(directory, 'addon.cjs'); + writeFileSync(file, source); + return file; +}; + +const validFixture = (version: number | string = 1) => + writeFixture(` + globalThis.__rtcLoadCount = (globalThis.__rtcLoadCount ?? 0) + 1; + class NativeCall { + join() {} leave() {} stateJson() {} statsJson() {} + nextEvent() {} nextRemoteTrack() {} requestPermissions() {} + setDisconnectionTimeout() {} updatePublishOptions() {} + updateSubscriptions() {} updateSubscriptionTargets() {} + setIncomingVideoEnabled() {} publishAudio() {} publishVideo() {} + publishScreenShare() {} publishScreenShareAudio() {} + stopPublishAudio() {} stopPublishVideo() {} muteTrack() {} + unmuteTrack() {} startNoiseCancellation() {} stopNoiseCancellation() {} + } + class NativeLocalAudioTrack { + static opus() { return new NativeLocalAudioTrack(); } + writePcm(data) { + globalThis.__rtcWriteSnapshot = { + received: data, + copied: Buffer.from(data), + }; + return Promise.resolve(); + } + writeEncoded() {} + writeRtp(packet) { + globalThis.__rtcRtpSnapshot = { + received: packet, + csrc: [...packet.csrc], + payload: Buffer.from(packet.payload), + extensions: packet.extensions.map((extension) => ({ + ...extension, + payload: Buffer.from(extension.payload), + })), + }; + return Promise.resolve(); + } + flush() {} + } + class NativeLocalVideoTrack { + static vp8() { return new NativeLocalVideoTrack(); } + static vp9() { return new NativeLocalVideoTrack(); } + static h264() { return new NativeLocalVideoTrack(); } + writeI420() {} writeEncoded() {} writeRtp() {} + } + class NativeRemoteTrack { + nextPcm() {} nextVideoFrame() {} readRtp() {} + drainRtp() {} requestKeyframe() {} + } + module.exports = { + bindingApiVersion: ${version}, + NativeCall, + NativeStreamClient: class { call() { return new NativeCall(); } }, + NativeLocalAudioTrack, + NativeLocalVideoTrack, + NativeRemoteTrack, + }; + `); + +describe('RTC native loader', () => { + const originalPath = process.env[NATIVE_PATH]; + + beforeEach(() => { + resetRtcNativeBindingCache(); + (globalThis as Record).__rtcLoadCount = 0; + (globalThis as Record).__rtcWriteSnapshot = undefined; + (globalThis as Record).__rtcRtpSnapshot = undefined; + }); + + afterEach(() => { + resetRtcNativeBindingCache(); + if (originalPath === undefined) delete process.env[NATIVE_PATH]; + else process.env[NATIVE_PATH] = originalPath; + }); + + it('loads the addon from an absolute path exactly once', () => { + process.env[NATIVE_PATH] = validFixture(); + + const first = loadRtcNativeBinding(); + const second = loadRtcNativeBinding(); + + expect(second).toBe(first); + expect((globalThis as Record).__rtcLoadCount).toBe(1); + }); + + it('rejects a relative native path', () => { + process.env[NATIVE_PATH] = './addon.node'; + + expect(() => loadRtcNativeBinding()).toThrow(RtcNativeUnavailableError); + expect((globalThis as Record).__rtcLoadCount).toBe(0); + }); + + it('reports an actionable error when the path does not resolve', () => { + process.env[NATIVE_PATH] = join(tmpdir(), 'stream-rtc-missing.node'); + + expect(() => loadRtcNativeBinding()).toThrow(/STREAM_NODE_RTC_NATIVE_PATH/); + }); + + it('rejects an addon missing required exports', () => { + process.env[NATIVE_PATH] = writeFixture( + 'module.exports = { bindingApiVersion: 1 };', + ); + + expect(() => loadRtcNativeBinding()).toThrow(/invalid NativeStreamClient/); + }); + + it('rejects an incompatible binding API version', () => { + process.env[NATIVE_PATH] = validFixture(99); + + let error: unknown; + try { + loadRtcNativeBinding(); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(RtcNativeVersionMismatchError); + expect((error as RtcNativeVersionMismatchError).details).toMatchObject({ + actual: 99, + expected: 1, + }); + }); + + it('accepts a binding that exposes its version as a getter function', () => { + process.env[NATIVE_PATH] = validFixture('() => 1'); + + expect(() => loadRtcNativeBinding()).not.toThrow(); + }); + + it('never loads the addon for SDK import or REST-only usage', async () => { + // An addon that explodes on load: nothing on the REST path may require it. + process.env[NATIVE_PATH] = writeFixture( + 'throw new Error("the addon must not be loaded here");', + ); + + const sdk = await import('../../index'); + const client = new sdk.StreamClient('key', 'secret'); + const restCall = client.video.call('default', 'rest-only'); + + expect(restCall.cid).toBe('default:rest-only'); + expect(restCall.state.callingState).toBe('idle'); + expect(restCall.state.participants).toEqual([]); + expect(client.video.call('default', 'another').cid).toBe('default:another'); + expect((globalThis as Record).__rtcLoadCount).toBe(0); + }); + + it('hands media to the native copy boundary synchronously', async () => { + process.env[NATIVE_PATH] = validFixture(); + const track = LocalAudioTrack.opus(); + const pcm = Buffer.from([1, 2, 3, 4]); + const originalPcm = Buffer.from(pcm); + + const pcmWrite = track.writePcm(pcm, { + sampleRate: 48_000, + channels: 1, + }); + const pcmSnapshot = (globalThis as Record) + .__rtcWriteSnapshot as { received: Buffer; copied: Buffer }; + pcm.fill(0); + await pcmWrite; + + expect(pcmSnapshot.received).toBe(pcm); + expect(pcmSnapshot.copied).toEqual(originalPcm); + + const packet: RtpPacket = { + version: 2, + padding: false, + extension: true, + marker: false, + payloadType: 111, + sequenceNumber: 1, + timestamp: 2, + ssrc: 3, + csrc: [4], + extensionProfile: 0xbede, + extensions: [{ id: 1, payload: Buffer.from([5, 6]) }], + extensionsPadding: 0, + payload: Buffer.from([7, 8]), + }; + const rtpWrite = track.writeRtp(packet); + const rtpSnapshot = (globalThis as Record) + .__rtcRtpSnapshot as { + received: RtpPacket; + csrc: number[]; + payload: Buffer; + extensions: Array<{ id: number; payload: Buffer }>; + }; + packet.csrc[0] = 0; + packet.extensions[0].payload.fill(0); + packet.payload.fill(0); + await rtpWrite; + + expect(rtpSnapshot.received).toBe(packet); + expect(rtpSnapshot.csrc).toEqual([4]); + expect(rtpSnapshot.extensions[0].payload).toEqual(Buffer.from([5, 6])); + expect(rtpSnapshot.payload).toEqual(Buffer.from([7, 8])); + }); + + it('refuses unsupported platforms before touching the filesystem', () => { + process.env[NATIVE_PATH] = validFixture(); + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')!; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + try { + expect(() => loadRtcNativeBinding()).toThrow(RtcUnsupportedPlatformError); + expect((globalThis as Record).__rtcLoadCount).toBe(0); + } finally { + Object.defineProperty(process, 'platform', descriptor); + } + }); +}); diff --git a/__tests__/rtc/permissions.test.ts b/__tests__/rtc/permissions.test.ts new file mode 100644 index 00000000..8c5e88bd --- /dev/null +++ b/__tests__/rtc/permissions.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { LocalAudioTrack } from '../../src/rtc/tracks'; +import { + canRunLive, + createLiveClient, + LiveCallRegistry, + uniqueId, + waitFor, +} from './live'; + +/* + * Capability enforcement is decided by the SFU and the coordinator together, so + * these are integration tests. A mocked addon would only assert that our own + * calls were forwarded, not that a revoked publisher is actually stopped. + * + * Note which field is authoritative: `state.currentGrants` is what the SFU + * enforces and what a revoke updates. `state.ownCapabilities` comes from the + * coordinator and can still list a capability that has been revoked, so it is + * not a reliable gate. + */ +const canPublishAudio = (call: { state: { currentGrants?: unknown } }) => + (call.state.currentGrants as { canPublishAudio?: boolean } | undefined) + ?.canPublishAudio; +describe.runIf(canRunLive)('RTC permissions (live)', () => { + const registry = new LiveCallRegistry(); + afterEach(() => registry.cleanup()); + + const joinedCall = async () => { + const client = createLiveClient(); + const ownerId = uniqueId('owner'); + const userId = uniqueId('member'); + await client.upsertUsers([{ id: ownerId }, { id: userId }]); + + const callId = uniqueId('call'); + const owner = client.video.call('default', callId); + await owner.create({ data: { created_by_id: ownerId } }); + registry.track(owner, { created: true }); + + const call = client.video.call('default', callId); + registry.track(call); + await call.join({ userId }); + return { client, owner, ownerId, call, userId }; + }; + + it('grants publishing capabilities on join', async () => { + const { call } = await joinedCall(); + expect(call.state.ownCapabilities).toContain('send-audio'); + }); + + it('reflects a revoked capability in the SFU grants', async () => { + const { owner, call, userId } = await joinedCall(); + expect(canPublishAudio(call)).not.toBe(false); + + await owner.revokePermissions(userId, ['send-audio']); + + await waitFor(() => canPublishAudio(call) === false, { + label: 'canPublishAudio to go false', + }); + }); + + it('refuses to publish audio without send-audio', async () => { + const { owner, call, userId } = await joinedCall(); + await owner.revokePermissions(userId, ['send-audio']); + await waitFor(() => canPublishAudio(call) === false, { + label: 'canPublishAudio to go false', + }); + + await expect( + call.publishAudio(LocalAudioTrack.opus()), + ).rejects.toMatchObject({ code: 'RTC_PERMISSION_DENIED' }); + }); + + it('allows publishing again after a grant, without rejoining', async () => { + const { owner, call, userId } = await joinedCall(); + const sessionBefore = call.state.sessionId; + + await owner.revokePermissions(userId, ['send-audio']); + await waitFor(() => canPublishAudio(call) === false, { + label: 'canPublishAudio to go false', + }); + + await owner.grantPermissions(userId, ['send-audio']); + await waitFor(() => canPublishAudio(call) === true, { + label: 'canPublishAudio to return', + }); + + await expect( + call.publishAudio(LocalAudioTrack.opus()), + ).resolves.toBeUndefined(); + // The grant took effect on the live session. + expect(call.state.sessionId).toBe(sessionBefore); + expect(call.state.callingState).toBe('joined'); + }); + + it('stops an active publication when the capability is revoked', async () => { + const { owner, call, userId } = await joinedCall(); + await call.publishAudio(LocalAudioTrack.opus()); + + await owner.revokePermissions(userId, ['send-audio']); + await waitFor(() => canPublishAudio(call) === false, { + label: 'canPublishAudio to go false', + }); + + // A further publish attempt is refused while unauthorized. + await expect( + call.publishAudio(LocalAudioTrack.opus()), + ).rejects.toMatchObject({ code: 'RTC_PERMISSION_DENIED' }); + }); + + it('makes a permission request observable to another participant', async () => { + const { client, owner, ownerId, call, userId } = await joinedCall(); + await owner.join({ userId: ownerId }); + + const requests: unknown[] = []; + owner.on('call.permission_request', (event) => void requests.push(event)); + + await owner.revokePermissions(userId, ['send-audio']); + await waitFor(() => canPublishAudio(call) === false, { + label: 'canPublishAudio to go false', + }); + + const response = await call.requestPermissions({ + permissions: ['send-audio'], + }); + expect(response).toBeDefined(); + + await waitFor(() => requests.length > 0, { + label: 'permission request to reach the owner', + timeoutMs: 25_000, + }); + + void client; + }); + + it('keeps updateUserPermissions available as the REST operation', async () => { + const { owner, call, userId } = await joinedCall(); + + await owner.updateUserPermissions({ + user_id: userId, + grant_permissions: ['send-video'], + revoke_permissions: [], + }); + + await waitFor(() => call.state.ownCapabilities.includes('send-video'), { + label: 'send-video to be granted', + }); + }); +}); diff --git a/__tests__/rtc/test-helpers.ts b/__tests__/rtc/test-helpers.ts new file mode 100644 index 00000000..ba7da45c --- /dev/null +++ b/__tests__/rtc/test-helpers.ts @@ -0,0 +1,114 @@ +import { vi } from 'vitest'; + +import type { NativeCall, NativeRemoteTrack } from '../../src/rtc/native'; +import type { + RtcCallStateSnapshot, + RtcCallingState, +} from '../../src/rtc/types'; + +export interface Deferred { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} + +export const deferred = (): Deferred => { + let resolve!: Deferred['resolve']; + let reject!: Deferred['reject']; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +}; + +export class PullQueue { + private readonly buffered: T[] = []; + private readonly readers: Array> = []; + private ended = false; + + next = () => { + const value = this.buffered.shift(); + if (value !== undefined) return Promise.resolve(value); + if (this.ended) return Promise.resolve(undefined); + const reader = deferred(); + this.readers.push(reader); + return reader.promise; + }; + + push = (value: T) => { + const reader = this.readers.shift(); + if (reader) reader.resolve(value); + else this.buffered.push(value); + }; + + end = () => { + this.ended = true; + for (const reader of this.readers.splice(0)) reader.resolve(undefined); + }; +} + +export const callStateJson = ( + callingState: RtcCallingState = 'joined', + overrides: Partial = {}, +) => + JSON.stringify({ + callingState, + participants: [], + participantCount: 0, + anonymousParticipantCount: 0, + pins: [], + e2eeEnabled: false, + ownCapabilities: [], + ...overrides, + } satisfies RtcCallStateSnapshot); + +export const nativeCallFixture = ( + overrides: Partial = {}, +): NativeCall => ({ + join: vi.fn(() => Promise.resolve()), + leave: vi.fn(() => Promise.resolve()), + stateJson: vi.fn(() => Promise.resolve(callStateJson())), + statsJson: vi.fn(() => Promise.resolve(undefined)), + nextEvent: vi.fn(() => new Promise(() => {})), + nextRemoteTrack: vi.fn( + () => new Promise(() => {}), + ), + requestPermissions: vi.fn(() => Promise.resolve('{}')), + setDisconnectionTimeout: vi.fn(), + updatePublishOptions: vi.fn(), + updateSubscriptions: vi.fn(() => Promise.resolve()), + updateSubscriptionTargets: vi.fn(() => Promise.resolve()), + setIncomingVideoEnabled: vi.fn(() => Promise.resolve()), + publishAudio: vi.fn(() => Promise.resolve()), + publishVideo: vi.fn(() => Promise.resolve()), + publishScreenShare: vi.fn(() => Promise.resolve()), + publishScreenShareAudio: vi.fn(() => Promise.resolve()), + stopPublishAudio: vi.fn(() => Promise.resolve()), + stopPublishVideo: vi.fn(() => Promise.resolve()), + muteTrack: vi.fn(() => Promise.resolve()), + unmuteTrack: vi.fn(() => Promise.resolve()), + startNoiseCancellation: vi.fn(() => Promise.resolve()), + stopNoiseCancellation: vi.fn(() => Promise.resolve()), + ...overrides, +}); + +export const remoteTrackFixture = ( + overrides: Partial = {}, +): NativeRemoteTrack => ({ + userId: 'peer', + sessionId: 'peer-session', + trackLookupPrefix: 'peer-prefix', + trackType: 'audio', + mimeType: 'audio/opus', + payloadType: 111, + clockRate: 48_000, + channels: 1, + ssrc: 42, + nextPcm: vi.fn(() => Promise.resolve(undefined)), + nextVideoFrame: vi.fn(() => Promise.resolve(undefined)), + readRtp: vi.fn(() => Promise.resolve(undefined)), + drainRtp: vi.fn(() => Promise.resolve(false)), + requestKeyframe: vi.fn(() => Promise.resolve()), + ...overrides, +}); diff --git a/__tests__/rtc/tracks.unit.test.ts b/__tests__/rtc/tracks.unit.test.ts new file mode 100644 index 00000000..0bf2a594 --- /dev/null +++ b/__tests__/rtc/tracks.unit.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { RtcIllegalStateError } from '../../src/rtc/errors'; +import { RemoteTrack } from '../../src/rtc/tracks'; +import type { PcmFrame, RtpPacket } from '../../src/rtc/types'; +import { PullQueue, remoteTrackFixture } from './test-helpers'; + +const rtpPacket = (): RtpPacket => ({ + version: 2, + padding: false, + extension: false, + marker: false, + payloadType: 111, + sequenceNumber: 1, + timestamp: 2, + ssrc: 3, + csrc: [], + extensionProfile: 0, + extensions: [], + extensionsPadding: 0, + payload: Buffer.from([1, 2, 3]), +}); + +describe('RemoteTrack read ownership', () => { + it('permanently selects decoded mode on the first read', async () => { + const readRtp = vi.fn(() => Promise.resolve(undefined)); + const native = remoteTrackFixture({ + nextPcm: vi.fn(() => + Promise.resolve({ + data: Buffer.alloc(2), + sampleRate: 48_000, + channels: 1, + durationMs: 20, + }), + ), + readRtp, + }); + const track = new RemoteTrack(native); + + await track.nextPcm(); + await expect(track.readRtp()).rejects.toBeInstanceOf(RtcIllegalStateError); + + expect(readRtp).not.toHaveBeenCalled(); + await expect(track.nextPcm()).resolves.toBeDefined(); + }); + + it('permanently selects RTP mode even after the first read ends', async () => { + const nextPcm = vi.fn(() => Promise.resolve(undefined)); + const native = remoteTrackFixture({ + nextPcm, + readRtp: vi.fn(() => Promise.resolve(rtpPacket())), + }); + const track = new RemoteTrack(native); + + await track.readRtp(); + await expect(track.nextPcm()).rejects.toMatchObject({ + code: 'RTC_ILLEGAL_STATE', + details: { selectedMode: 'rtp', requestedMode: 'decoded' }, + }); + + expect(nextPcm).not.toHaveBeenCalled(); + await expect(track.drainRtp()).resolves.toBe(false); + }); + + it('keeps the first mode when its native read fails', async () => { + const track = new RemoteTrack( + remoteTrackFixture({ + nextPcm: vi.fn(() => Promise.reject(new Error('decoder failed'))), + }), + ); + + await expect(track.nextPcm()).rejects.toThrow('decoder failed'); + await expect(track.readRtp()).rejects.toBeInstanceOf(RtcIllegalStateError); + }); + + it('allows a pending read to drain buffered data before ending', async () => { + const reads = new PullQueue(); + const track = new RemoteTrack( + remoteTrackFixture({ + nextPcm: reads.next, + }), + ); + const frame: PcmFrame = { + data: Buffer.from([1, 0]), + sampleRate: 48_000, + channels: 1, + durationMs: 20, + }; + + const buffered = track.nextPcm(); + reads.push(frame); + await expect(buffered).resolves.toBe(frame); + + const terminal = track.nextPcm(); + reads.end(); + await expect(terminal).resolves.toBeUndefined(); + }); +}); diff --git a/package.json b/package.json index 9b58a285..3bf4049c 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "prepack": "yarn build", "test": "vitest", "test:bun": "bun run vitest", + "test:rtc": "vitest run __tests__/rtc", + "test:rtc:live": "vitest run --config vitest.rtc-live.config.mts", + "benchmark:rtc": "node benchmarks/rtc/run.mjs", + "benchmark:rtc:compare": "node benchmarks/rtc/compare.mjs", "start": "vite build --watch", "build": "rm -rf dist && vite build && tsc --emitDeclarationOnly -p tsconfig.json", "generate:open-api": "./generate-openapi.sh", diff --git a/vite.config.mts b/vite.config.mts index cf56164f..9ccf83f8 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -41,6 +41,12 @@ export default defineConfig({ }, testTimeout: 60000, include: ["__tests__/**/*.test.ts"], + exclude: [ + "__tests__/rtc/agent-scenario.test.ts", + "__tests__/rtc/call-lifecycle.test.ts", + "__tests__/rtc/media.test.ts", + "__tests__/rtc/permissions.test.ts", + ], includeSource: ["src/**/*.ts"], retry: 3, }, diff --git a/vitest.rtc-live.config.mts b/vitest.rtc-live.config.mts new file mode 100644 index 00000000..e0c87748 --- /dev/null +++ b/vitest.rtc-live.config.mts @@ -0,0 +1,31 @@ +import { existsSync } from "node:fs"; +import { defineConfig } from "vitest/config"; + +const nativePath = process.env.STREAM_NODE_RTC_NATIVE_PATH; +const missing = [ + ["RUN_STREAM_RTC_LIVE=1", process.env.RUN_STREAM_RTC_LIVE === "1"], + ["STREAM_API_KEY", Boolean(process.env.STREAM_API_KEY)], + ["STREAM_SECRET", Boolean(process.env.STREAM_SECRET)], + ["STREAM_NODE_RTC_NATIVE_PATH", Boolean(nativePath)], + ["an existing native addon", Boolean(nativePath && existsSync(nativePath))], +] + .filter(([, present]) => !present) + .map(([name]) => name); + +if (missing.length > 0) { + throw new Error(`RTC live tests require ${missing.join(", ")}`); +} + +export default defineConfig({ + test: { + hookTimeout: 120000, + include: [ + "__tests__/rtc/agent-scenario.test.ts", + "__tests__/rtc/call-lifecycle.test.ts", + "__tests__/rtc/media.test.ts", + "__tests__/rtc/permissions.test.ts", + ], + retry: 0, + testTimeout: 120000, + }, +}); From 3edb751af9451397a63e9e88c2cc6401fc4de2bf Mon Sep 17 00:00:00 2001 From: "Neevash Ramdial (Nash)" Date: Thu, 27 Aug 2026 17:47:41 -0400 Subject: [PATCH 3/7] docs: add server-side RTC guides Document the preview integration and provide runnable echo and native-effect agent examples. --- docs/server-side-rtc.md | 290 ++++++++++++++++++++++++++++++++ examples/rtc-echo-agent.mjs | 100 +++++++++++ examples/rtc-neon-agent.mjs | 117 +++++++++++++ examples/rtc-neon-effects.d.mts | 22 +++ examples/rtc-neon-effects.mjs | 202 ++++++++++++++++++++++ 5 files changed, 731 insertions(+) create mode 100644 docs/server-side-rtc.md create mode 100644 examples/rtc-echo-agent.mjs create mode 100644 examples/rtc-neon-agent.mjs create mode 100644 examples/rtc-neon-effects.d.mts create mode 100644 examples/rtc-neon-effects.mjs diff --git a/docs/server-side-rtc.md b/docs/server-side-rtc.md new file mode 100644 index 00000000..6cd9d8a3 --- /dev/null +++ b/docs/server-side-rtc.md @@ -0,0 +1,290 @@ +# Server-side RTC (branch-local preview) + +This document covers the `feat/rust-rtc-bindings` prototype: a Node backend that +joins a Stream call as a real participant, receives and manipulates remote +media, and publishes media back. + +**Nothing here is published.** The native addon is built locally from the +`feat/python-rtc-bindings` branch of `stream-video-rust` and loaded through an +environment variable. There is no npm dependency, no release, and no change to +either repository's `main`. + +## 1. Build the Rust addon + +From your `stream-video-rust` checkout, on `feat/python-rtc-bindings`: + +```bash +node bindings/node/scripts/build-local.mjs +``` + +The script prints the absolute path of the addon it produced. Pass `--debug` for +a faster, unoptimized build while iterating. + +Verify the addon in isolation before wiring it up: + +```bash +node bindings/node/scripts/smoke.mjs +``` + +The media stack is statically linked — no system `libvpx` is required. + +## 2. Point the Node SDK at it + +```bash +export STREAM_NODE_RTC_NATIVE_PATH=/absolute/path/to/stream-node-rtc.node +``` + +The path must be absolute. The addon is loaded lazily on first RTC use, so +importing the SDK and calling REST endpoints never touches it. + +Three failures are reported distinctly, so you can tell them apart: + +| Error | Meaning | +| ------------------------------- | ------------------------------------------------------------------------- | +| `RtcNativeUnavailableError` | The path is unset, relative, unresolvable, or the module is not a binding | +| `RtcNativeVersionMismatchError` | The addon was built against a different binding API version — rebuild it | +| `RtcUnsupportedPlatformError` | This preview supports macOS and Linux only | + +## 3. Run the tests + +```bash +yarn vitest run __tests__/rtc +``` + +The RTC path spans JavaScript, a native addon, and Stream's SFU. Too much of it +can fail in ways a mock cannot reproduce — codec negotiation, subscription +semantics, capability enforcement — so protocol behaviour is covered by +**integration tests against a real call**, not by a mocked addon. + +| Suite | Kind | Covers | +| ------------------------ | ----------- | ------------------------------------------------------------------- | +| `native-loader.test.ts` | unit | Addon contract, version/platform gating, lazy loading, copy handoff | +| `errors.test.ts` | unit + real | Error decoding; real-addon input validation | +| `lifecycle.unit.test.ts` | unit | Join/leave ownership, teardown, stale generations, pump safety | +| `events.unit.test.ts` | unit | Typed events, JSON contracts, listener isolation | +| `tracks.unit.test.ts` | unit | Permanent read modes and terminal pending reads | +| `media-effects.test.ts` | unit | PCM and I420 transforms, validation, input immutability | +| `call-lifecycle.test.ts` | live | Join, leave, rejoin, state, participants, events | +| `media.test.ts` | live | PCM, I420, encoded audio, raw RTP, screen share, codecs | +| `permissions.test.ts` | live | Capability enforcement, grant, revoke, request | +| `agent-scenario.test.ts` | live | End-to-end agent: receive, transform, republish, verify | + +The lifecycle and event pumps are isolated as internal SDK logic so races and +negative assertions can be tested deterministically. There is deliberately no +public way to inject a substitute binding into the SDK. Codec negotiation, +permissions, subscriptions, and forwarding remain in the live suites. + +The live suites skip unless `RUN_STREAM_RTC_LIVE=1`, `STREAM_API_KEY`, +`STREAM_SECRET`, and `STREAM_NODE_RTC_NATIVE_PATH` are all set; put them in a +local `.env`. Credentials alone do not opt a normal test run into network calls +or RTC resource creation. Each test uses a uniquely named call and ends it +afterwards, on success and failure alike. They take a few minutes because media +has to actually flow. + +No AI provider is involved in any of them. + +The Rust side has its own conversion and JSON-contract tests: + +```bash +cargo test -p getstream-node-rtc +``` + +## Runnable examples + +[`examples/rtc-neon-agent.mjs`](../examples/rtc-neon-agent.mjs) is the complete +audio/video demonstration. It receives decoded PCM and I420 in Node, applies a +robot voice and a Neon Time-Slice effect, and republishes both tracks. The video +keeps a short temporal trail, separates its chroma, sweeps a neon scanline, and +burns `NODE//RTC` into every frame. The small reusable transforms live in +[`examples/rtc-neon-effects.mjs`](../examples/rtc-neon-effects.mjs). + +```bash +STREAM_API_KEY=YOUR_STREAM_KEY \ +STREAM_SECRET=YOUR_STREAM_SECRET \ +STREAM_NODE_RTC_NATIVE_PATH=/abs/path/to/stream-node-rtc.node \ + node examples/rtc-neon-agent.mjs +``` + +Join the same call from any Stream client and publish camera and microphone. +The agent appears as a second participant carrying the processed tracks. +`EXAMPLE_USER_ID`, `EXAMPLE_CALL_TYPE`, and `EXAMPLE_CALL_ID` override the +defaults. + +[`examples/rtc-echo-agent.mjs`](../examples/rtc-echo-agent.mjs) remains the +smallest audio-only example when visual processing is not needed. + +## The happy path + +```ts +import { LocalAudioTrack, StreamClient } from "@stream-io/node-sdk"; + +const client = new StreamClient(apiKey, apiSecret); +const agentUserId = "support-agent"; + +await client.upsertUsers([{ id: agentUserId, name: "Support Agent" }]); + +const call = client.video.call("default", "support-room"); +await call.create({ data: { created_by_id: agentUserId } }); + +const output = LocalAudioTrack.opus(); + +const unsubscribe = call.on("remoteTrack", async (track) => { + if (track.type !== "audio") return; + + while (true) { + const frame = await track.nextPcm(); + if (!frame) break; // the track ended, or we left the call + + const processed = transformPcm(frame); + await output.writePcm(processed.data, { + sampleRate: processed.sampleRate, + channels: processed.channels, + }); + } +}); + +try { + await call.join({ userId: agentUserId }); + await call.publishAudio(output); + await waitUntilShutdown(); +} finally { + unsubscribe(); + await call.leave(); +} +``` + +`call.state` is a synchronous snapshot throughout: `callingState`, `sessionId`, +`participants`, `localParticipant`, `remoteParticipants`, `ownCapabilities`, +`remoteTracks`, and the participant counts. It is refreshed from the addon +_before_ each event handler runs, so state and events never disagree. + +One caveat on counts: `participants` updates as soon as someone joins or +leaves, but `participantCount` and `anonymousParticipantCount` are the SFU's +own periodic totals and can trail the roster by a few seconds. Use +`participants.length` when you need an immediate answer, and the counts when +you want the SFU's view including anonymous participants. + +## Media formats + +| Method | Format | +| ------------------------------ | -------------------------------------------------------------------- | +| `writePcm` / `nextPcm` | Interleaved little-endian `int16`, any sample rate and channel count | +| `writeI420` / `nextVideoFrame` | I420 planar (Y, then U, then V) | +| `writeEncoded` | Codec-ready frames — Opus packets, or VP8/VP9/H264 frames | +| `writeRtp` / `readRtp` | Complete RTP packets with camel-case headers and `Buffer` payloads | + +Video tracks come from `LocalVideoTrack.vp8()`, `.vp9()`, or `.h264()`, each +accepting `targetBitrateBps` and a `layering` mode of `single` or +`server-managed`. + +The addon copies input buffers synchronously before returning its Promise. The +TypeScript layer does not make a second copy, and callers may reuse or mutate an +input buffer as soon as a write method returns. + +## Decoded vs raw reads + +A remote track permanently selects one mode on its first read: + +- **Decoded** — `nextPcm()` / `nextVideoFrame()` for transforming media. +- **Raw** — `readRtp()` / `drainRtp()` for forwarding without transcoding. + +After the first decoded or raw read, every read in the other mode throws +`RtcIllegalStateError`, even when no read is currently pending. + +Raw forwarding through `readRtp()` and `writeRtp()` preserves the encoded media +without transcoding. The live suite verifies that forwarded Opus reaches a +subscriber and decodes as non-silent PCM. + +## Permissions + +Publishing is gated on **`state.currentGrants`** — the SFU's own view, updated +by `callGrantsUpdated`. Attempting to publish without the grant fails with +`RtcPermissionDeniedError` (`code: 'RTC_PERMISSION_DENIED'`). + +`state.ownCapabilities` comes from the coordinator and is _not_ a reliable gate: +after a revoke it can still list the capability. Check `currentGrants` when you +need to know whether publishing will be allowed: + +```ts +if (call.state.currentGrants?.canPublishAudio) { + await call.publishAudio(track); +} +``` + +- `requestPermissions({ permissions })` asks the call owner; other participants + observe the request. +- `grantPermissions(userId, permissions)` / `revokePermissions(userId, permissions)` + act on another user. A grant takes effect without rejoining; a revoke stops an + active publication. + +## Subscriptions and backpressure + +The SFU forwards nothing until you subscribe. The default is audio-only. + +- `updateSubscriptions({ audio, video, screenShare, videoDimension })` sets the + policy for every participant. The SFU forwards no video unless the + subscription carries a dimension hint, so the SDK fills in 1280x720 when you + enable video or screen share without naming one. +- `updateSubscriptionTargets([...])` names exact participant/track pairs. +- `setIncomingVideoEnabled(false)` stops incoming video globally. +- `setPreferredIncomingVideoResolution(resolution, sessionIds?)` sets a + resolution hint globally or per participant. + +Native queues are bounded. When a media producer outruns a write queue you get +`RtcQueueOverflowError` (`code: 'RTC_QUEUE_OVERFLOW'`). Event-reader lag is +reported as a typed `queueOverflow` event with `queue: 'events'`, `dropped`, and +`totalDropped`. Inbound remote-track queue drops do not synthesize an event; +their cumulative count is available as `getStats().droppedRemoteTracks`. +Consume events and tracks in loops that do not block on unrelated work. + +## Codecs + +The SFU decides which video codecs it advertises for a call. Publishing a codec +it did not advertise fails with `RTC_MEDIA` and an error naming the codecs that +are available. Call `updatePublishOptions({ preferredVideoCodec })` _before_ +`join()` to pin the negotiation, and construct the matching local track: + +```ts +call.updatePublishOptions({ preferredVideoCodec: "vp9" }); +await call.join({ userId: agentUserId }); +await call.publishVideo(LocalVideoTrack.vp9({ targetBitrateBps: 600_000 })); +``` + +## Pacing your writes + +`writePcm` and `writeI420` return as soon as the frame is queued, not when it is +sent. A loop that writes without waiting will outrun the bounded queue and throw +`RtcQueueOverflowError`. Pace the producer to wall-clock time: + +```ts +const started = Date.now(); +let index = 0; +while (running) { + await track.writePcm(nextFrame(), { sampleRate: 48_000, channels: 1 }); + index += 1; + const wait = started + index * 20 - Date.now(); // 20ms frames + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); +} +``` + +Media you are forwarding from a remote track is already paced by its source, so +a read-transform-write loop needs no extra delay. + +## Shutting down cleanly + +`leave()` succeeds from any state, including mid-join and mid-reconnect. It +invalidates the JavaScript generation immediately, cancels native work, and +waits for teardown exactly once across concurrent callers. A pending track read +may receive media that was already buffered; subsequent reads end with +`undefined`, so a `while (await track.nextPcm())` loop exits on its own. Always +unsubscribe handlers and `await call.leave()` in a `finally` block. + +Rejoining the same `StreamCall` is supported. Each join is tagged with a +generation, so events from a previous join can never mutate the rejoined call. + +## Known limits of this preview + +- macOS and Linux only; no Windows, and no Bun for the RTC path. +- The addon is loaded from `STREAM_NODE_RTC_NATIVE_PATH`; there is no published + `@stream-io/node-rtc` package. +- Promotion to published packages requires a separate plan and approval. diff --git a/examples/rtc-echo-agent.mjs b/examples/rtc-echo-agent.mjs new file mode 100644 index 00000000..9b8e0f51 --- /dev/null +++ b/examples/rtc-echo-agent.mjs @@ -0,0 +1,100 @@ +/** + * Minimal backend RTC agent: join a call, listen to every remote audio track, + * transform the PCM, and publish the result back into the call. + * + * node examples/rtc-echo-agent.mjs + * + * Requires a locally built native addon (see docs/server-side-rtc.md): + * + * STREAM_API_KEY=YOUR_STREAM_KEY \ + * STREAM_SECRET=YOUR_STREAM_SECRET \ + * STREAM_NODE_RTC_NATIVE_PATH=/abs/path/to/stream-node-rtc.node \ + * node examples/rtc-echo-agent.mjs + * + * Optional overrides: EXAMPLE_USER_ID, EXAMPLE_CALL_TYPE, EXAMPLE_CALL_ID. + * Join the same call from any Stream client to hear the agent respond. + */ +import { LocalAudioTrack, StreamClient } from "@stream-io/node-sdk"; + +/** Fail closed: never fall back to a placeholder credential at runtime. */ +const required = (name) => { + const value = process.env[name]; + if (!value) { + console.error( + `Missing ${name}. See docs/server-side-rtc.md for the full setup.`, + ); + process.exit(1); + } + return value; +}; + +const apiKey = required("STREAM_API_KEY"); +const apiSecret = required("STREAM_SECRET"); +required("STREAM_NODE_RTC_NATIVE_PATH"); + +const userId = process.env.EXAMPLE_USER_ID ?? "support-agent"; +const callType = process.env.EXAMPLE_CALL_TYPE ?? "default"; +const callId = process.env.EXAMPLE_CALL_ID ?? "support-room"; + +/** + * The transform. This one halves the amplitude so the effect is audible + * without a model in the loop; swap in your own processing here. + */ +const transformPcm = (frame) => { + const data = Buffer.alloc(frame.data.length); + for (let i = 0; i < frame.data.length / 2; i += 1) { + data.writeInt16LE(Math.round(frame.data.readInt16LE(i * 2) / 2), i * 2); + } + return { data, sampleRate: frame.sampleRate, channels: frame.channels }; +}; + +const client = new StreamClient(apiKey, apiSecret); + +await client.upsertUsers([{ id: userId, name: "Support Agent" }]); + +const call = client.video.call(callType, callId); +await call.create({ data: { created_by_id: userId } }); + +const output = LocalAudioTrack.opus(); + +const unsubscribe = call.on("remoteTrack", async (track) => { + if (track.type !== "audio") return; + console.log(`hearing ${track.userId}`); + + // Each track gets its own read loop. nextPcm resolves with undefined when + // the track ends or the call is left, which ends the loop. + for (;;) { + const frame = await track.nextPcm(); + if (!frame) break; + + const processed = transformPcm(frame); + await output.writePcm(processed.data, { + sampleRate: processed.sampleRate, + channels: processed.channels, + }); + } + console.log(`${track.userId} stopped publishing`); +}); + +call.on("callingStateChanged", (event) => { + console.log(`calling state: ${event.callingState}`); +}); + +const shutdown = async () => { + console.log("\nleaving..."); + unsubscribe(); + await call.leave(); + process.exit(0); +}; +process.on("SIGINT", () => void shutdown()); +process.on("SIGTERM", () => void shutdown()); + +await call.join({ userId }); +await call.publishAudio(output); + +console.log(`joined ${callType}:${callId} as ${userId}`); +console.log(`session: ${call.state.sessionId}`); +console.log("waiting for participants — Ctrl+C to leave"); + +// Forwarding media is what keeps this process alive; nothing else to do. +await new Promise(() => {}); diff --git a/examples/rtc-neon-agent.mjs b/examples/rtc-neon-agent.mjs new file mode 100644 index 00000000..b5ca4d90 --- /dev/null +++ b/examples/rtc-neon-agent.mjs @@ -0,0 +1,117 @@ +/** + * Backend media agent: receive PCM/I420, visibly and audibly transform it in + * Node, then publish the processed tracks back into the same Stream call. + */ +import { + LocalAudioTrack, + LocalVideoTrack, + StreamClient, +} from "@stream-io/node-sdk"; +import { createNeonTimeSlice, createRobotVoice } from "./rtc-neon-effects.mjs"; + +const required = (name) => { + const value = process.env[name]; + if (!value) throw new Error(`Missing ${name}; see docs/server-side-rtc.md`); + return value; +}; + +const apiKey = required("STREAM_API_KEY"); +const apiSecret = required("STREAM_SECRET"); +required("STREAM_NODE_RTC_NATIVE_PATH"); + +const userId = process.env.EXAMPLE_USER_ID ?? "neon-node-agent"; +const callType = process.env.EXAMPLE_CALL_TYPE ?? "default"; +const callId = process.env.EXAMPLE_CALL_ID ?? "support-room"; +const client = new StreamClient(apiKey, apiSecret); + +await client.upsertUsers([{ id: userId, name: "Neon Node Agent" }]); +const call = client.video.call(callType, callId); +await call.create({ data: { created_by_id: userId } }); + +const audioOutput = LocalAudioTrack.opus(); +const videoOutput = LocalVideoTrack.vp9({ + targetBitrateBps: 1_200_000, + layering: { mode: "single" }, +}); +const activeTypes = new Set(); +const tasks = new Set(); +let stopping = false; +let finish; +const finished = new Promise((resolve) => { + finish = resolve; +}); + +const processTrack = async (track) => { + if (activeTypes.has(track.type)) return; + activeTypes.add(track.type); + + try { + if (track.type === "audio") { + const transform = createRobotVoice(); + console.log(`robot voice processing: ${track.userId}`); + for (;;) { + const frame = await track.nextPcm(); + if (!frame) break; + const output = transform(frame); + await audioOutput.writePcm(output.data, { + sampleRate: output.sampleRate, + channels: output.channels, + }); + } + } else if (track.type === "video") { + const transform = createNeonTimeSlice(); + console.log(`neon time-slice processing: ${track.userId}`); + for (;;) { + const frame = await track.nextVideoFrame(); + if (!frame) break; + const output = transform(frame); + await videoOutput.writeI420(output.data, { + width: output.width, + height: output.height, + durationMs: 33, + }); + } + } + } catch (error) { + if (!stopping) throw error; + } finally { + activeTypes.delete(track.type); + } +}; + +const unsubscribe = call.on("remoteTrack", (track) => { + if (track.type !== "audio" && track.type !== "video") return; + const task = processTrack(track) + .catch((error) => console.error(`processing ${track.type} failed`, error)) + .finally(() => tasks.delete(task)); + tasks.add(task); +}); + +call.on("callingStateChanged", ({ callingState }) => { + console.log(`calling state: ${callingState}`); +}); + +const shutdown = async () => { + if (stopping) return; + stopping = true; + console.log("\nleaving..."); + unsubscribe(); + await call.leave(); + await Promise.allSettled(tasks); + finish(); +}; + +process.once("SIGINT", () => void shutdown()); +process.once("SIGTERM", () => void shutdown()); + +call.updatePublishOptions({ preferredVideoCodec: "vp9" }); +await call.join({ userId }); +await call.updateSubscriptions({ audio: true, video: true }); +await call.publishAudio(audioOutput); +await call.publishVideo(videoOutput); + +console.log(`joined ${callType}:${callId} as ${userId}`); +console.log(`session: ${call.state.sessionId}`); +console.log("publishing robot audio + Neon Time-Slice video — Ctrl+C to leave"); + +await finished; diff --git a/examples/rtc-neon-effects.d.mts b/examples/rtc-neon-effects.d.mts new file mode 100644 index 00000000..c22810fa --- /dev/null +++ b/examples/rtc-neon-effects.d.mts @@ -0,0 +1,22 @@ +export interface PcmEffectFrame { + data: Buffer; + sampleRate: number; + channels: number; + durationMs: number; +} + +export interface I420EffectFrame { + data: Buffer; + width: number; + height: number; + rtpTimestamp: number; +} + +export function createRobotVoice(options?: { + tremoloHz?: number; + bitDepth?: number; +}): (frame: T) => T; + +export function createNeonTimeSlice(options?: { + trailFrames?: number; +}): (frame: T) => T; diff --git a/examples/rtc-neon-effects.mjs b/examples/rtc-neon-effects.mjs new file mode 100644 index 00000000..67d05778 --- /dev/null +++ b/examples/rtc-neon-effects.mjs @@ -0,0 +1,202 @@ +const FONT = { + C: [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110], + D: [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110], + E: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111], + N: [0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001], + O: [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110], + R: [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001], + T: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100], + "/": [0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000], +}; + +const clampInt16 = (value) => Math.max(-32_768, Math.min(32_767, value)); + +export const createRobotVoice = ({ tremoloHz = 18, bitDepth = 11 } = {}) => { + if (tremoloHz <= 0 || bitDepth < 2 || bitDepth > 16) { + throw new RangeError("Invalid robot voice options"); + } + + const quantum = 2 ** (16 - bitDepth); + let sampleOffset = 0; + + return (frame) => { + if ( + frame.data.length % 2 !== 0 || + frame.sampleRate <= 0 || + frame.channels <= 0 + ) { + throw new RangeError( + "PCM must contain complete int16 samples with a valid format", + ); + } + + const data = Buffer.allocUnsafe(frame.data.length); + const sampleCount = frame.data.length / 2; + for (let index = 0; index < sampleCount; index += 1) { + const time = + (sampleOffset + Math.floor(index / frame.channels)) / frame.sampleRate; + const tremolo = 0.6 + 0.4 * Math.sin(2 * Math.PI * tremoloHz * time); + const sample = frame.data.readInt16LE(index * 2); + const crushed = Math.round((sample * tremolo) / quantum) * quantum; + data.writeInt16LE(clampInt16(crushed), index * 2); + } + sampleOffset += Math.floor(sampleCount / frame.channels); + + return { ...frame, data }; + }; +}; + +const assertI420 = ({ data, width, height }) => { + const expected = (width * height * 3) / 2; + if ( + width <= 0 || + height <= 0 || + width % 2 || + height % 2 || + data.length !== expected + ) { + throw new RangeError( + "I420 frames require even dimensions and width * height * 3 / 2 bytes", + ); + } +}; + +const fillRect = (data, width, height, x, y, rectWidth, rectHeight, yuv) => { + const x0 = Math.max(0, x); + const y0 = Math.max(0, y); + const x1 = Math.min(width, x + rectWidth); + const y1 = Math.min(height, y + rectHeight); + const lumaSize = width * height; + const chromaWidth = width / 2; + const uOffset = lumaSize; + const vOffset = lumaSize + lumaSize / 4; + + for (let row = y0; row < y1; row += 1) { + data.fill(yuv[0], row * width + x0, row * width + x1); + } + for (let row = Math.floor(y0 / 2); row < Math.ceil(y1 / 2); row += 1) { + const start = row * chromaWidth + Math.floor(x0 / 2); + const end = row * chromaWidth + Math.ceil(x1 / 2); + data.fill(yuv[1], uOffset + start, uOffset + end); + data.fill(yuv[2], vOffset + start, vOffset + end); + } +}; + +const drawBadge = (data, width, height, scale) => { + const text = "NODE//RTC"; + const badgeWidth = (text.length * 6 - 1) * scale + 8 * scale; + const badgeHeight = 15 * scale; + const left = Math.max(4 * scale, width - badgeWidth - 4 * scale); + const top = Math.max(4 * scale, height - badgeHeight - 4 * scale); + fillRect( + data, + width, + height, + left, + top, + badgeWidth, + badgeHeight, + [18, 128, 128], + ); + + for (let letter = 0; letter < text.length; letter += 1) { + const glyph = FONT[text[letter]]; + for (let row = 0; row < glyph.length; row += 1) { + for (let column = 0; column < 5; column += 1) { + if (glyph[row] & (1 << (4 - column))) { + fillRect( + data, + width, + height, + left + (4 + letter * 6 + column) * scale, + top + (4 + row) * scale, + scale, + scale, + [220, 170, 45], + ); + } + } + } + } +}; + +export const createNeonTimeSlice = ({ trailFrames = 5 } = {}) => { + if (!Number.isInteger(trailFrames) || trailFrames < 1 || trailFrames > 12) { + throw new RangeError("trailFrames must be an integer from 1 to 12"); + } + + let history = []; + let dimensions = ""; + let frameIndex = 0; + + return (frame) => { + assertI420(frame); + const key = `${frame.width}x${frame.height}`; + if (key !== dimensions) { + dimensions = key; + history = []; + } + + const { width, height } = frame; + const data = Buffer.from(frame.data); + const past = history[0]; + const lumaSize = width * height; + const chromaWidth = width / 2; + const chromaHeight = height / 2; + const uOffset = lumaSize; + const vOffset = lumaSize + lumaSize / 4; + + if (past) { + for (let index = 0; index < lumaSize; index += 1) { + data[index] = (frame.data[index] * 3 + past[index]) >> 2; + } + + const shift = Math.max(1, Math.floor(width / 80)); + for (let row = 0; row < chromaHeight; row += 1) { + const rowStart = row * chromaWidth; + const uStart = uOffset + rowStart; + const vStart = vOffset + rowStart; + data.fill(past[uStart], uStart, uStart + shift); + past.copy(data, uStart + shift, uStart, uStart + chromaWidth - shift); + past.copy(data, vStart, vStart + shift, vStart + chromaWidth); + data.fill( + past[vStart + chromaWidth - 1], + vStart + chromaWidth - shift, + vStart + chromaWidth, + ); + } + + const sliceTop = (frameIndex * 17) % height; + const sliceHeight = Math.max(4, Math.floor(height / 18)); + const offset = Math.min(width - 1, Math.max(4, Math.floor(width / 32))); + for ( + let row = sliceTop; + row < Math.min(height, sliceTop + sliceHeight); + row += 1 + ) { + const start = row * width; + past.copy(data, start, start + offset, start + width); + past.copy(data, start + width - offset, start, start + offset); + } + } + + const border = Math.max(2, Math.floor(Math.min(width, height) / 80)); + const cyan = [210, 170, 35]; + const magenta = [135, 205, 225]; + fillRect(data, width, height, 0, 0, width, border, cyan); + fillRect(data, width, height, 0, height - border, width, border, magenta); + fillRect(data, width, height, 0, 0, border, height, magenta); + fillRect(data, width, height, width - border, 0, border, height, cyan); + + const scanline = + (frameIndex * Math.max(2, Math.floor(height / 60))) % height; + fillRect(data, width, height, 0, scanline, width, border, cyan); + drawBadge(data, width, height, Math.max(1, Math.floor(width / 640))); + + history.push(Buffer.from(frame.data)); + if (history.length > trailFrames) history.shift(); + frameIndex += 1; + + return { ...frame, data }; + }; +}; From 8258dd0481762591cc88740bdee65eff3c2e50dd Mon Sep 17 00:00:00 2001 From: "Neevash Ramdial (Nash)" Date: Thu, 27 Aug 2026 17:47:41 -0400 Subject: [PATCH 4/7] bench: add reproducible RTC verification Add isolated comparison and recovery tooling while keeping raw results and coordination state out of version control. --- .gitignore | 4 + benchmarks/rtc/README.md | 305 +++++++++++ benchmarks/rtc/compare.mjs | 255 +++++++++ benchmarks/rtc/live.mjs | 1033 ++++++++++++++++++++++++++++++++++++ benchmarks/rtc/netem.sh | 141 +++++ benchmarks/rtc/run.mjs | 753 ++++++++++++++++++++++++++ benchmarks/rtc/support.mjs | 486 +++++++++++++++++ 7 files changed, 2977 insertions(+) create mode 100644 benchmarks/rtc/README.md create mode 100755 benchmarks/rtc/compare.mjs create mode 100644 benchmarks/rtc/live.mjs create mode 100755 benchmarks/rtc/netem.sh create mode 100755 benchmarks/rtc/run.mjs create mode 100644 benchmarks/rtc/support.mjs diff --git a/.gitignore b/.gitignore index e80ac016..4ba443c2 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ dist .eslintcache coverage +# Server-side RTC benchmark outputs and recovery coordination. +/benchmarks/rtc/results/ +/benchmarks/rtc/control/ + diff --git a/benchmarks/rtc/README.md b/benchmarks/rtc/README.md new file mode 100644 index 00000000..af97697c --- /dev/null +++ b/benchmarks/rtc/README.md @@ -0,0 +1,305 @@ +# Server-side RTC performance verification + +This harness compares two Rust/Node ref pairs on one idle Linux x86_64 host. It +does not define acceptable numbers and does not publish or interpret results. +Run the harness from the post-hardening `stream-node` checkout while pointing it +at separately built before/after worktrees. + +Safe local validation is limited to `help`, `list`, `metadata`, and `dry-run`. +The `rust` and `live` commands require both a Linux x86_64 host and +`--acknowledge-remote-host`. + +## Coverage + +- Rust `media_baseline`: resampling, Opus, RTP packetization, VP8/VP9/H264 + encode/decode, 720p codec paths, and bounded multitrack load. +- Rust `timer_drift`: 500 paced 20 ms ticks under Opus encode load. +- Node/NAPI lifecycle: join, leave, same-handle rejoin, and repeated final + teardown. +- Real-SFU media: PCM audio, 1280x720 I420 at 30 fps, decoded reads, and raw RTP + forwarding followed by decode. +- Teardown: a pending media read, bounded buffered drain, terminal read, and + repeated cleanup. +- Resource soaks: CPU, RSS, Node heap, external/array-buffer memory, event-loop + delay, Linux thread count, active-handle counts, queue-overflow events, + dropped-remote-track counters, and WebRTC stats snapshots. +- Recovery: an opt-in, externally controlled 100% packet-loss interval followed + by restoration. It records disconnect detection, joined-state recovery, and + resumed-media latency. + +Every result includes raw samples and p50/p90/p95/p99 summaries, both repository +SHAs and dirty state, the native-addon SHA-256, Node/V8/N-API/Rust/Cargo +versions, OS/CPU/load, host fingerprint, SFU location/profile, network profile, +run configuration, subprocess outcomes, and structured failures. + +## Canonical server prerequisites + +Use a dedicated or otherwise idle Linux x86_64 host in the same placement for +both ref pairs. Do not compare laptop, macOS, containerized Docker Desktop, or +different-host results. + +Recommended baseline: + +- Ubuntu 24.04 x86_64 or an equivalent glibc Linux distribution. +- At least 8 physical/logical CPUs, 16 GiB RAM, and 20 GiB free local SSD. +- CPU frequency governor fixed consistently for both sides; no other builds, + agents, backups, or load generators running. +- Stable wired networking. Record the exact Stream SFU location and deployment + profile supplied for the run. +- Node 22.12 or newer, Corepack/Yarn 4, npm, Rust 1.88 plus stable, and Git. +- `build-essential`, `clang`, `cmake`, `libvpx-dev`, and `pkg-config`. +- `iproute2` only for the optional netem profiles. + +Install native prerequisites: + +```bash +sudo apt-get update +sudo apt-get install --yes --no-install-recommends \ + build-essential clang cmake git iproute2 libvpx-dev pkg-config +rustup toolchain install 1.88.0 --profile minimal +rustup toolchain install stable --profile minimal \ + --component clippy --component rustfmt +corepack enable +``` + +Before each run, verify that one-minute load divided by CPU count is below +`0.25`. The harness aborts at `0.75` and records the complete load snapshot. +Runs between those values are explicitly warned and should be discarded for a +canonical comparison. + +## Create immutable before/after worktrees + +Use full commit SHAs. The four refs are independent because a Node ref must use +the addon built from its matching Rust ref. + +```bash +export BENCH_ROOT="$HOME/stream-rtc-benchmark" +export RUST_BEFORE="" +export RUST_AFTER="" +export NODE_BEFORE="" +export NODE_AFTER="" + +mkdir -p "$BENCH_ROOT" +git clone https://github.com/GetStream/stream-video-rust.git \ + "$BENCH_ROOT/stream-video-rust-source" +git clone https://github.com/GetStream/stream-node.git \ + "$BENCH_ROOT/stream-node-source" + +git -C "$BENCH_ROOT/stream-video-rust-source" worktree add \ + --detach "$BENCH_ROOT/rust-before" "$RUST_BEFORE" +git -C "$BENCH_ROOT/stream-video-rust-source" worktree add \ + --detach "$BENCH_ROOT/rust-after" "$RUST_AFTER" +git -C "$BENCH_ROOT/stream-node-source" worktree add \ + --detach "$BENCH_ROOT/node-before" "$NODE_BEFORE" +git -C "$BENCH_ROOT/stream-node-source" worktree add \ + --detach "$BENCH_ROOT/node-after" "$NODE_AFTER" + +export HARNESS="$BENCH_ROOT/node-after/benchmarks/rtc" +``` + +Build every ref independently. `build:local` performs a release NAPI build and +fails if the addon has a dynamic `libvpx` dependency. + +```bash +(cd "$BENCH_ROOT/rust-before/bindings/node" && \ + npm ci --ignore-scripts && npm run build:local && npm run smoke) +(cd "$BENCH_ROOT/rust-after/bindings/node" && \ + npm ci --ignore-scripts && npm run build:local && npm run smoke) + +(cd "$BENCH_ROOT/node-before" && \ + corepack yarn install --immutable && corepack yarn build) +(cd "$BENCH_ROOT/node-after" && \ + corepack yarn install --immutable && corepack yarn build) +``` + +Confirm all four worktrees remain clean after generated build output is ignored: + +```bash +git -C "$BENCH_ROOT/rust-before" status --short +git -C "$BENCH_ROOT/rust-after" status --short +git -C "$BENCH_ROOT/node-before" status --short +git -C "$BENCH_ROOT/node-after" status --short +``` + +## Safe preflight + +These commands make no API or SFU calls and run no benchmarks: + +```bash +node "$HARNESS/run.mjs" list +node "$HARNESS/run.mjs" dry-run \ + --label pre-hardening \ + --rust-repo "$BENCH_ROOT/rust-before" --rust-ref "$RUST_BEFORE" \ + --node-repo "$BENCH_ROOT/node-before" --node-ref "$NODE_BEFORE" \ + --node-sdk "$BENCH_ROOT/node-before/dist/index.es.mjs" \ + --native-addon \ + "$BENCH_ROOT/rust-before/bindings/node/stream-node-rtc.node" \ + --sfu-location "" \ + --sfu-profile "" \ + --network-profile clean +``` + +## Before and after commands + +Export credentials only in the benchmark shell. The harness checks presence but +never serializes their values. + +```bash +export STREAM_API_KEY="" +export STREAM_SECRET="" +export SFU_LOCATION="" +export SFU_PROFILE="" +mkdir -p "$BENCH_ROOT/results" +``` + +Run Rust baselines: + +```bash +node "$HARNESS/run.mjs" rust --acknowledge-remote-host \ + --label pre-hardening \ + --rust-repo "$BENCH_ROOT/rust-before" --rust-ref "$RUST_BEFORE" \ + --node-repo "$BENCH_ROOT/node-before" --node-ref "$NODE_BEFORE" \ + --native-addon \ + "$BENCH_ROOT/rust-before/bindings/node/stream-node-rtc.node" \ + --sfu-location "$SFU_LOCATION" --sfu-profile "$SFU_PROFILE" \ + --network-profile clean \ + --output "$BENCH_ROOT/results/rust-before.json" + +node "$HARNESS/run.mjs" rust --acknowledge-remote-host \ + --label post-hardening \ + --rust-repo "$BENCH_ROOT/rust-after" --rust-ref "$RUST_AFTER" \ + --node-repo "$BENCH_ROOT/node-after" --node-ref "$NODE_AFTER" \ + --native-addon \ + "$BENCH_ROOT/rust-after/bindings/node/stream-node-rtc.node" \ + --sfu-location "$SFU_LOCATION" --sfu-profile "$SFU_PROFILE" \ + --network-profile clean \ + --output "$BENCH_ROOT/results/rust-after.json" +``` + +Run the clean-network real-SFU Node suite: + +```bash +node "$HARNESS/run.mjs" live --acknowledge-remote-host \ + --label pre-hardening \ + --rust-repo "$BENCH_ROOT/rust-before" --rust-ref "$RUST_BEFORE" \ + --node-repo "$BENCH_ROOT/node-before" --node-ref "$NODE_BEFORE" \ + --node-sdk "$BENCH_ROOT/node-before/dist/index.es.mjs" \ + --native-addon \ + "$BENCH_ROOT/rust-before/bindings/node/stream-node-rtc.node" \ + --sfu-location "$SFU_LOCATION" --sfu-profile "$SFU_PROFILE" \ + --network-profile clean --warmups 2 --repeats 10 \ + --soak-seconds 60 --soak-repeats 3 \ + --output "$BENCH_ROOT/results/node-before.json" + +node "$HARNESS/run.mjs" live --acknowledge-remote-host \ + --label post-hardening \ + --rust-repo "$BENCH_ROOT/rust-after" --rust-ref "$RUST_AFTER" \ + --node-repo "$BENCH_ROOT/node-after" --node-ref "$NODE_AFTER" \ + --node-sdk "$BENCH_ROOT/node-after/dist/index.es.mjs" \ + --native-addon \ + "$BENCH_ROOT/rust-after/bindings/node/stream-node-rtc.node" \ + --sfu-location "$SFU_LOCATION" --sfu-profile "$SFU_PROFILE" \ + --network-profile clean --warmups 2 --repeats 10 \ + --soak-seconds 60 --soak-repeats 3 \ + --output "$BENCH_ROOT/results/node-after.json" +``` + +Resource scenarios run in a fresh Node process for every soak repeat. Other +scenarios use one isolated process per scenario so warmups and measurements do +not contaminate unrelated resource baselines. + +Validate compatibility before computing deltas: + +```bash +node "$HARNESS/compare.mjs" \ + --before "$BENCH_ROOT/results/rust-before.json" \ + --after "$BENCH_ROOT/results/rust-after.json" --validate-only +node "$HARNESS/compare.mjs" \ + --before "$BENCH_ROOT/results/node-before.json" \ + --after "$BENCH_ROOT/results/node-after.json" --validate-only +``` + +Only after both validations succeed, produce machine-readable numeric deltas: + +```bash +node "$HARNESS/compare.mjs" \ + --before "$BENCH_ROOT/results/rust-before.json" \ + --after "$BENCH_ROOT/results/rust-after.json" \ + --output "$BENCH_ROOT/results/rust-comparison.json" +node "$HARNESS/compare.mjs" \ + --before "$BENCH_ROOT/results/node-before.json" \ + --after "$BENCH_ROOT/results/node-after.json" \ + --output "$BENCH_ROOT/results/node-comparison.json" +``` + +The comparison output deliberately contains no regression threshold or +interpretation. + +## Controlled recovery + +Recovery is separate from the clean suite because it changes the host qdisc. +Use a dedicated interface with no pre-existing custom root qdisc. Run the Node +command in terminal A: + +```bash +export CONTROL_DIR="$BENCH_ROOT/control/recovery-before" +mkdir -p "$CONTROL_DIR" +node "$HARNESS/run.mjs" live --acknowledge-remote-host \ + --label pre-hardening-recovery \ + --rust-repo "$BENCH_ROOT/rust-before" --rust-ref "$RUST_BEFORE" \ + --node-repo "$BENCH_ROOT/node-before" --node-ref "$NODE_BEFORE" \ + --node-sdk "$BENCH_ROOT/node-before/dist/index.es.mjs" \ + --native-addon \ + "$BENCH_ROOT/rust-before/bindings/node/stream-node-rtc.node" \ + --sfu-location "$SFU_LOCATION" --sfu-profile "$SFU_PROFILE" \ + --network-profile controlled-outage --scenarios recovery \ + --recovery-control-dir "$CONTROL_DIR" --recovery-repeats 5 \ + --output "$BENCH_ROOT/results/recovery-before.json" +``` + +Immediately run the controller in terminal B: + +```bash +sudo "$HARNESS/netem.sh" recovery "$CONTROL_DIR" 5 +``` + +Repeat with the post-hardening paths and a new +`$BENCH_ROOT/control/recovery-after` directory. The controller applies 100% +egress loss, waits until the SDK reports a recovery state, clears the qdisc, +signals the exact restoration point, waits for resumed media, and clears the +qdisc again on normal exit, error, `INT`, or `TERM`. + +Always verify restoration: + +```bash +sudo "$HARNESS/netem.sh" clear +sudo "$HARNESS/netem.sh" show +``` + +## Optional steady netem profiles + +These are diagnostic matrices, not part of the clean canonical pair. Apply the +same profile to before and after, pass the matching label to +`--network-profile`, and restore immediately: + +```bash +sudo "$HARNESS/netem.sh" apply loss-1pct +trap 'sudo "$HARNESS/netem.sh" clear' EXIT INT TERM + +# Run the before and after live commands with: --network-profile loss-1pct + +sudo "$HARNESS/netem.sh" clear +trap - EXIT INT TERM +``` + +Available profiles are `loss-1pct`, `loss-5pct`, `cap-1mbps`, and +`rtt-200ms`. Never run netem on a shared host, over the only administrative +network path, or on macOS/Docker Desktop. + +## Result hygiene + +`benchmarks/rtc/results/` and `benchmarks/rtc/control/` are ignored. Keep raw +JSON private until both sides pass compatibility validation and maintainers +explicitly approve interpretation/publication. A failed iteration, cleanup +failure, queue overflow, unexpected emitted RTC error, dirty ref, addon mismatch, +busy host, or profile mismatch invalidates that pair; do not silently drop it. diff --git a/benchmarks/rtc/compare.mjs b/benchmarks/rtc/compare.mjs new file mode 100755 index 00000000..75096e26 --- /dev/null +++ b/benchmarks/rtc/compare.mjs @@ -0,0 +1,255 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { SCHEMA_VERSION, writeJson } from "./support.mjs"; + +const HELP = `Validate and compare two RTC benchmark result files + +Usage: + node benchmarks/rtc/compare.mjs --before FILE --after FILE --output FILE + node benchmarks/rtc/compare.mjs --before FILE --after FILE --validate-only + +The command rejects mixed hosts, SFU profiles, network profiles, commands, media +settings, and run configurations. It emits numeric deltas only; it does not +classify, interpret, or publish results. +`; + +const parseArguments = (argv) => { + if (argv.includes("--help") || argv.includes("-h")) return { help: true }; + const values = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--validate-only") { + values.validateOnly = true; + continue; + } + if (!["--before", "--after", "--output"].includes(argument)) { + throw new Error(`unknown argument: ${argument}`); + } + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${argument} requires a value`); + } + values[argument.slice(2)] = value; + index += 1; + } + if (!values.before || !values.after) { + throw new Error("--before and --after are required"); + } + if (!values.validateOnly && !values.output) { + throw new Error("--output is required unless --validate-only is used"); + } + return values; +}; + +const loadResult = async (path) => { + const absolute = resolve(path); + const value = JSON.parse(await readFile(absolute, "utf8")); + if ( + value.schemaVersion !== SCHEMA_VERSION || + value.kind !== "stream-node-rtc-benchmark" + ) { + throw new Error( + `${absolute} is not RTC benchmark schema ${SCHEMA_VERSION}`, + ); + } + return { path: absolute, value }; +}; + +const stable = (value) => JSON.stringify(value); + +const compatibilityChecks = (before, after) => { + if (before.quality?.valid !== true || after.quality?.valid !== true) { + throw new Error("results with benchmark quality issues are not comparable"); + } + if (before.eligibility?.idle !== true || after.eligibility?.idle !== true) { + throw new Error("results from a non-idle host are not comparable"); + } + const comparableConfiguration = (result) => { + const configuration = { ...result.configuration }; + for (const key of [ + "label", + "nativeAddon", + "nodeRef", + "nodeRepo", + "nodeSdk", + "output", + "recoveryControlDir", + "runId", + "rustRef", + "rustRepo", + ]) { + delete configuration[key]; + } + return configuration; + }; + const checks = { + command: [before.command, after.command], + hostFingerprint: [ + before.metadata.host.fingerprint, + after.metadata.host.fingerprint, + ], + hostClass: [before.metadata.host.class, after.metadata.host.class], + tools: [before.metadata.tools, after.metadata.tools], + sfu: [before.metadata.sfu, after.metadata.sfu], + networkProfile: [ + before.metadata.networkProfile, + after.metadata.networkProfile, + ], + runConfiguration: [ + comparableConfiguration(before), + comparableConfiguration(after), + ], + }; + const incompatible = Object.entries(checks) + .filter(([, pair]) => stable(pair[0]) !== stable(pair[1])) + .map(([name, pair]) => ({ name, before: pair[0], after: pair[1] })); + if (incompatible.length) { + throw new Error( + `results are not comparable: ${incompatible + .map((entry) => entry.name) + .join(", ")}`, + ); + } + if ( + before.metadata.repositories.rust.dirty || + before.metadata.repositories.node.dirty || + after.metadata.repositories.rust.dirty || + after.metadata.repositories.node.dirty + ) { + throw new Error("results from dirty repositories are not comparable"); + } + return checks; +}; + +const addNumericComparison = (target, name, unit, before, after) => { + if (!Number.isFinite(before) || !Number.isFinite(after)) return; + target[name] = { + unit, + before, + after, + difference: after - before, + ratio: before === 0 ? null : after / before, + percentChange: before === 0 ? null : ((after - before) / before) * 100, + }; +}; + +const liveMetrics = (result) => { + const metrics = {}; + for (const scenario of result.live?.scenarios ?? []) { + for (const [name, summary] of Object.entries(scenario.metrics)) { + for (const statistic of [ + "mean", + "p50", + "p90", + "p95", + "p99", + "min", + "max", + ]) { + metrics[`${scenario.name}.${name}.${statistic}`] = { + unit: summary.unit, + value: summary[statistic], + }; + } + } + } + return metrics; +}; + +const rustMetrics = (result) => { + const metrics = {}; + for (const entry of result.rust?.criterion ?? []) { + for (const [estimate, value] of Object.entries(entry.estimates)) { + if (!Number.isFinite(value?.point_estimate)) continue; + metrics[`criterion.${entry.path}.${estimate}`] = { + unit: "criterion-native", + value: value.point_estimate, + }; + } + } + for (const statistic of ["p50", "p90", "p99", "max"]) { + const value = result.rust?.timerDrift?.[statistic]; + if (Number.isFinite(value)) { + metrics[`timer-drift.${statistic}`] = { unit: "us", value }; + } + } + return metrics; +}; + +const compareMetrics = (before, after) => { + const left = + before.command === "rust" ? rustMetrics(before) : liveMetrics(before); + const right = + after.command === "rust" ? rustMetrics(after) : liveMetrics(after); + const comparisons = {}; + for (const [name, leftMetric] of Object.entries(left)) { + const rightMetric = right[name]; + if (!rightMetric || rightMetric.unit !== leftMetric.unit) continue; + addNumericComparison( + comparisons, + name, + leftMetric.unit, + leftMetric.value, + rightMetric.value, + ); + } + return comparisons; +}; + +const main = async () => { + const args = parseArguments(process.argv.slice(2)); + if (args.help) { + process.stdout.write(HELP); + return; + } + const [before, after] = await Promise.all([ + loadResult(args.before), + loadResult(args.after), + ]); + const checks = compatibilityChecks(before.value, after.value); + const validation = { + schemaVersion: SCHEMA_VERSION, + kind: "stream-node-rtc-benchmark-comparison", + validatedAt: new Date().toISOString(), + before: { + path: before.path, + label: before.value.metadata.label, + rustSha: before.value.metadata.repositories.rust.sha, + nodeSha: before.value.metadata.repositories.node.sha, + addonHash: before.value.metadata.nativeAddon?.hash ?? null, + }, + after: { + path: after.path, + label: after.value.metadata.label, + rustSha: after.value.metadata.repositories.rust.sha, + nodeSha: after.value.metadata.repositories.node.sha, + addonHash: after.value.metadata.nativeAddon?.hash ?? null, + }, + compatibility: { + valid: true, + checkedFields: Object.keys(checks), + }, + }; + await writeJson( + args.validateOnly ? null : resolve(args.output), + args.validateOnly + ? validation + : { + ...validation, + measurements: compareMetrics(before.value, after.value), + interpretation: null, + }, + ); +}; + +try { + await main(); +} catch (error) { + process.stderr.write( + `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); + process.exitCode = 1; +} diff --git a/benchmarks/rtc/live.mjs b/benchmarks/rtc/live.mjs new file mode 100644 index 00000000..19f08a09 --- /dev/null +++ b/benchmarks/rtc/live.mjs @@ -0,0 +1,1033 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { performance } from "node:perf_hooks"; + +import { + ResourceMonitor, + sleep, + summarize, + uniqueId, + waitFor, + withTimeout, +} from "./support.mjs"; + +const AUDIO_SAMPLE_RATE = 48_000; +const AUDIO_FRAME_SAMPLES = AUDIO_SAMPLE_RATE / 50; +const VIDEO_WIDTH = 1_280; +const VIDEO_HEIGHT = 720; +const VIDEO_FRAME_MS = 1_000 / 30; +const DEFAULT_VIDEO_BITRATE_BPS = 2_500_000; + +const errorRecord = (error, context) => ({ + context, + name: error instanceof Error ? error.name : "UnknownError", + message: error instanceof Error ? error.message : String(error), + code: + error && typeof error === "object" && "code" in error + ? String(error.code) + : null, + details: + error && typeof error === "object" && "details" in error + ? error.details + : null, +}); + +const timed = async (operation) => { + const started = performance.now(); + const value = await operation(); + return { value, durationMs: performance.now() - started }; +}; + +const memorySnapshot = () => ({ + at: new Date().toISOString(), + ...process.memoryUsage(), +}); + +const toneFrame = (frameIndex) => { + const data = Buffer.allocUnsafe(AUDIO_FRAME_SAMPLES * 2); + for (let index = 0; index < AUDIO_FRAME_SAMPLES; index += 1) { + const sample = + Math.sin( + (2 * Math.PI * 440 * (frameIndex * AUDIO_FRAME_SAMPLES + index)) / + AUDIO_SAMPLE_RATE, + ) * 12_000; + data.writeInt16LE(Math.round(sample), index * 2); + } + return data; +}; + +const i420Frame = () => { + const lumaSize = VIDEO_WIDTH * VIDEO_HEIGHT; + const data = Buffer.alloc(lumaSize * 1.5); + for (let row = 0; row < VIDEO_HEIGHT; row += 1) { + const start = row * VIDEO_WIDTH; + data.fill(32 + (row % 192), start, start + VIDEO_WIDTH); + } + data.fill(128, lumaSize); + return data; +}; + +const startPacedMedia = ({ + audio, + video, + tolerateErrors = false, + maxDurationMs, +}) => { + let running = true; + const failures = []; + const videoData = video ? i420Frame() : null; + const done = (async () => { + const started = performance.now(); + let audioIndex = 0; + let videoIndex = 0; + while (running && performance.now() - started < maxDurationMs) { + if (audio) { + try { + await audio.writePcm(toneFrame(audioIndex), { + sampleRate: AUDIO_SAMPLE_RATE, + channels: 1, + }); + } catch (error) { + failures.push(errorRecord(error, "audio write")); + if (!tolerateErrors) break; + } + audioIndex += 1; + } + if (video && performance.now() - started >= videoIndex * VIDEO_FRAME_MS) { + try { + await video.writeI420(videoData, { + width: VIDEO_WIDTH, + height: VIDEO_HEIGHT, + durationMs: VIDEO_FRAME_MS, + }); + } catch (error) { + failures.push(errorRecord(error, "video write")); + if (!tolerateErrors) break; + } + videoIndex += 1; + } + const nextAudioAt = audio ? audioIndex * 20 : Infinity; + const nextVideoAt = video ? videoIndex * VIDEO_FRAME_MS : Infinity; + await sleep( + started + Math.min(nextAudioAt, nextVideoAt) - performance.now(), + ); + } + return { audioFrames: audioIndex, videoFrames: videoIndex, failures }; + })(); + return { + stop: () => { + running = false; + }, + done, + }; +}; + +class LiveCallRegistry { + constructor(timeoutMs, failureSink) { + this.timeoutMs = timeoutMs; + this.failureSink = failureSink; + this.joined = []; + this.created = []; + } + + track(call, { created = false } = {}) { + this.joined.push(call); + if (created) this.created.push(call); + return call; + } + + markCreated(call) { + this.created.push(call); + } + + async cleanup() { + const failures = []; + for (const call of this.joined.reverse()) { + try { + await withTimeout("call leave during cleanup", this.timeoutMs, () => + call.leave(), + ); + } catch (error) { + failures.push(errorRecord(error, `leave ${call.cid}`)); + } + } + for (const call of this.created.reverse()) { + try { + await withTimeout("call end during cleanup", this.timeoutMs, () => + call.end(), + ); + } catch (error) { + failures.push(errorRecord(error, `end ${call.cid}`)); + } + } + this.failureSink.push(...failures); + return failures; + } +} + +const telemetryFor = (call) => { + const telemetry = { + queueOverflowEvents: 0, + droppedEvents: 0, + totalDroppedEvents: 0, + emittedErrors: [], + }; + const unsubscribeOverflow = call.on("queueOverflow", (event) => { + telemetry.queueOverflowEvents += 1; + telemetry.droppedEvents += event.dropped; + telemetry.totalDroppedEvents = Math.max( + telemetry.totalDroppedEvents, + event.totalDropped, + ); + }); + const unsubscribeError = call.on("error", (event) => { + telemetry.emittedErrors.push({ + sourceEventType: event.sourceEventType ?? null, + error: event.error + ? { + name: event.error.name, + message: event.error.message, + code: event.error.code, + details: event.error.details, + } + : null, + }); + }); + return { + telemetry, + unsubscribe: () => { + unsubscribeOverflow(); + unsubscribeError(); + }, + }; +}; + +const statsFor = async (calls, timeoutMs) => { + const values = []; + for (const call of calls) { + const stats = await withTimeout(`stats ${call.cid}`, timeoutMs, () => + call.getStats(), + ); + values.push({ + cid: call.cid, + droppedRemoteTracks: stats?.droppedRemoteTracks ?? null, + publisher: stats?.publisher ?? null, + subscriber: stats?.subscriber ?? null, + }); + } + return values; +}; + +const firstTrack = (call, type, timeoutMs) => + withTimeout(`first remote ${type} track`, timeoutMs, () => { + const current = call.state.remoteTracks.find( + (track) => track.type === type, + ); + if (current) return current; + return new Promise((resolvePromise) => { + const unsubscribe = call.on("remoteTrack", (track) => { + if (track.type !== type) return; + unsubscribe(); + resolvePromise(track); + }); + }); + }); + +const join = (call, userId, config) => + withTimeout(`join ${call.cid}`, config.operationTimeoutMs, () => + call.join({ + userId, + location: config.sfuLocation, + maxJoinRetries: config.maxJoinRetries, + joinResponseTimeoutMs: config.operationTimeoutMs, + rpcRequestTimeoutMs: config.operationTimeoutMs, + }), + ); + +const createSingle = async (sdk, client, config, registry, prefix) => { + const userId = uniqueId(`${prefix}-user`, config.runId); + const callId = uniqueId(`${prefix}-call`, config.runId); + await withTimeout("upsert benchmark user", config.operationTimeoutMs, () => + client.upsertUsers([{ id: userId, name: "RTC benchmark participant" }]), + ); + const call = registry.track(client.video.call(config.callType, callId)); + await withTimeout("create benchmark call", config.operationTimeoutMs, () => + call.create({ data: { created_by_id: userId } }), + ); + registry.markCreated(call); + return { call, userId }; +}; + +const createPair = async (sdk, client, config, registry, prefix) => { + const senderId = uniqueId(`${prefix}-sender`, config.runId); + const receiverId = uniqueId(`${prefix}-receiver`, config.runId); + const callId = uniqueId(`${prefix}-call`, config.runId); + await withTimeout("upsert benchmark users", config.operationTimeoutMs, () => + client.upsertUsers([ + { id: senderId, name: "RTC benchmark sender" }, + { id: receiverId, name: "RTC benchmark receiver" }, + ]), + ); + const sender = registry.track(client.video.call(config.callType, callId)); + const receiver = registry.track(client.video.call(config.callType, callId)); + await withTimeout("create benchmark call", config.operationTimeoutMs, () => + sender.create({ data: { created_by_id: senderId } }), + ); + registry.markCreated(sender); + sender.updatePublishOptions({ preferredVideoCodec: config.videoCodec }); + receiver.updatePublishOptions({ preferredVideoCodec: config.videoCodec }); + return { sender, senderId, receiver, receiverId }; +}; + +const lifecycleIteration = async (sdk, client, config, prefix) => { + const registry = new LiveCallRegistry( + config.operationTimeoutMs, + config.cleanupFailures, + ); + const details = {}; + try { + const { call, userId } = await createSingle( + sdk, + client, + config, + registry, + prefix, + ); + const observed = telemetryFor(call); + details.telemetry = observed.telemetry; + details.join = await timed(() => join(call, userId, config)); + details.statsAfterJoin = await statsFor([call], config.operationTimeoutMs); + details.leave = await timed(() => + withTimeout("leave", config.operationTimeoutMs, () => call.leave()), + ); + details.rejoin = await timed(() => join(call, userId, config)); + details.statsAfterRejoin = await statsFor( + [call], + config.operationTimeoutMs, + ); + details.finalLeave = await timed(() => + withTimeout("final leave", config.operationTimeoutMs, () => call.leave()), + ); + observed.unsubscribe(); + return { + metrics: { + joinMs: details.join.durationMs, + leaveMs: details.leave.durationMs, + rejoinMs: details.rejoin.durationMs, + finalLeaveMs: details.finalLeave.durationMs, + }, + details, + }; + } finally { + details.cleanupFailures = await registry.cleanup(); + } +}; + +const decodedMediaIteration = async ( + sdk, + client, + config, + prefix, + mediaType, +) => { + const registry = new LiveCallRegistry( + config.operationTimeoutMs, + config.cleanupFailures, + ); + const details = {}; + let pump; + try { + const pair = await createPair(sdk, client, config, registry, prefix); + const senderTelemetry = telemetryFor(pair.sender); + const receiverTelemetry = telemetryFor(pair.receiver); + details.telemetry = { + sender: senderTelemetry.telemetry, + receiver: receiverTelemetry.telemetry, + }; + await join(pair.sender, pair.senderId, config); + await join(pair.receiver, pair.receiverId, config); + if (mediaType === "video") { + await withTimeout("video subscription", config.operationTimeoutMs, () => + pair.receiver.updateSubscriptions({ audio: false, video: true }), + ); + } + + const trackPromise = firstTrack( + pair.receiver, + mediaType, + config.mediaTimeoutMs, + ); + let localTrack; + if (mediaType === "audio") { + localTrack = sdk.LocalAudioTrack.opus(); + await withTimeout("publish audio", config.operationTimeoutMs, () => + pair.sender.publishAudio(localTrack), + ); + } else { + localTrack = sdk.LocalVideoTrack[config.videoCodec]({ + targetBitrateBps: config.videoBitrateBps, + }); + await withTimeout("publish video", config.operationTimeoutMs, () => + pair.sender.publishVideo(localTrack), + ); + } + + const started = performance.now(); + pump = startPacedMedia({ + audio: mediaType === "audio" ? localTrack : undefined, + video: mediaType === "video" ? localTrack : undefined, + maxDurationMs: config.mediaTimeoutMs, + }); + const remote = await trackPromise; + const first = + mediaType === "audio" + ? await withTimeout("first decoded PCM", config.mediaTimeoutMs, () => + remote.nextPcm(), + ) + : await withTimeout("first decoded I420", config.mediaTimeoutMs, () => + remote.nextVideoFrame(), + ); + if (!first?.data?.length) + throw new Error(`empty decoded ${mediaType} frame`); + const firstFrameMs = performance.now() - started; + details.firstFrame = { + bytes: first.data.length, + width: first.width ?? null, + height: first.height ?? null, + sampleRate: first.sampleRate ?? null, + channels: first.channels ?? null, + }; + details.stats = await statsFor( + [pair.sender, pair.receiver], + config.operationTimeoutMs, + ); + pump.stop(); + details.pump = await pump.done; + senderTelemetry.unsubscribe(); + receiverTelemetry.unsubscribe(); + if (details.pump.failures.length) { + throw new Error(`${mediaType} pump reported write failures`); + } + return { + metrics: { + firstFrameMs, + }, + details, + }; + } finally { + pump?.stop(); + if (pump) details.pump ??= await pump.done; + details.cleanupFailures = await registry.cleanup(); + } +}; + +const rawRtpIteration = async (sdk, client, config, prefix) => { + const registry = new LiveCallRegistry( + config.operationTimeoutMs, + config.cleanupFailures, + ); + const details = {}; + let pump; + let forwarding = true; + let forwardDone; + try { + const pair = await createPair(sdk, client, config, registry, prefix); + const senderTelemetry = telemetryFor(pair.sender); + const receiverTelemetry = telemetryFor(pair.receiver); + details.telemetry = { + sender: senderTelemetry.telemetry, + receiver: receiverTelemetry.telemetry, + }; + await join(pair.sender, pair.senderId, config); + await join(pair.receiver, pair.receiverId, config); + + const source = sdk.LocalAudioTrack.opus(); + const relay = sdk.LocalAudioTrack.opus(); + await withTimeout("publish source audio", config.operationTimeoutMs, () => + pair.sender.publishAudio(source), + ); + await withTimeout("publish relay audio", config.operationTimeoutMs, () => + pair.receiver.publishAudio(relay), + ); + + const sourceInboundPromise = firstTrack( + pair.receiver, + "audio", + config.mediaTimeoutMs, + ); + const started = performance.now(); + pump = startPacedMedia({ + audio: source, + maxDurationMs: config.mediaTimeoutMs, + }); + const sourceInbound = await sourceInboundPromise; + let forwardedPackets = 0; + const forwardingFailures = []; + forwardDone = (async () => { + while (forwarding) { + const packet = await sourceInbound.readRtp(); + if (!packet) break; + try { + await relay.writeRtp(packet); + forwardedPackets += 1; + } catch (error) { + forwardingFailures.push(errorRecord(error, "raw RTP relay write")); + break; + } + } + })(); + + const relayedInbound = await firstTrack( + pair.sender, + "audio", + config.mediaTimeoutMs, + ); + const decoded = await withTimeout( + "first decoded relayed PCM", + config.mediaTimeoutMs, + () => relayedInbound.nextPcm(), + ); + if (!decoded?.data?.length) + throw new Error("empty decoded relayed PCM frame"); + const roundTripFirstFrameMs = performance.now() - started; + await waitFor("raw RTP forwarding sample", () => forwardedPackets >= 50, { + timeoutMs: config.mediaTimeoutMs, + }); + details.forwardedPackets = forwardedPackets; + details.forwardingFailures = forwardingFailures; + details.stats = await statsFor( + [pair.sender, pair.receiver], + config.operationTimeoutMs, + ); + pump.stop(); + details.pump = await pump.done; + senderTelemetry.unsubscribe(); + receiverTelemetry.unsubscribe(); + if (forwardingFailures.length || details.pump.failures.length) { + throw new Error("raw RTP path reported media failures"); + } + return { + metrics: { + roundTripFirstFrameMs, + forwardedPackets, + }, + details, + }; + } finally { + forwarding = false; + pump?.stop(); + if (pump) details.pump ??= await pump.done; + details.cleanupFailures = await registry.cleanup(); + if (forwardDone) { + await withTimeout( + "raw forwarding shutdown", + config.operationTimeoutMs, + () => forwardDone, + ); + } + } +}; + +const teardownIteration = async (sdk, client, config, prefix) => { + const registry = new LiveCallRegistry( + config.operationTimeoutMs, + config.cleanupFailures, + ); + const details = {}; + let pump; + let unsubscribeTelemetry = () => {}; + try { + const pair = await createPair(sdk, client, config, registry, prefix); + const senderTelemetry = telemetryFor(pair.sender); + const receiverTelemetry = telemetryFor(pair.receiver); + unsubscribeTelemetry = () => { + senderTelemetry.unsubscribe(); + receiverTelemetry.unsubscribe(); + }; + details.telemetry = { + sender: senderTelemetry.telemetry, + receiver: receiverTelemetry.telemetry, + }; + await join(pair.sender, pair.senderId, config); + await join(pair.receiver, pair.receiverId, config); + const audio = sdk.LocalAudioTrack.opus(); + await pair.sender.publishAudio(audio); + const inboundPromise = firstTrack( + pair.receiver, + "audio", + config.mediaTimeoutMs, + ); + pump = startPacedMedia({ + audio, + maxDurationMs: config.mediaTimeoutMs, + }); + const inbound = await inboundPromise; + await withTimeout("initial decoded PCM", config.mediaTimeoutMs, () => + inbound.nextPcm(), + ); + details.statsBeforeLeave = await statsFor( + [pair.sender, pair.receiver], + config.operationTimeoutMs, + ); + pump.stop(); + details.pump = await pump.done; + const pending = inbound.nextPcm(); + const leave = await timed(() => + withTimeout("receiver teardown", config.operationTimeoutMs, () => + pair.receiver.leave(), + ), + ); + await withTimeout( + "pending read completion", + config.operationTimeoutMs, + () => pending, + ); + const drainStarted = performance.now(); + let bufferedReads = 0; + await withTimeout( + "terminal read drain", + config.operationTimeoutMs, + async () => { + for (;;) { + const frame = await inbound.nextPcm(); + if (!frame) break; + bufferedReads += 1; + if (bufferedReads > config.maxBufferedReadsAfterLeave) { + throw new Error("remote track did not reach terminal read state"); + } + } + }, + ); + details.bufferedReadsAfterLeave = bufferedReads; + return { + metrics: { + leaveMs: leave.durationMs, + terminalReadMs: performance.now() - drainStarted, + bufferedReadsAfterLeave: bufferedReads, + }, + details, + }; + } finally { + unsubscribeTelemetry(); + pump?.stop(); + if (pump) details.pump ??= await pump.done; + details.cleanupFailures = await registry.cleanup(); + } +}; + +const resourceIteration = async (sdk, client, config, prefix, mediaType) => { + const registry = new LiveCallRegistry( + config.operationTimeoutMs, + config.cleanupFailures, + ); + const details = { phaseMemory: { initial: memorySnapshot() } }; + let pump; + let draining = true; + let drainDone; + let monitorRunning = false; + let unsubscribeTelemetry = () => {}; + const monitor = new ResourceMonitor(config.resourceSampleIntervalMs); + try { + const pair = await createPair(sdk, client, config, registry, prefix); + const senderTelemetry = telemetryFor(pair.sender); + const receiverTelemetry = telemetryFor(pair.receiver); + unsubscribeTelemetry = () => { + senderTelemetry.unsubscribe(); + receiverTelemetry.unsubscribe(); + }; + details.telemetry = { + sender: senderTelemetry.telemetry, + receiver: receiverTelemetry.telemetry, + }; + details.phaseMemory.beforeJoin = memorySnapshot(); + await join(pair.sender, pair.senderId, config); + await join(pair.receiver, pair.receiverId, config); + details.phaseMemory.afterJoin = memorySnapshot(); + if (mediaType === "video") { + await pair.receiver.updateSubscriptions({ audio: false, video: true }); + } + const local = + mediaType === "audio" + ? sdk.LocalAudioTrack.opus() + : sdk.LocalVideoTrack[config.videoCodec]({ + targetBitrateBps: config.videoBitrateBps, + }); + const inboundPromise = firstTrack( + pair.receiver, + mediaType, + config.mediaTimeoutMs, + ); + if (mediaType === "audio") await pair.sender.publishAudio(local); + else await pair.sender.publishVideo(local); + pump = startPacedMedia({ + audio: mediaType === "audio" ? local : undefined, + video: mediaType === "video" ? local : undefined, + maxDurationMs: config.soakSeconds * 1_000 + config.mediaTimeoutMs, + }); + const inbound = await inboundPromise; + const first = + mediaType === "audio" + ? await inbound.nextPcm() + : await inbound.nextVideoFrame(); + if (!first) throw new Error(`no ${mediaType} frame before resource sample`); + let decodedFrames = 1; + drainDone = (async () => { + while (draining) { + const frame = + mediaType === "audio" + ? await inbound.nextPcm() + : await inbound.nextVideoFrame(); + if (!frame) break; + decodedFrames += 1; + } + })(); + await monitor.start(); + monitorRunning = true; + await sleep(config.soakSeconds * 1_000); + details.resources = await monitor.stop(); + monitorRunning = false; + details.decodedFrames = decodedFrames; + details.stats = await statsFor( + [pair.sender, pair.receiver], + config.operationTimeoutMs, + ); + pump.stop(); + details.pump = await pump.done; + if (details.pump.failures.length) { + throw new Error(`${mediaType} resource pump reported write failures`); + } + return { + metrics: { + cpuPercentOfOneCore: details.resources.cpuPercentOfOneCore, + rssPeakBytes: details.resources.memory.rss.max, + heapUsedPeakBytes: details.resources.memory.heapUsed.max, + externalPeakBytes: details.resources.memory.external.max, + eventLoopDelayP99Ms: details.resources.eventLoopDelayMs.p99, + threadPeak: details.resources.threads.max, + activeHandlePeak: details.resources.activeHandleCount.max, + decodedFrames, + }, + details, + }; + } finally { + draining = false; + unsubscribeTelemetry(); + if (monitorRunning) { + details.resources ??= await monitor.stop(); + } + pump?.stop(); + if (pump) details.pump ??= await pump.done; + details.cleanupFailures = await registry.cleanup(); + if (drainDone) { + await withTimeout( + "decoded drain shutdown", + config.operationTimeoutMs, + () => drainDone, + ); + } + } +}; + +const nextSignal = (signal) => + new Promise((resolvePromise) => { + process.once(signal, () => resolvePromise(performance.now())); + }); + +const recoveryIteration = async (sdk, client, config, prefix, cycle) => { + const registry = new LiveCallRegistry( + config.operationTimeoutMs, + config.cleanupFailures, + ); + const details = {}; + let pump; + let draining = true; + const drainTasks = []; + const consumedTracks = new Set(); + let unsubscribeRemote = () => {}; + let unsubscribeState = () => {}; + let unsubscribeTelemetry = () => {}; + const cycleDir = resolve(config.recoveryControlDir, `cycle-${cycle}`); + await mkdir(cycleDir, { recursive: true }); + try { + const pair = await createPair(sdk, client, config, registry, prefix); + const senderTelemetry = telemetryFor(pair.sender); + const receiverTelemetry = telemetryFor(pair.receiver); + unsubscribeTelemetry = () => { + senderTelemetry.unsubscribe(); + receiverTelemetry.unsubscribe(); + }; + details.telemetry = { + sender: senderTelemetry.telemetry, + receiver: receiverTelemetry.telemetry, + }; + pair.sender.setDisconnectionTimeout(config.recoveryTimeoutSeconds); + pair.receiver.setDisconnectionTimeout(config.recoveryTimeoutSeconds); + await join(pair.sender, pair.senderId, config); + await join(pair.receiver, pair.receiverId, config); + const audio = sdk.LocalAudioTrack.opus(); + await pair.sender.publishAudio(audio); + let decodedFrames = 0; + details.drainFailures = []; + const consume = (track) => { + if (track.type !== "audio" || consumedTracks.has(track)) return; + consumedTracks.add(track); + drainTasks.push( + (async () => { + try { + while (draining) { + const frame = await track.nextPcm(); + if (!frame) break; + decodedFrames += 1; + } + } catch (error) { + details.drainFailures.push( + errorRecord(error, "recovery decoded audio drain"), + ); + } + })(), + ); + }; + unsubscribeRemote = pair.receiver.on("remoteTrack", consume); + const inboundPromise = firstTrack( + pair.receiver, + "audio", + config.mediaTimeoutMs, + ); + pump = startPacedMedia({ + audio, + tolerateErrors: true, + maxDurationMs: config.recoveryScenarioTimeoutMs, + }); + const inbound = await inboundPromise; + consume(inbound); + await waitFor("pre-outage media", () => decodedFrames >= 20, { + timeoutMs: config.mediaTimeoutMs, + }); + + let reconnectingAt; + unsubscribeState = pair.receiver.on("callingStateChanged", (event) => { + if ( + reconnectingAt === undefined && + ["reconnecting", "offline", "migrating"].includes(event.callingState) + ) { + reconnectingAt = performance.now(); + void writeFile( + resolve(cycleDir, "reconnecting"), + `${event.callingState}\n`, + ); + } + }); + const outageApplied = nextSignal("SIGUSR1"); + const restored = nextSignal("SIGUSR2"); + await writeFile(resolve(cycleDir, "ready.pid"), `${process.pid}\n`); + const outageAt = await withTimeout( + "netem outage signal", + config.recoveryScenarioTimeoutMs, + () => outageApplied, + ); + await waitFor("reconnecting state", () => reconnectingAt !== undefined, { + timeoutMs: config.recoveryScenarioTimeoutMs, + }); + const restoredAt = await withTimeout( + "netem restoration signal", + config.recoveryScenarioTimeoutMs, + () => restored, + ); + const framesAtRestore = decodedFrames; + await waitFor( + "joined state after restore", + () => pair.receiver.state.callingState === "joined", + { timeoutMs: config.recoveryScenarioTimeoutMs }, + ); + const rejoinedAt = performance.now(); + await waitFor( + "media after restore", + () => decodedFrames > framesAtRestore, + { + timeoutMs: config.recoveryScenarioTimeoutMs, + }, + ); + const mediaAt = performance.now(); + await writeFile(resolve(cycleDir, "complete"), "ok\n"); + if (details.drainFailures.length) { + throw new Error("recovery media drain reported failures"); + } + details.timeline = { + outageObservedMs: outageAt, + reconnectingObservedMs: reconnectingAt, + restoredObservedMs: restoredAt, + rejoinedObservedMs: rejoinedAt, + mediaObservedMs: mediaAt, + signalDetectionResolutionMs: 1, + }; + details.stats = await statsFor( + [pair.sender, pair.receiver], + config.operationTimeoutMs, + ); + return { + metrics: { + disconnectDetectionMs: reconnectingAt - outageAt, + stateRecoveryMs: rejoinedAt - restoredAt, + mediaRecoveryMs: mediaAt - restoredAt, + }, + details, + }; + } finally { + draining = false; + unsubscribeRemote(); + unsubscribeState(); + unsubscribeTelemetry(); + pump?.stop(); + if (pump) details.pump = await pump.done; + details.cleanupFailures = await registry.cleanup(); + await withTimeout( + "recovery drain shutdown", + config.operationTimeoutMs, + () => Promise.all(drainTasks), + ); + } +}; + +const metricUnit = (name) => { + if (name.endsWith("Ms")) return "ms"; + if (name.endsWith("Bytes")) return "bytes"; + if (name.toLowerCase().includes("percent")) return "percent"; + return "count"; +}; + +const scenarioRunner = (name) => { + if (name === "lifecycle") return lifecycleIteration; + if (name === "decoded-audio") { + return (sdk, client, config, prefix) => + decodedMediaIteration(sdk, client, config, prefix, "audio"); + } + if (name === "decoded-video-720p30") { + return (sdk, client, config, prefix) => + decodedMediaIteration(sdk, client, config, prefix, "video"); + } + if (name === "raw-rtp-audio") return rawRtpIteration; + if (name === "teardown") return teardownIteration; + if (name === "resource-audio") { + return (sdk, client, config, prefix) => + resourceIteration(sdk, client, config, prefix, "audio"); + } + if (name === "resource-video-720p30") { + return (sdk, client, config, prefix) => + resourceIteration(sdk, client, config, prefix, "video"); + } + if (name === "recovery") return recoveryIteration; + throw new Error(`unknown live scenario: ${name}`); +}; + +export const runLiveBenchmarks = async (config) => { + const cleanupFailures = []; + config.cleanupFailures = cleanupFailures; + process.env.STREAM_NODE_RTC_NATIVE_PATH = resolve(config.nativeAddon); + const sdk = await import(pathToFileURL(resolve(config.nodeSdk)).href); + const client = new sdk.StreamClient( + process.env.STREAM_API_KEY, + process.env.STREAM_SECRET, + { timeout: config.operationTimeoutMs }, + ); + const scenarios = []; + const failures = []; + let recoveryCycle = 0; + + for (const name of config.scenarios) { + const runner = scenarioRunner(name); + const isResource = name.startsWith("resource-"); + const isRecovery = name === "recovery"; + const warmups = isResource || isRecovery ? 0 : config.warmups; + const repeats = isResource + ? config.soakRepeats + : isRecovery + ? config.recoveryRepeats + : config.repeats; + const iterations = []; + const metricSamples = {}; + + for (let index = -warmups; index < repeats; index += 1) { + const warmup = index < 0; + const iterationPrefix = `${name}-${warmup ? `warmup-${-index}` : `run-${index + 1}`}`; + try { + const result = await withTimeout( + `${name} ${iterationPrefix}`, + isResource + ? config.soakSeconds * 1_000 + config.mediaTimeoutMs * 2 + : isRecovery + ? config.recoveryScenarioTimeoutMs * 2 + : config.scenarioTimeoutMs, + () => + runner( + sdk, + client, + config, + iterationPrefix, + isRecovery ? recoveryCycle++ : undefined, + ), + ); + const cleanupFailures = result.details.cleanupFailures ?? []; + if (cleanupFailures.length) { + throw new Error( + `${name} cleanup failed: ${cleanupFailures + .map((failure) => failure.message) + .join("; ")}`, + ); + } + if (!warmup) { + iterations.push(result); + for (const [metric, value] of Object.entries(result.metrics)) { + (metricSamples[metric] ??= []).push(value); + } + } + } catch (error) { + failures.push({ + scenario: name, + iteration: warmup ? `warmup-${-index}` : index + 1, + warmup, + ...errorRecord(error, `${name} ${iterationPrefix}`), + }); + } + } + + scenarios.push({ + name, + warmups, + repeats, + metrics: Object.fromEntries( + Object.entries(metricSamples).map(([metric, samples]) => [ + metric, + summarize(samples, metricUnit(metric)), + ]), + ), + iterations, + }); + } + + return { scenarios, failures, cleanupFailures }; +}; + +export const LIVE_SCENARIOS = [ + "lifecycle", + "decoded-audio", + "decoded-video-720p30", + "raw-rtp-audio", + "teardown", + "resource-audio", + "resource-video-720p30", + "recovery", +]; + +export const DEFAULT_LIVE_SCENARIOS = LIVE_SCENARIOS.filter( + (name) => name !== "recovery", +); + +export const MEDIA_CONSTANTS = { + audioSampleRate: AUDIO_SAMPLE_RATE, + audioFrameDurationMs: 20, + videoWidth: VIDEO_WIDTH, + videoHeight: VIDEO_HEIGHT, + videoFramesPerSecond: 30, + defaultVideoBitrateBps: DEFAULT_VIDEO_BITRATE_BPS, +}; diff --git a/benchmarks/rtc/netem.sh b/benchmarks/rtc/netem.sh new file mode 100755 index 00000000..ad209619 --- /dev/null +++ b/benchmarks/rtc/netem.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "usage:" >&2 + echo " sudo $0 show [iface]" >&2 + echo " sudo $0 clear [iface]" >&2 + echo " sudo $0 apply [iface]" >&2 + echo " sudo $0 recovery [iface]" >&2 + exit 2 +} + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "netem requires Linux; this host is $(uname -s)" >&2 + exit 1 +fi +if [[ "${EUID}" -ne 0 ]]; then + echo "netem requires root; rerun with sudo" >&2 + exit 1 +fi + +COMMAND="${1:-}" +[[ -n "${COMMAND}" ]] || usage +shift + +default_iface() { + ip -o route show default | awk '{print $5; exit}' +} + +resolve_iface() { + local explicit="${1:-}" + local selected="${explicit:-${IFACE:-}}" + if [[ -z "${selected}" ]]; then + selected="$(default_iface)" + fi + if [[ -z "${selected}" ]]; then + echo "could not detect the default interface; pass one explicitly or set IFACE" >&2 + exit 1 + fi + echo "${selected}" +} + +clear_qdisc() { + local iface="$1" + if tc qdisc show dev "${iface}" | awk '($2 == "netem" || $2 == "tbf") && / root / { found=1 } END { exit !found }'; then + tc qdisc del dev "${iface}" root + fi +} + +apply_profile() { + local profile="$1" + local iface="$2" + clear_qdisc "${iface}" + case "${profile}" in + loss-1pct) + tc qdisc add dev "${iface}" root netem loss 1% + ;; + loss-5pct) + tc qdisc add dev "${iface}" root netem loss 5% + ;; + cap-1mbps) + tc qdisc add dev "${iface}" root tbf rate 1mbit burst 32kbit latency 50ms + ;; + rtt-200ms) + tc qdisc add dev "${iface}" root netem delay 200ms + ;; + outage) + tc qdisc add dev "${iface}" root netem loss 100% + ;; + *) + echo "unknown netem profile: ${profile}" >&2 + exit 2 + ;; + esac + tc qdisc show dev "${iface}" +} + +wait_for_file() { + local path="$1" + local timeout_seconds="$2" + local deadline=$((SECONDS + timeout_seconds)) + while [[ ! -f "${path}" ]]; do + if (( SECONDS >= deadline )); then + echo "timed out waiting for ${path}" >&2 + return 1 + fi + sleep 0.05 + done +} + +case "${COMMAND}" in + show) + IFACE_NAME="$(resolve_iface "${1:-}")" + tc qdisc show dev "${IFACE_NAME}" + ;; + clear) + IFACE_NAME="$(resolve_iface "${1:-}")" + clear_qdisc "${IFACE_NAME}" + tc qdisc show dev "${IFACE_NAME}" + ;; + apply) + PROFILE="${1:-}" + [[ -n "${PROFILE}" ]] || usage + IFACE_NAME="$(resolve_iface "${2:-}")" + apply_profile "${PROFILE}" "${IFACE_NAME}" + ;; + recovery) + CONTROL_DIR="${1:-}" + REPEATS="${2:-}" + [[ -n "${CONTROL_DIR}" && "${REPEATS}" =~ ^[1-9][0-9]*$ ]] || usage + [[ -d "${CONTROL_DIR}" ]] || { + echo "control directory does not exist: ${CONTROL_DIR}" >&2 + exit 1 + } + IFACE_NAME="$(resolve_iface "${3:-}")" + trap 'clear_qdisc "${IFACE_NAME}"' EXIT INT TERM + clear_qdisc "${IFACE_NAME}" + for ((cycle = 0; cycle < REPEATS; cycle += 1)); do + CYCLE_DIR="${CONTROL_DIR}/cycle-${cycle}" + wait_for_file "${CYCLE_DIR}/ready.pid" 180 + IFS= read -r NODE_PID < "${CYCLE_DIR}/ready.pid" + [[ "${NODE_PID}" =~ ^[1-9][0-9]*$ ]] || { + echo "invalid Node PID in ${CYCLE_DIR}/ready.pid" >&2 + exit 1 + } + kill -0 "${NODE_PID}" + apply_profile outage "${IFACE_NAME}" + kill -USR1 "${NODE_PID}" + wait_for_file "${CYCLE_DIR}/reconnecting" 120 + clear_qdisc "${IFACE_NAME}" + kill -USR2 "${NODE_PID}" + wait_for_file "${CYCLE_DIR}/complete" 120 + done + clear_qdisc "${IFACE_NAME}" + trap - EXIT INT TERM + tc qdisc show dev "${IFACE_NAME}" + ;; + *) + usage + ;; +esac diff --git a/benchmarks/rtc/run.mjs b/benchmarks/rtc/run.mjs new file mode 100755 index 00000000..6f2baa8c --- /dev/null +++ b/benchmarks/rtc/run.mjs @@ -0,0 +1,753 @@ +#!/usr/bin/env node + +import { randomUUID } from "node:crypto"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + ABORT_LOAD_PER_CPU, + SCHEMA_VERSION, + WARN_LOAD_PER_CPU, + assertCanonicalHost, + assertCleanRepositories, + assertLiveInputs, + collectCriterionEstimates, + collectMetadata, + loadSnapshot, + runCommand, + sleep, + summarize, + writeJson, +} from "./support.mjs"; +import { + DEFAULT_LIVE_SCENARIOS, + LIVE_SCENARIOS, + MEDIA_CONSTANTS, + runLiveBenchmarks, +} from "./live.mjs"; + +const SCRIPT = fileURLToPath(import.meta.url); +const NODE_REPO = resolve(dirname(SCRIPT), "../.."); +const RUST_REPO = resolve(NODE_REPO, "../stream-video-rust-release"); +const COMMANDS = new Set([ + "help", + "list", + "metadata", + "dry-run", + "rust", + "live", + "live-worker", +]); +const OPTIONS = new Set([ + "--acknowledge-remote-host", + "--call-type", + "--config", + "--cooldown-seconds", + "--label", + "--max-buffered-reads-after-leave", + "--max-join-retries", + "--media-timeout-ms", + "--native-addon", + "--network-profile", + "--node-ref", + "--node-repo", + "--node-sdk", + "--operation-timeout-ms", + "--output", + "--recovery-control-dir", + "--recovery-repeats", + "--recovery-scenario-timeout-ms", + "--recovery-timeout-seconds", + "--repeats", + "--resource-sample-interval-ms", + "--run-id", + "--rust-ref", + "--rust-repo", + "--scenario-timeout-ms", + "--scenarios", + "--sfu-location", + "--sfu-profile", + "--soak-repeats", + "--soak-seconds", + "--video-bitrate-bps", + "--video-codec", + "--warmups", +]); + +const HELP = `Server-side RTC benchmark harness + +Usage: + node benchmarks/rtc/run.mjs help + node benchmarks/rtc/run.mjs list + node benchmarks/rtc/run.mjs metadata [options] + node benchmarks/rtc/run.mjs dry-run [options] + node benchmarks/rtc/run.mjs rust --label LABEL --output FILE --acknowledge-remote-host [options] + node benchmarks/rtc/run.mjs live --label LABEL --output FILE --acknowledge-remote-host [options] + +Safe commands: + metadata Capture repository, addon, runtime, host, load, and SFU labels only. + dry-run Validate configuration and print the exact bounded execution plan. + list List Rust and live benchmark coverage. Makes no network calls. + +Execution commands: + rust Run media_baseline Criterion and timer_drift on canonical Linux x86_64. + live Run selected real-SFU scenarios in isolated child processes. + +Core options: + --rust-repo PATH Candidate stream-video-rust-release checkout. + --node-repo PATH Candidate stream-node checkout. + --node-sdk PATH Candidate dist/index.es.mjs. + --native-addon PATH Candidate release .node addon (or STREAM_NODE_RTC_NATIVE_PATH). + --rust-ref REF Require the Rust checkout to resolve exactly to REF. + --node-ref REF Require the Node checkout to resolve exactly to REF. + --label LABEL Stable label such as pre-hardening or post-hardening. + --output FILE Machine-readable JSON destination. + --sfu-location VALUE Join location sent to Stream and recorded in metadata. + --sfu-profile VALUE Human-readable SFU deployment/profile identifier. + --network-profile VALUE clean or an externally applied netem profile. + --cooldown-seconds N Idle delay between isolated components; default 15. + --acknowledge-remote-host + Required for rust/live; execution still rejects non-Linux/x64. + +Live options: + --scenarios CSV Default: ${DEFAULT_LIVE_SCENARIOS.join(",")} + --warmups N Default: 2. + --repeats N Default: 10. + --soak-seconds N Default: 60. + --soak-repeats N Default: 3; each repeat runs in a fresh process. + --operation-timeout-ms N Default: 30000. + --media-timeout-ms N Default: 45000. + --scenario-timeout-ms N Default: 180000. + --video-codec VALUE vp8, vp9, or h264; default vp9. + --video-bitrate-bps N Default: ${MEDIA_CONSTANTS.defaultVideoBitrateBps}. + --call-type VALUE Default: default. + --recovery-control-dir PATH + Required when selecting recovery; coordinate with netem.sh. + --recovery-repeats N Default: 5. + +Credential gate: + live requires STREAM_API_KEY, STREAM_SECRET, an absolute native addon path, + and explicit --sfu-location/--sfu-profile. Safe commands never load the SDK, + native addon, or contact Stream. +`; + +const parseArguments = (argv) => { + const command = argv[0] ?? "help"; + if (!COMMANDS.has(command)) throw new Error(`unknown command: ${command}`); + const values = {}; + const booleans = new Set(["--acknowledge-remote-host"]); + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index]; + if (!OPTIONS.has(argument)) { + throw new Error(`unknown argument: ${argument}`); + } + if (booleans.has(argument)) { + values[argument.slice(2)] = true; + continue; + } + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`${argument} requires a value`); + } + values[argument.slice(2)] = value; + index += 1; + } + return { command, values }; +}; + +const integer = (values, name, defaultValue, minimum = 0) => { + const raw = values[name]; + if (raw === undefined) return defaultValue; + const value = Number(raw); + if (!Number.isInteger(value) || value < minimum) { + throw new Error(`--${name} must be an integer >= ${minimum}`); + } + return value; +}; + +const number = (values, name, defaultValue, minimum = 0) => { + const raw = values[name]; + if (raw === undefined) return defaultValue; + const value = Number(raw); + if (!Number.isFinite(value) || value < minimum) { + throw new Error(`--${name} must be a finite number >= ${minimum}`); + } + return value; +}; + +const configFrom = (values) => { + const scenarios = (values.scenarios ?? DEFAULT_LIVE_SCENARIOS.join(",")) + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const unknownScenarios = scenarios.filter( + (scenario) => !LIVE_SCENARIOS.includes(scenario), + ); + if (unknownScenarios.length) { + throw new Error(`unknown scenarios: ${unknownScenarios.join(", ")}`); + } + const videoCodec = values["video-codec"] ?? "vp9"; + if (!["vp8", "vp9", "h264"].includes(videoCodec)) { + throw new Error("--video-codec must be vp8, vp9, or h264"); + } + return { + runId: values["run-id"] ?? randomUUID(), + label: values.label ?? null, + output: values.output ? resolve(values.output) : null, + rustRepo: resolve(values["rust-repo"] ?? RUST_REPO), + nodeRepo: resolve(values["node-repo"] ?? NODE_REPO), + nodeSdk: resolve( + values["node-sdk"] ?? + resolve(values["node-repo"] ?? NODE_REPO, "dist/index.es.mjs"), + ), + nativeAddon: + values["native-addon"] ?? process.env.STREAM_NODE_RTC_NATIVE_PATH ?? null, + rustRef: values["rust-ref"] ?? null, + nodeRef: values["node-ref"] ?? null, + sfuLocation: values["sfu-location"] ?? null, + sfuProfile: values["sfu-profile"] ?? null, + networkProfile: values["network-profile"] ?? "clean", + cooldownSeconds: number(values, "cooldown-seconds", 15), + acknowledgedRemoteHost: Boolean(values["acknowledge-remote-host"]), + scenarios, + warmups: integer(values, "warmups", 2), + repeats: integer(values, "repeats", 10, 1), + soakSeconds: number(values, "soak-seconds", 60, 1), + soakRepeats: integer(values, "soak-repeats", 3, 1), + operationTimeoutMs: integer(values, "operation-timeout-ms", 30_000, 1), + mediaTimeoutMs: integer(values, "media-timeout-ms", 45_000, 1), + scenarioTimeoutMs: integer(values, "scenario-timeout-ms", 180_000, 1), + resourceSampleIntervalMs: integer( + values, + "resource-sample-interval-ms", + 1_000, + 100, + ), + videoCodec, + videoBitrateBps: integer( + values, + "video-bitrate-bps", + MEDIA_CONSTANTS.defaultVideoBitrateBps, + 1, + ), + callType: values["call-type"] ?? "default", + maxJoinRetries: integer(values, "max-join-retries", 3), + maxBufferedReadsAfterLeave: integer( + values, + "max-buffered-reads-after-leave", + 500, + 1, + ), + recoveryControlDir: values["recovery-control-dir"] + ? resolve(values["recovery-control-dir"]) + : null, + recoveryRepeats: integer(values, "recovery-repeats", 5, 1), + recoveryTimeoutSeconds: integer(values, "recovery-timeout-seconds", 30, 1), + recoveryScenarioTimeoutMs: integer( + values, + "recovery-scenario-timeout-ms", + 120_000, + 1, + ), + }; +}; + +const publicConfig = (config) => ({ + runId: config.runId, + label: config.label, + output: config.output, + rustRepo: config.rustRepo, + nodeRepo: config.nodeRepo, + nodeSdk: config.nodeSdk, + nativeAddon: config.nativeAddon, + rustRef: config.rustRef, + nodeRef: config.nodeRef, + sfuLocation: config.sfuLocation, + sfuProfile: config.sfuProfile, + networkProfile: config.networkProfile, + cooldownSeconds: config.cooldownSeconds, + scenarios: config.scenarios, + warmups: config.warmups, + repeats: config.repeats, + soakSeconds: config.soakSeconds, + soakRepeats: config.soakRepeats, + operationTimeoutMs: config.operationTimeoutMs, + mediaTimeoutMs: config.mediaTimeoutMs, + scenarioTimeoutMs: config.scenarioTimeoutMs, + resourceSampleIntervalMs: config.resourceSampleIntervalMs, + videoCodec: config.videoCodec, + videoBitrateBps: config.videoBitrateBps, + callType: config.callType, + maxJoinRetries: config.maxJoinRetries, + maxBufferedReadsAfterLeave: config.maxBufferedReadsAfterLeave, + recoveryControlDir: config.recoveryControlDir, + recoveryRepeats: config.recoveryRepeats, + recoveryTimeoutSeconds: config.recoveryTimeoutSeconds, + recoveryScenarioTimeoutMs: config.recoveryScenarioTimeoutMs, + media: MEDIA_CONSTANTS, +}); + +const filePathStatus = async (path) => { + if (!path) return { path: null, exists: false, file: false }; + try { + const details = await stat(path); + return { path, exists: true, file: details.isFile() }; + } catch (error) { + if (error?.code === "ENOENT") return { path, exists: false, file: false }; + throw error; + } +}; + +const executionGuard = (config, metadata) => { + if (!config.acknowledgedRemoteHost) { + throw new Error( + "benchmark execution requires --acknowledge-remote-host after provisioning the canonical server", + ); + } + const load = assertCanonicalHost(); + assertCleanRepositories(metadata); + if (!config.label) + throw new Error("--label is required for benchmark execution"); + if (!config.output) + throw new Error("--output is required for benchmark execution"); + if (!config.nativeAddon) { + throw new Error( + "--native-addon is required to hash the matching release addon", + ); + } + if (!config.sfuLocation || !config.sfuProfile) { + throw new Error( + "--sfu-location and --sfu-profile are required for the canonical run envelope", + ); + } + return { + hostAccepted: true, + idle: load.loadPerCpu < WARN_LOAD_PER_CPU, + warning: + load.loadPerCpu >= WARN_LOAD_PER_CPU + ? `load per CPU is ${load.loadPerCpu.toFixed(3)}; canonical runs should remain below ${WARN_LOAD_PER_CPU}` + : null, + abortThreshold: ABORT_LOAD_PER_CPU, + }; +}; + +const runRust = async (config) => { + const startedAt = Date.now(); + const beforeMedia = loadSnapshot(); + const env = { + ...process.env, + CARGO_TERM_COLOR: "never", + RUN_STREAM_RTC_LIVE: "", + STREAM_API_KEY: "", + STREAM_API_SECRET: "", + STREAM_SECRET: "", + VPX_STATIC: "1", + }; + const media = await runCommand( + "cargo", + ["bench", "--locked", "--bench", "media_baseline", "--", "--noplot"], + { + cwd: config.rustRepo, + env, + timeoutMs: 30 * 60_000, + }, + ); + await sleep(config.cooldownSeconds * 1_000); + const beforeTimer = loadSnapshot(); + const timer = await runCommand( + "cargo", + ["bench", "--locked", "--bench", "timer_drift"], + { + cwd: config.rustRepo, + env, + timeoutMs: 5 * 60_000, + }, + ); + const timerOutput = `${timer.stdout}\n${timer.stderr}`; + const drift = timerOutput.match( + /p50=(\d+)us p90=(\d+)us p99=(\d+)us max=(\d+)us/, + ); + return { + load: { + beforeMedia, + beforeTimer, + }, + commands: { media, timer }, + criterion: await collectCriterionEstimates(config.rustRepo, startedAt), + timerDrift: drift + ? { + unit: "us", + ticks: 500, + periodMs: 20, + p50: Number(drift[1]), + p90: Number(drift[2]), + p99: Number(drift[3]), + max: Number(drift[4]), + } + : null, + failures: [ + ...(media.exitCode === 0 + ? [] + : [{ component: "media_baseline", exitCode: media.exitCode }]), + ...(timer.exitCode === 0 + ? [] + : [{ component: "timer_drift", exitCode: timer.exitCode }]), + ...(timer.exitCode === 0 && !drift + ? [{ component: "timer_drift", reason: "summary was not parseable" }] + : []), + ], + }; +}; + +const mergeScenarioRuns = (runs) => { + const first = runs[0]; + const metricSamples = {}; + const iterations = []; + const failures = []; + const cleanupFailures = []; + for (const run of runs) { + failures.push(...run.failures); + cleanupFailures.push(...(run.cleanupFailures ?? [])); + const scenario = run.scenarios[0]; + iterations.push(...scenario.iterations); + for (const [name, summary] of Object.entries(scenario.metrics)) { + (metricSamples[name] ??= []).push(...summary.samples); + } + } + return { + scenarios: [ + { + ...first.scenarios[0], + repeats: runs.length, + iterations, + metrics: Object.fromEntries( + Object.entries(metricSamples).map(([name, samples]) => [ + name, + summarize( + samples, + runs + .flatMap((run) => run.scenarios) + .map((scenario) => scenario.metrics[name]?.unit) + .find(Boolean) ?? "count", + ), + ]), + ), + }, + ], + failures, + cleanupFailures, + }; +}; + +const runLiveWorker = async (config, scenario, output) => { + const loadBefore = assertCanonicalHost(); + const workerConfig = { + ...config, + scenarios: [scenario], + output: null, + soakRepeats: 1, + }; + const directory = await mkdtemp(resolve(tmpdir(), "stream-rtc-bench-")); + const configPath = resolve(directory, "config.json"); + const outputPath = resolve(directory, "result.json"); + await writeJson(configPath, publicConfig(workerConfig)); + const result = await runCommand( + process.execPath, + [SCRIPT, "live-worker", "--config", configPath, "--output", outputPath], + { + cwd: config.nodeRepo, + env: { ...process.env, STREAM_RTC_BENCH_WORKER: "1" }, + timeoutMs: scenario.startsWith("resource-") + ? config.soakSeconds * 1_000 + config.mediaTimeoutMs * 3 + : scenario === "recovery" + ? config.recoveryScenarioTimeoutMs * config.recoveryRepeats * 2 + : config.scenarioTimeoutMs * (config.warmups + config.repeats), + }, + ); + let payload; + try { + payload = JSON.parse(await readFile(outputPath, "utf8")); + } catch (error) { + payload = { + scenarios: [ + { + name: scenario, + warmups: 0, + repeats: 0, + metrics: {}, + iterations: [], + }, + ], + failures: [ + { + scenario, + context: "isolated worker", + message: `worker did not produce readable JSON: ${error.message}`, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }, + ], + }; + } finally { + await rm(directory, { recursive: true, force: true }); + } + if (result.exitCode !== 0 || result.timedOut) { + payload.failures.push({ + scenario, + context: "isolated worker process", + message: result.timedOut + ? "worker exceeded its hard timeout" + : `worker exited with ${result.exitCode ?? result.signal}`, + exitCode: result.exitCode, + signal: result.signal, + }); + } + output.push({ + scenario, + loadBefore, + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + durationMs: result.durationMs, + }); + return payload; +}; + +const runLiveIsolated = async (config) => { + const workerRuns = []; + const scenarios = []; + const failures = []; + const cleanupFailures = []; + let workerIndex = 0; + for (const scenario of config.scenarios) { + const count = scenario.startsWith("resource-") ? config.soakRepeats : 1; + const runs = []; + for (let repeat = 0; repeat < count; repeat += 1) { + if (workerIndex > 0) { + await sleep(config.cooldownSeconds * 1_000); + } + runs.push(await runLiveWorker(config, scenario, workerRuns)); + workerIndex += 1; + } + const merged = count === 1 ? runs[0] : mergeScenarioRuns(runs); + scenarios.push(...merged.scenarios); + failures.push(...merged.failures); + cleanupFailures.push(...(merged.cleanupFailures ?? [])); + } + return { scenarios, failures, cleanupFailures, workers: workerRuns }; +}; + +const liveQualityIssues = (live) => { + const issues = [ + ...live.failures.map((failure) => ({ + type: "iteration-failure", + ...failure, + })), + ...live.cleanupFailures.map((failure) => ({ + type: "cleanup-failure", + ...failure, + })), + ]; + for (const worker of live.workers) { + if (worker.loadBefore.loadPerCpu >= WARN_LOAD_PER_CPU) { + issues.push({ + type: "busy-host-before-scenario", + scenario: worker.scenario, + load: worker.loadBefore, + }); + } + } + const inspectTelemetry = (telemetry, scenario, iteration) => { + if (!telemetry || typeof telemetry !== "object") return; + if ("queueOverflowEvents" in telemetry) { + if (telemetry.queueOverflowEvents > 0) { + issues.push({ + type: "queue-overflow", + scenario, + iteration, + queueOverflowEvents: telemetry.queueOverflowEvents, + droppedEvents: telemetry.droppedEvents, + totalDroppedEvents: telemetry.totalDroppedEvents, + }); + } + for (const error of telemetry.emittedErrors ?? []) { + issues.push({ + type: "emitted-rtc-error", + scenario, + iteration, + error, + }); + } + return; + } + for (const value of Object.values(telemetry)) { + inspectTelemetry(value, scenario, iteration); + } + }; + for (const scenario of live.scenarios) { + scenario.iterations.forEach((result, index) => { + const details = result.details; + inspectTelemetry(details.telemetry, scenario.name, index + 1); + const statsGroups = [ + details.stats, + details.statsAfterJoin, + details.statsAfterRejoin, + details.statsBeforeLeave, + ]; + for (const stats of statsGroups.flatMap((value) => value ?? [])) { + if ((stats.droppedRemoteTracks ?? 0) > 0) { + issues.push({ + type: "dropped-remote-tracks", + scenario: scenario.name, + iteration: index + 1, + cid: stats.cid, + count: stats.droppedRemoteTracks, + }); + } + } + for (const [type, failures] of [ + ["media-pump-failure", details.pump?.failures], + ["forwarding-failure", details.forwardingFailures], + ["media-drain-failure", details.drainFailures], + ]) { + for (const failure of failures ?? []) { + issues.push({ + type, + scenario: scenario.name, + iteration: index + 1, + failure, + }); + } + } + }); + } + return issues; +}; + +const listPayload = { + rust: { + media_baseline: + "Criterion resample, Opus encode/decode, RTP packetization, VP8/VP9/H264 encode/decode, and bounded multitrack load", + timer_drift: "500 paced 20ms ticks under Opus encode load", + }, + live: { + scenarios: LIVE_SCENARIOS, + defaults: DEFAULT_LIVE_SCENARIOS, + recovery: + "Opt-in controlled outage; requires benchmarks/rtc/netem.sh in a second terminal", + }, + media: MEDIA_CONSTANTS, +}; + +const worker = async (values) => { + if (process.env.STREAM_RTC_BENCH_WORKER !== "1") { + throw new Error("live-worker is internal and may only be started by live"); + } + const configPath = values.config; + if (!configPath || !values.output) { + throw new Error("live-worker requires --config and --output"); + } + const config = JSON.parse(await readFile(resolve(configPath), "utf8")); + await writeJson(resolve(values.output), await runLiveBenchmarks(config)); +}; + +const main = async () => { + const { command, values } = parseArguments(process.argv.slice(2)); + if (command === "help") { + process.stdout.write(HELP); + return 0; + } + if (command === "list") { + await writeJson(null, listPayload); + return 0; + } + if (command === "live-worker") { + await worker(values); + return 0; + } + + const config = configFrom(values); + const metadata = await collectMetadata(config); + const base = { + schemaVersion: SCHEMA_VERSION, + kind: "stream-node-rtc-benchmark", + command, + metadata, + configuration: publicConfig(config), + }; + if (command === "metadata") { + await writeJson(config.output, base); + return 0; + } + if (command === "dry-run") { + const [nodeSdk, nativeAddon] = await Promise.all([ + filePathStatus(config.nodeSdk), + filePathStatus(config.nativeAddon), + ]); + await writeJson(config.output, { + ...base, + executionPlan: { + rustCommands: [ + "cargo bench --locked --bench media_baseline -- --noplot", + "cargo bench --locked --bench timer_drift", + ], + liveScenarios: config.scenarios, + isolatedResourceSoaks: true, + liveCredentialPresence: { + apiKey: Boolean(process.env.STREAM_API_KEY), + secret: Boolean(process.env.STREAM_SECRET), + nativeAddon: Boolean(config.nativeAddon), + }, + paths: { nodeSdk, nativeAddon }, + performsNetworkCalls: false, + runsBenchmarks: false, + }, + }); + return 0; + } + + const eligibility = executionGuard(config, metadata); + if (command === "rust") { + const rust = await runRust(config); + await writeJson(config.output, { + ...base, + eligibility, + quality: { + valid: rust.failures.length === 0, + issues: rust.failures, + }, + rust, + }); + return rust.failures.length ? 1 : 0; + } + if (config.scenarios.includes("recovery") && !config.recoveryControlDir) { + throw new Error( + "--recovery-control-dir is required when selecting the recovery scenario", + ); + } + await assertLiveInputs(config); + const live = await runLiveIsolated(config); + const qualityIssues = liveQualityIssues(live); + await writeJson(config.output, { + ...base, + eligibility, + quality: { + valid: qualityIssues.length === 0, + issues: qualityIssues, + }, + live, + }); + return qualityIssues.length ? 1 : 0; +}; + +try { + process.exitCode = await main(); +} catch (error) { + process.stderr.write( + `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); + process.exitCode = 1; +} diff --git a/benchmarks/rtc/support.mjs b/benchmarks/rtc/support.mjs new file mode 100644 index 00000000..2e1cf604 --- /dev/null +++ b/benchmarks/rtc/support.mjs @@ -0,0 +1,486 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { + arch, + cpus, + hostname, + loadavg, + platform, + release, + type, +} from "node:os"; +import { dirname, relative, resolve } from "node:path"; +import { performance, monitorEventLoopDelay } from "node:perf_hooks"; +import { spawn } from "node:child_process"; + +export const SCHEMA_VERSION = 1; +export const CANONICAL_PLATFORM = "linux"; +export const CANONICAL_ARCH = "x64"; +export const WARN_LOAD_PER_CPU = 0.25; +export const ABORT_LOAD_PER_CPU = 0.75; + +const text = async (path) => (await readFile(path, "utf8")).trim(); + +const commandOutput = async (command, args, options = {}) => { + const result = await runCommand(command, args, { + ...options, + timeoutMs: options.timeoutMs ?? 30_000, + }); + return result.exitCode === 0 ? result.stdout.trim() : undefined; +}; + +const git = (repo, args) => + commandOutput("git", ["-C", repo, ...args], { timeoutMs: 30_000 }); + +const cpuModel = async () => { + if (platform() === "linux") { + const cpuinfo = await text("/proc/cpuinfo"); + const line = cpuinfo + .split("\n") + .find((entry) => entry.startsWith("model name")); + if (line) return line.split(":").slice(1).join(":").trim(); + } + return cpus()[0]?.model ?? `${type()} ${arch()}`; +}; + +const osDescription = async () => { + if (platform() !== "linux") return `${type()} ${release()}`; + const osRelease = await text("/etc/os-release"); + const prettyName = osRelease + .split("\n") + .find((entry) => entry.startsWith("PRETTY_NAME=")) + ?.slice("PRETTY_NAME=".length) + .replace(/^"|"$/g, ""); + return prettyName ?? `${type()} ${release()}`; +}; + +const fileHash = async (path) => { + const hash = createHash("sha256"); + hash.update(await readFile(path)); + return `sha256:${hash.digest("hex")}`; +}; + +const repoMetadata = async (path, expectedRef) => { + const absolute = resolve(path); + const [sha, branch, status, resolvedExpectedRef] = await Promise.all([ + git(absolute, ["rev-parse", "HEAD"]), + git(absolute, ["branch", "--show-current"]), + git(absolute, ["status", "--porcelain=v1", "--untracked-files=all"]), + expectedRef + ? git(absolute, ["rev-parse", `${expectedRef}^{commit}`]) + : Promise.resolve(undefined), + ]); + if (!sha) throw new Error(`not a readable Git repository: ${absolute}`); + if (expectedRef && resolvedExpectedRef !== sha) { + throw new Error( + `${absolute} is at ${sha}, not requested ref ${expectedRef} (${resolvedExpectedRef ?? "unresolved"})`, + ); + } + return { + path: absolute, + sha, + branch: branch || null, + expectedRef: expectedRef ?? null, + dirty: Boolean(status), + changes: status ? status.split("\n") : [], + }; +}; + +const nativeAddonMetadata = async (path) => { + if (!path) return null; + const absolute = resolve(path); + const details = await stat(absolute); + if (!details.isFile()) + throw new Error(`native addon is not a file: ${absolute}`); + return { + path: absolute, + bytes: details.size, + hash: await fileHash(absolute), + }; +}; + +export const loadSnapshot = () => { + const [load1, load5, load15] = loadavg(); + const cpuCount = cpus().length || 1; + return { + load1, + load5, + load15, + cpuCount, + loadPerCpu: load1 / cpuCount, + }; +}; + +export const assertCanonicalHost = ({ allowBusy = false } = {}) => { + if (platform() !== CANONICAL_PLATFORM || arch() !== CANONICAL_ARCH) { + throw new Error( + `benchmark execution requires ${CANONICAL_PLATFORM}/${CANONICAL_ARCH}; this host is ${platform()}/${arch()}`, + ); + } + const load = loadSnapshot(); + if (!allowBusy && load.loadPerCpu >= ABORT_LOAD_PER_CPU) { + throw new Error( + `load ${load.load1.toFixed(2)} across ${load.cpuCount} CPUs (${load.loadPerCpu.toFixed(2)}/CPU) exceeds ${ABORT_LOAD_PER_CPU.toFixed(2)}/CPU`, + ); + } + return load; +}; + +export const collectMetadata = async (config) => { + const [rustRepo, nodeRepo, rustc, cargo, cpu, operatingSystem, addon] = + await Promise.all([ + repoMetadata(config.rustRepo, config.rustRef), + repoMetadata(config.nodeRepo, config.nodeRef), + commandOutput("rustc", ["-Vv"]), + commandOutput("cargo", ["-V"]), + cpuModel(), + osDescription(), + nativeAddonMetadata(config.nativeAddon), + ]); + const load = loadSnapshot(); + const hostFingerprint = createHash("sha256") + .update( + JSON.stringify({ + hostname: hostname(), + platform: platform(), + arch: arch(), + release: release(), + cpu, + cpuCount: load.cpuCount, + }), + ) + .digest("hex"); + return { + capturedAt: new Date().toISOString(), + runId: config.runId, + label: config.label, + host: { + class: + platform() === CANONICAL_PLATFORM && arch() === CANONICAL_ARCH + ? "linux-x86_64" + : "non-canonical", + fingerprint: `sha256:${hostFingerprint}`, + platform: platform(), + arch: arch(), + release: release(), + operatingSystem, + cpu, + cpuCount: load.cpuCount, + load, + }, + repositories: { + rust: rustRepo, + node: nodeRepo, + }, + nativeAddon: addon, + tools: { + node: process.version, + v8: process.versions.v8, + napi: process.versions.napi ?? null, + rustc: rustc ?? null, + cargo: cargo ?? null, + }, + sfu: { + location: config.sfuLocation, + profile: config.sfuProfile, + }, + networkProfile: config.networkProfile, + }; +}; + +export const assertCleanRepositories = (metadata) => { + const dirty = Object.entries(metadata.repositories) + .filter(([, repository]) => repository.dirty) + .map(([name]) => name); + if (dirty.length) { + throw new Error( + `benchmark refs must be clean; dirty repositories: ${dirty.join(", ")}`, + ); + } +}; + +export const assertLiveInputs = async (config) => { + const missing = ["STREAM_API_KEY", "STREAM_SECRET"].filter( + (name) => !process.env[name], + ); + if (missing.length) { + throw new Error(`missing required live credentials: ${missing.join(", ")}`); + } + if (!config.nativeAddon) { + throw new Error( + "STREAM_NODE_RTC_NATIVE_PATH or --native-addon is required for live runs", + ); + } + if (!config.nodeSdk) { + throw new Error("--node-sdk must point to the candidate dist/index.es.mjs"); + } + if (!config.sfuLocation || !config.sfuProfile) { + throw new Error( + "--sfu-location and --sfu-profile are required for live runs", + ); + } + const [addon, sdk] = await Promise.all([ + stat(resolve(config.nativeAddon)), + stat(resolve(config.nodeSdk)), + ]); + if (!addon.isFile()) throw new Error("native addon path is not a file"); + if (!sdk.isFile()) throw new Error("Node SDK module path is not a file"); +}; + +export const writeJson = async (path, payload) => { + const serialized = `${JSON.stringify(payload, null, 2)}\n`; + if (!path) { + process.stdout.write(serialized); + return; + } + const absolute = resolve(path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, serialized); + process.stderr.write(`wrote ${absolute}\n`); +}; + +export const withTimeout = async (label, timeoutMs, operation) => { + let timer; + try { + return await Promise.race([ + Promise.resolve().then(operation), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +}; + +export const sleep = (milliseconds) => + new Promise((resolvePromise) => + setTimeout(resolvePromise, Math.max(0, milliseconds)), + ); + +export const waitFor = async (label, predicate, { timeoutMs, pollMs = 25 }) => { + const deadline = performance.now() + timeoutMs; + while (performance.now() < deadline) { + if (await predicate()) return; + await sleep(pollMs); + } + throw new Error(`${label} timed out after ${timeoutMs}ms`); +}; + +export const uniqueId = (prefix, runId) => + `${prefix}-${runId.slice(0, 8)}-${randomUUID().slice(0, 12)}`; + +export const percentile = (samples, fraction) => { + if (!samples.length) return null; + const values = [...samples].sort((left, right) => left - right); + const position = (values.length - 1) * fraction; + const lower = Math.floor(position); + const upper = Math.min(lower + 1, values.length - 1); + const weight = position - lower; + return values[lower] + (values[upper] - values[lower]) * weight; +}; + +export const summarize = (samples, unit) => { + const values = samples.filter(Number.isFinite); + if (!values.length) { + return { + unit, + n: 0, + samples: [], + min: null, + p50: null, + p90: null, + p95: null, + p99: null, + max: null, + mean: null, + }; + } + return { + unit, + n: values.length, + samples: values, + min: Math.min(...values), + p50: percentile(values, 0.5), + p90: percentile(values, 0.9), + p95: percentile(values, 0.95), + p99: percentile(values, 0.99), + max: Math.max(...values), + mean: values.reduce((total, value) => total + value, 0) / values.length, + }; +}; + +const readLinuxThreads = async () => { + if (platform() !== "linux") return null; + const status = await text("/proc/self/status"); + const line = status.split("\n").find((entry) => entry.startsWith("Threads:")); + return line ? Number(line.split(/\s+/)[1]) : null; +}; + +const activeHandles = () => { + const getter = process._getActiveHandles; + if (typeof getter !== "function") return null; + const counts = {}; + for (const handle of getter.call(process)) { + const name = handle?.constructor?.name ?? "Unknown"; + counts[name] = (counts[name] ?? 0) + 1; + } + return { + count: Object.values(counts).reduce((total, value) => total + value, 0), + byType: counts, + }; +}; + +export class ResourceMonitor { + constructor(intervalMs = 1_000) { + this.intervalMs = intervalMs; + this.samples = []; + this.delay = monitorEventLoopDelay({ resolution: 20 }); + } + + async start() { + this.startedAt = performance.now(); + this.startedCpu = process.cpuUsage(); + this.delay.enable(); + await this.capture(); + this.timer = setInterval(() => void this.capture(), this.intervalMs); + } + + async capture() { + const memory = process.memoryUsage(); + this.samples.push({ + elapsedMs: performance.now() - this.startedAt, + cpu: process.cpuUsage(this.startedCpu), + memory, + threads: await readLinuxThreads(), + activeHandles: activeHandles(), + }); + } + + async stop() { + clearInterval(this.timer); + await this.capture(); + this.delay.disable(); + const wallMs = performance.now() - this.startedAt; + const cpu = process.cpuUsage(this.startedCpu); + const cpuMs = (cpu.user + cpu.system) / 1_000; + const memoryKeys = [ + "rss", + "heapTotal", + "heapUsed", + "external", + "arrayBuffers", + ]; + const memory = Object.fromEntries( + memoryKeys.map((key) => [ + key, + summarize( + this.samples.map((sample) => sample.memory[key]), + "bytes", + ), + ]), + ); + return { + wallMs, + cpuMs, + cpuPercentOfOneCore: wallMs > 0 ? (cpuMs / wallMs) * 100 : null, + memory, + threads: summarize( + this.samples.map((sample) => sample.threads), + "count", + ), + activeHandleCount: summarize( + this.samples.map((sample) => sample.activeHandles?.count), + "count", + ), + eventLoopDelayMs: { + min: this.delay.min / 1e6, + p50: this.delay.percentile(50) / 1e6, + p95: this.delay.percentile(95) / 1e6, + p99: this.delay.percentile(99) / 1e6, + max: this.delay.max / 1e6, + mean: this.delay.mean / 1e6, + }, + samples: this.samples, + }; + } +} + +export const runCommand = ( + command, + args, + { cwd, env, timeoutMs = 15 * 60_000 } = {}, +) => + new Promise((resolvePromise, reject) => { + const started = performance.now(); + let timedOut = false; + const child = spawn(command, args, { + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 5_000).unref(); + }, timeoutMs); + child.once("error", reject); + child.once("close", (exitCode, signal) => { + clearTimeout(timer); + resolvePromise({ + command: [command, ...args], + cwd: cwd ?? process.cwd(), + exitCode, + signal, + timedOut, + durationMs: performance.now() - started, + stdout, + stderr, + }); + }); + }); + +const findFiles = async (root, name) => { + const results = []; + const entries = await readdir(root, { withFileTypes: true }); + for (const entry of entries) { + const path = resolve(root, entry.name); + if (entry.isDirectory()) results.push(...(await findFiles(path, name))); + else if (entry.name === name) results.push(path); + } + return results; +}; + +export const collectCriterionEstimates = async (rustRepo, startedAt) => { + const criterionRoot = resolve(rustRepo, "target/criterion"); + let files; + try { + files = await findFiles(criterionRoot, "estimates.json"); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } + const estimates = []; + for (const path of files) { + const details = await stat(path); + if (details.mtimeMs + 2_000 < startedAt) continue; + estimates.push({ + path: relative(rustRepo, path), + estimates: JSON.parse(await readFile(path, "utf8")), + }); + } + return estimates.sort((left, right) => left.path.localeCompare(right.path)); +}; From 4afd26b4d98bbb4a55d56449159dee393484cf4f Mon Sep 17 00:00:00 2001 From: "Neevash Ramdial (Nash)" Date: Thu, 27 Aug 2026 22:02:07 -0400 Subject: [PATCH 5/7] Potential fix for pull request finding 'CodeQL / Clear-text logging of sensitive information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- examples/rtc-echo-agent.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/rtc-echo-agent.mjs b/examples/rtc-echo-agent.mjs index 9b8e0f51..d8f418b9 100644 --- a/examples/rtc-echo-agent.mjs +++ b/examples/rtc-echo-agent.mjs @@ -92,7 +92,7 @@ process.on("SIGTERM", () => void shutdown()); await call.join({ userId }); await call.publishAudio(output); -console.log(`joined ${callType}:${callId} as ${userId}`); +console.log("joined call as agent"); console.log(`session: ${call.state.sessionId}`); console.log("waiting for participants — Ctrl+C to leave"); From 2409c0919319c636021e273f18eff67132a7badd Mon Sep 17 00:00:00 2001 From: "Neevash Ramdial (Nash)" Date: Thu, 27 Aug 2026 22:02:14 -0400 Subject: [PATCH 6/7] Potential fix for pull request finding 'CodeQL / Clear-text logging of sensitive information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- examples/rtc-neon-agent.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/rtc-neon-agent.mjs b/examples/rtc-neon-agent.mjs index b5ca4d90..b5d73496 100644 --- a/examples/rtc-neon-agent.mjs +++ b/examples/rtc-neon-agent.mjs @@ -110,7 +110,7 @@ await call.updateSubscriptions({ audio: true, video: true }); await call.publishAudio(audioOutput); await call.publishVideo(videoOutput); -console.log(`joined ${callType}:${callId} as ${userId}`); +console.log("joined call"); console.log(`session: ${call.state.sessionId}`); console.log("publishing robot audio + Neon Time-Slice video — Ctrl+C to leave"); From 18fa26ea9244905c4d4d0a264fcd690dc98ebe5b Mon Sep 17 00:00:00 2001 From: "Neevash Ramdial (Nash)" Date: Fri, 28 Aug 2026 16:05:39 -0400 Subject: [PATCH 7/7] docs: fix server-side RTC setup guide Document the required local builds and receive-driven video behavior so fresh checkouts do not produce misleading blank agent tiles. --- docs/server-side-rtc.md | 46 +++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/server-side-rtc.md b/docs/server-side-rtc.md index 6cd9d8a3..428c3b06 100644 --- a/docs/server-side-rtc.md +++ b/docs/server-side-rtc.md @@ -5,13 +5,13 @@ joins a Stream call as a real participant, receives and manipulates remote media, and publishes media back. **Nothing here is published.** The native addon is built locally from the -`feat/python-rtc-bindings` branch of `stream-video-rust` and loaded through an +`feat/rtc-bindings` branch of `stream-video-rust` and loaded through an environment variable. There is no npm dependency, no release, and no change to either repository's `main`. ## 1. Build the Rust addon -From your `stream-video-rust` checkout, on `feat/python-rtc-bindings`: +From your `stream-video-rust` checkout, on `feat/rtc-bindings`: ```bash node bindings/node/scripts/build-local.mjs @@ -28,7 +28,18 @@ node bindings/node/scripts/smoke.mjs The media stack is statically linked — no system `libvpx` is required. -## 2. Point the Node SDK at it +## 2. Build and configure the Node SDK + +From your `stream-node` checkout, install dependencies and build the package. +The examples import `@stream-io/node-sdk`, which resolves to files in `dist`; +a fresh checkout does not contain those generated files. + +```bash +yarn install --immutable +yarn build +``` + +Then point the Node SDK at the addon: ```bash export STREAM_NODE_RTC_NATIVE_PATH=/absolute/path/to/stream-node-rtc.node @@ -48,7 +59,7 @@ Three failures are reported distinctly, so you can tell them apart: ## 3. Run the tests ```bash -yarn vitest run __tests__/rtc +yarn test:rtc ``` The RTC path spans JavaScript, a native addon, and Stream's SFU. Too much of it @@ -107,13 +118,27 @@ STREAM_NODE_RTC_NATIVE_PATH=/abs/path/to/stream-node-rtc.node \ Join the same call from any Stream client and publish camera and microphone. The agent appears as a second participant carrying the processed tracks. +Its output is receive-driven: it publishes an empty video track when it joins, +then writes transformed frames only after it receives camera video. A blank +agent tile is therefore expected while the other participant's camera is off. +The viewing client must also subscribe to the agent's video. + +When video is flowing, the agent logs `neon time-slice processing: `. +If that message does not appear after the camera is enabled, verify that both +participants joined the same call and that the agent's video subscription +completed. + `EXAMPLE_USER_ID`, `EXAMPLE_CALL_TYPE`, and `EXAMPLE_CALL_ID` override the defaults. [`examples/rtc-echo-agent.mjs`](../examples/rtc-echo-agent.mjs) remains the smallest audio-only example when visual processing is not needed. -## The happy path +## Minimal audio-only happy path + +This example intentionally processes audio only. Use the Neon example above +for decoded video, `updateSubscriptions({ audio: true, video: true })`, and +video republishing. ```ts import { LocalAudioTrack, StreamClient } from "@stream-io/node-sdk"; @@ -206,9 +231,13 @@ after a revoke it can still list the capability. Check `currentGrants` when you need to know whether publishing will be allowed: ```ts -if (call.state.currentGrants?.canPublishAudio) { +const grants = call.state.currentGrants; +if (grants?.canPublishAudio) { await call.publishAudio(track); } +if (grants?.canPublishVideo) { + await call.publishVideo(videoTrack); +} ``` - `requestPermissions({ permissions })` asks the call owner; other participants @@ -250,6 +279,11 @@ await call.join({ userId: agentUserId }); await call.publishVideo(LocalVideoTrack.vp9({ targetBitrateBps: 600_000 })); ``` +When publishing H264 from I420 frames, each `durationMs` must be at least one +thirtieth of a second. Use `durationMs: 34` when expressing the duration as an +integer; `33` is too short and is rejected. The Neon example publishes VP9, so +its `durationMs: 33` does not have this H264 constraint. + ## Pacing your writes `writePcm` and `writeI420` return as soon as the frame is queued, not when it is