diff --git a/.changeset/olive-clouds-taste.md b/.changeset/olive-clouds-taste.md new file mode 100644 index 00000000..562f50d7 --- /dev/null +++ b/.changeset/olive-clouds-taste.md @@ -0,0 +1,5 @@ +--- +"@livekit/rtc-node": patch +--- + +feat: add support for SimulateScenario diff --git a/packages/livekit-rtc/package.json b/packages/livekit-rtc/package.json index e9501da1..37a87804 100644 --- a/packages/livekit-rtc/package.json +++ b/packages/livekit-rtc/package.json @@ -33,7 +33,7 @@ "@datastructures-js/deque": "1.0.8", "@livekit/mutex": "^1.0.0", "@livekit/typed-emitter": "^3.0.0", - "@livekit/rtc-ffi-bindings": "0.12.53", + "@livekit/rtc-ffi-bindings": "0.12.54", "pino": "^9.0.0", "pino-pretty": "^13.0.0" }, @@ -53,6 +53,7 @@ "prebuild": "node -p \"'export const SDK_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts", "build": "pnpm prebuild && tsup --onSuccess \"tsc --declaration --emitDeclarationOnly\"", "lint": "eslint -f unix \"src/**/*.ts\" --ignore-pattern \"src/proto/*\"", - "test": "vitest run src" + "test": "vitest run src", + "test:e2e": "node scripts/run-e2e.mjs" } } diff --git a/packages/livekit-rtc/scripts/run-e2e.mjs b/packages/livekit-rtc/scripts/run-e2e.mjs new file mode 100644 index 00000000..c92f41b4 --- /dev/null +++ b/packages/livekit-rtc/scripts/run-e2e.mjs @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +// Spins up `livekit-server --dev` with a known dev key, runs the e2e +// vitest suite against it, then tears the server down on exit. +// +// Requires `livekit-server` on PATH. +import { spawn } from 'node:child_process'; +import net from 'node:net'; +import { setTimeout as delay } from 'node:timers/promises'; + +const KEYS = 'devkey: secret'; +const HOST = '127.0.0.1'; +const PORT = 7880; +const URL = `ws://${HOST}:${PORT}`; +const API_KEY = 'devkey'; +const API_SECRET = 'secret'; + +async function tcpReady(host, port, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const ok = await new Promise((resolve) => { + const s = net.createConnection({ host, port }); + s.once('connect', () => { + s.end(); + resolve(true); + }); + s.once('error', () => resolve(false)); + }); + if (ok) return; + await delay(200); + } + throw new Error(`livekit-server not reachable at ${host}:${port} within ${timeoutMs}ms`); +} + +async function isPortOpen(host, port) { + return new Promise((resolve) => { + const s = net.createConnection({ host, port }); + s.once('connect', () => { + s.end(); + resolve(true); + }); + s.once('error', () => resolve(false)); + }); +} + +const reuseExisting = await isPortOpen(HOST, PORT); +let server; +let serverExited = !reuseExisting ? false : true; +if (reuseExisting) { + console.log(`[run-e2e] reusing existing livekit-server on ${HOST}:${PORT}`); +} else { + server = spawn('livekit-server', ['--dev'], { + env: { ...process.env, LIVEKIT_KEYS: KEYS }, + stdio: ['ignore', 'inherit', 'inherit'], + }); + server.on('exit', (code, signal) => { + serverExited = true; + if (code && code !== 0 && signal !== 'SIGTERM') { + console.error(`livekit-server exited unexpectedly: code=${code} signal=${signal}`); + } + }); +} + +let testProc; +const stopServer = () => { + if (server && !serverExited) server.kill('SIGTERM'); +}; +const onSignal = (sig) => { + if (testProc && !testProc.killed) testProc.kill(sig); + stopServer(); +}; +process.on('SIGINT', () => onSignal('SIGINT')); +process.on('SIGTERM', () => onSignal('SIGTERM')); + +try { + await tcpReady(HOST, PORT, 15_000); + + const args = ['exec', 'vitest', 'run', 'src/tests/e2e.test.ts', ...process.argv.slice(2)]; + testProc = spawn('pnpm', args, { + env: { + ...process.env, + LIVEKIT_URL: URL, + LIVEKIT_API_KEY: API_KEY, + LIVEKIT_API_SECRET: API_SECRET, + }, + stdio: 'inherit', + }); + const code = await new Promise((resolve) => testProc.on('exit', resolve)); + process.exitCode = code ?? 0; +} catch (err) { + console.error(err); + process.exitCode = 1; +} finally { + stopServer(); +} diff --git a/packages/livekit-rtc/src/index.ts b/packages/livekit-rtc/src/index.ts index adfa0227..7d131a3a 100644 --- a/packages/livekit-rtc/src/index.ts +++ b/packages/livekit-rtc/src/index.ts @@ -29,7 +29,12 @@ export { IceTransportType, TrackPublishOptions, } from '@livekit/rtc-ffi-bindings'; -export { StreamState, TrackKind, TrackSource } from '@livekit/rtc-ffi-bindings'; +export { + SimulateScenarioKind, + StreamState, + TrackKind, + TrackSource, +} from '@livekit/rtc-ffi-bindings'; export { VideoBufferType, VideoCodec, VideoRotation } from '@livekit/rtc-ffi-bindings'; export { ConnectError, Room, RoomEvent, type RoomOptions, type RtcConfiguration } from './room.js'; export { RpcError, type PerformRpcParams, type RpcInvocationData } from './rpc.js'; diff --git a/packages/livekit-rtc/src/room.ts b/packages/livekit-rtc/src/room.ts index db80fe22..0fc362d6 100644 --- a/packages/livekit-rtc/src/room.ts +++ b/packages/livekit-rtc/src/room.ts @@ -21,6 +21,9 @@ import { type IceServer, IceTransportType, type RoomInfo, + type SimulateScenarioCallback, + type SimulateScenarioKind, + type SimulateScenarioResponse, } from '@livekit/rtc-ffi-bindings'; import { TrackKind } from '@livekit/rtc-ffi-bindings'; import type { TypedEventEmitter as TypedEmitter } from '@livekit/typed-emitter'; @@ -325,6 +328,36 @@ export class Room extends (EventEmitter as new () => TypedEmitter this.removeAllListeners(); } + /** + * Trigger a reconnection / chaos scenario for testing. Most useful in + * tests to deterministically force a Resume (signal-only reconnect that + * preserves the PeerConnection and existing publications) or a full + * reconnect (the SDK rebuilds the RtcSession and re-publishes existing + * local tracks; `RoomEvent.Reconnected` fires). + */ + async simulateScenario(scenario: SimulateScenarioKind): Promise { + if (!this.isConnected || !this.ffiHandle) { + throw new Error('simulateScenario requires a connected room'); + } + const res = FfiClient.instance.request({ + message: { + case: 'simulateScenario', + value: { + roomHandle: this.ffiHandle.handle, + scenario, + }, + }, + }); + const cb = await FfiClient.instance.waitFor( + (ev: FfiEvent) => + ev.message.case === 'simulateScenario' && ev.message.value.asyncId === res.asyncId, + { signal: this.disconnectController.signal }, + ); + if (cb.error) { + throw new Error(`simulateScenario failed: ${cb.error}`); + } + } + private updateConnectionState(newState: ConnectionState) { if (this._connectionState === newState) { return; diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index 4130de2b..b71977a7 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -15,6 +15,8 @@ import { Room, RoomEvent, RpcError, + SimulateScenarioKind, + TrackKind, TrackPublishOptions, TrackSource, dispose, @@ -682,4 +684,155 @@ describeE2E('livekit-rtc e2e', () => { }, testTimeoutMs, ); + + // -- Reconnect scenarios -- + // + // Both tests verify the user-visible behavior: after the scenario fires, + // the subscriber continues to receive the publisher's tone. The full + // reconnect test additionally asserts there is exactly one audio + // publication on each side (regression: duplicate-publish bug). + + const runReconnectScenario = async (scenario: SimulateScenarioKind) => { + const { rooms } = await connectTestRooms(2); + const [subRoom, pubRoom] = rooms; + + const pubRateHz = 48_000; + const source = new AudioSource(pubRateHz, 1); + const track = LocalAudioTrack.createAudioTrack('reconnect_tone', source); + const opts = new TrackPublishOptions(); + opts.source = TrackSource.SOURCE_MICROPHONE; + await pubRoom!.localParticipant!.publishTrack(track, opts); + + let tonePhase = 0; + const samplesPer10ms = Math.floor(pubRateHz / 100); + const amplitude = 0.8 * 32767; + const sineHz = 60; + let toneRunning = true; + const toneTask = (async () => { + while (toneRunning) { + const frame = AudioFrame.create(pubRateHz, 1, samplesPer10ms); + for (let s = 0; s < samplesPer10ms; s++) { + frame.data[s] = Math.round( + amplitude * Math.sin((2 * Math.PI * sineHz * tonePhase) / pubRateHz), + ); + tonePhase++; + } + await source.captureFrame(frame); + } + })(); + + // Subscriber-side: re-attach an AudioStream every time TrackSubscribed + // fires (a full reconnect may issue TrackUnsubscribed → TrackSubscribed + // with a fresh remote track). + const sub = { + lastFrameAt: 0, + collectFromMs: Number.POSITIVE_INFINITY, + collected: [] as Int16Array[], + readers: [] as ReturnType[], + }; + const attach = (remoteTrack: unknown) => { + const stream = new AudioStream(remoteTrack as any, { + sampleRate: pubRateHz, + numChannels: 1, + }); + const reader = stream.getReader(); + sub.readers.push(reader); + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + sub.lastFrameAt = Date.now(); + if (sub.lastFrameAt >= sub.collectFromMs) { + sub.collected.push(channelSamples(value, 0)); + } + } + } catch { + // reader released + } + })(); + }; + subRoom!.on(RoomEvent.TrackSubscribed, (t) => attach(t)); + + try { + await waitFor(() => sub.lastFrameAt > 0 && Date.now() - sub.lastFrameAt < 500, { + timeoutMs: 10_000, + debugName: 'initial audio flow', + }); + + const simulateAt = Date.now(); + await pubRoom!.simulateScenario(scenario); + + // Wait for audio to actually flow again post-simulate: a frame + // received well after the simulate AND a fresh latest-frame timestamp. + await waitFor( + () => sub.lastFrameAt >= simulateAt + 500 && Date.now() - sub.lastFrameAt < 300, + { timeoutMs: 30_000, debugName: 'audio re-established after simulate' }, + ); + // Drain post-recovery buffer/jitter, then collect a 2s window of + // steady-state samples for tone detection. + await delay(1_500); + sub.collected.length = 0; + sub.collectFromMs = Date.now(); + await waitFor(() => sub.collected.reduce((a, s) => a + s.length, 0) >= pubRateHz * 2, { + timeoutMs: 15_000, + debugName: 'post-simulate audio sampling', + }); + + const totalLen = sub.collected.reduce((a, s) => a + s.length, 0); + const concat = new Int16Array(totalLen); + let off = 0; + for (const s of sub.collected) { + concat.set(s, off); + off += s.length; + } + const detected = estimateFreqHz(concat, pubRateHz); + expect(Math.abs(detected - sineHz)).toBeLessThan(20); + + return { rooms, subRoom: subRoom!, pubRoom: pubRoom! }; + } finally { + toneRunning = false; + await toneTask; + for (const r of sub.readers) { + try { + r.releaseLock(); + } catch { + // ignore + } + } + await track.close(); + } + }; + + itRaw( + 'resume keeps audio flowing on the subscriber side', + async () => { + const { rooms } = await runReconnectScenario(SimulateScenarioKind.SIMULATE_SIGNAL_RECONNECT); + await Promise.all(rooms.map((r) => r.disconnect())); + }, + testTimeoutMs * 4, + ); + + itRaw( + 'full reconnect keeps audio flowing and ends with one publication on the subscriber', + async () => { + const { rooms, subRoom, pubRoom } = await runReconnectScenario( + SimulateScenarioKind.SIMULATE_FULL_RECONNECT, + ); + + try { + // Regression: subscriber must see exactly ONE audio publication after + // recovery — not duplicates from the auto-republish path. + const subscriberAudioPubs = Array.from( + subRoom.remoteParticipants + .get(pubRoom.localParticipant!.identity)! + .trackPublications.values(), + ).filter((p) => p.kind === TrackKind.KIND_AUDIO); + expect(subscriberAudioPubs.length).toBe(1); + } finally { + await Promise.all(rooms.map((r) => r.disconnect())); + } + }, + testTimeoutMs * 4, + ); }); diff --git a/packages/livekit-server-sdk/src/SipClient.ts b/packages/livekit-server-sdk/src/SipClient.ts index 8aaa2160..1291fe86 100644 --- a/packages/livekit-server-sdk/src/SipClient.ts +++ b/packages/livekit-server-sdk/src/SipClient.ts @@ -7,6 +7,8 @@ import type { Pagination, RoomConfiguration, SIPHeaderOptions, + SIPMediaEncryption, + SIPOutboundConfig, } from '@livekit/protocol'; import { CreateSIPDispatchRuleRequest, @@ -29,8 +31,6 @@ import { SIPDispatchRuleIndividual, SIPDispatchRuleInfo, SIPInboundTrunkInfo, - SIPMediaEncryption, - SIPOutboundConfig, SIPOutboundTrunkInfo, SIPParticipantInfo, SIPTransport, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61fd68f1..4d3babbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,8 +210,8 @@ importers: specifier: ^1.0.0 version: 1.1.1 '@livekit/rtc-ffi-bindings': - specifier: 0.12.53 - version: 0.12.53 + specifier: 0.12.54 + version: 0.12.54 '@livekit/typed-emitter': specifier: ^3.0.0 version: 3.0.0 @@ -965,40 +965,40 @@ packages: '@livekit/protocol@1.45.6': resolution: {integrity: sha512-YPDmrUiVe1EY/q/2bD+Fp+69DWq6LZgeH+G/KEbz07OIVf8hgAYzfb1FgiOdWLRpSj06+SuTmrOY604fWNuD3w==} - '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.53': - resolution: {integrity: sha512-3feHNEK9vcMpE5X24JLm85hxNplhAnREv5HVOwsu3vTgUXR0P4ZtKO4je9vdM0DdE2vmSYBO95oMMKyt3yGr6g==} + '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.54': + resolution: {integrity: sha512-uxBBBGDGUZFTpMtTDb8/YUUIMxs6IqxrZ1Nz3NKKXBw7MHHmzSOPbl8gGIGsGbsoVS3fTJMnnv9WZuwVqxdI6A==} engines: {node: '>= 18'} cpu: [arm64] os: [darwin] - '@livekit/rtc-ffi-bindings-darwin-x64@0.12.53': - resolution: {integrity: sha512-g3AOfaG4uUxAQklv6mrD/1ABMF/rJysXcaUOqemjaVDJ//ItyXr5pCou8Z3L8lxRwBW7kYKVuimMeaEMnJAbgw==} + '@livekit/rtc-ffi-bindings-darwin-x64@0.12.54': + resolution: {integrity: sha512-QMDEKMehW6hZ87ADQC0a7ZWAPzzivSERW+dnVuUFf+DPMFs3yvSfD2aoPGHbrS5/8XU5UJKaCwWWjIR94dPUDw==} engines: {node: '>= 18'} cpu: [x64] os: [darwin] - '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.53': - resolution: {integrity: sha512-TSaavEfqnlbqJ47gsjWMABc7payG7eAZRMW6GmsmwML7gHzIYCZrBdNfgAIR2dE4bMPd6LySjbY19SX2N0vhYw==} + '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.54': + resolution: {integrity: sha512-7qrjk0izQy5ojVOZSADClvYOD25FtHXYardn3ZSufidn7fI6cykWv+/1RF51wg2M5xh7G6603aT8Hv4+u6dzog==} engines: {node: '>= 18'} cpu: [arm64] os: [linux] libc: [glibc] - '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.53': - resolution: {integrity: sha512-8cV1XXCT22uj4LMk7gVKgmETk0RXhD/UoZoZC8dpUTkUlhgRIp4JQ0jYMkWMLiWvyUsdf0PHIaX9GoNxeix4Hw==} + '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.54': + resolution: {integrity: sha512-uOrC9DtebbZv4ojInt9eHyvy2O517mFgIxwbR/XewoKA2ICOlmCkhKUaASYIiFSDEfQxmtsuWZP4HH8/q6cOFA==} engines: {node: '>= 18'} cpu: [x64] os: [linux] libc: [glibc] - '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.53': - resolution: {integrity: sha512-MTIO0OtwRF1qSq/3HkIr5FDmF25/kWPAPLycovGgjr4m+SHQs+7NezwVc07IklmfON0Qkh8bWQL56tHd+s1Qjw==} + '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.54': + resolution: {integrity: sha512-+BE0c1p58Q/wuxpViCOOpjmE9nffxxLNog6y27QyhKLRHqetHWV+yhSAYXGSEcUIoOwJuFS/WOxSYjf3OYWqpQ==} engines: {node: '>= 18'} cpu: [x64] os: [win32] - '@livekit/rtc-ffi-bindings@0.12.53': - resolution: {integrity: sha512-zHf1Bxrcm7/k1kOwKQvoTuexydGVWTqYUhUDGSYamuWuEQKKnuUw8UV5uA8xfk/F3aXUOyzWF01JIgjLhlQUKw==} + '@livekit/rtc-ffi-bindings@0.12.54': + resolution: {integrity: sha512-sZrhkwFO9RjEiqZSaPjOBvI/dXURpNJH1tmK3P7nEI3vxAOPaoaQfeFzDVvV1hulJb2qGkjFSbVXxMtuA28HvA==} engines: {node: '>= 18'} '@livekit/typed-emitter@3.0.0': @@ -4624,30 +4624,30 @@ snapshots: dependencies: '@bufbuild/protobuf': 1.10.1 - '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.53': + '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.54': optional: true - '@livekit/rtc-ffi-bindings-darwin-x64@0.12.53': + '@livekit/rtc-ffi-bindings-darwin-x64@0.12.54': optional: true - '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.53': + '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.54': optional: true - '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.53': + '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.54': optional: true - '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.53': + '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.54': optional: true - '@livekit/rtc-ffi-bindings@0.12.53': + '@livekit/rtc-ffi-bindings@0.12.54': dependencies: '@bufbuild/protobuf': 1.10.1 optionalDependencies: - '@livekit/rtc-ffi-bindings-darwin-arm64': 0.12.53 - '@livekit/rtc-ffi-bindings-darwin-x64': 0.12.53 - '@livekit/rtc-ffi-bindings-linux-arm64-gnu': 0.12.53 - '@livekit/rtc-ffi-bindings-linux-x64-gnu': 0.12.53 - '@livekit/rtc-ffi-bindings-win32-x64-msvc': 0.12.53 + '@livekit/rtc-ffi-bindings-darwin-arm64': 0.12.54 + '@livekit/rtc-ffi-bindings-darwin-x64': 0.12.54 + '@livekit/rtc-ffi-bindings-linux-arm64-gnu': 0.12.54 + '@livekit/rtc-ffi-bindings-linux-x64-gnu': 0.12.54 + '@livekit/rtc-ffi-bindings-win32-x64-msvc': 0.12.54 '@livekit/typed-emitter@3.0.0': {}