From 07b87d48cab1cd776a0106a639f866fde75b52ed Mon Sep 17 00:00:00 2001 From: David Zhao Date: Sat, 9 May 2026 23:25:53 -0700 Subject: [PATCH 1/5] feat: add support for SimulateScenario --- packages/livekit-rtc/package.json | 2 +- packages/livekit-rtc/src/index.ts | 10 +- packages/livekit-rtc/src/room.ts | 32 ++++ packages/livekit-rtc/src/tests/e2e.test.ts | 190 +++++++++++++++++++++ pnpm-lock.yaml | 50 +++--- 5 files changed, 256 insertions(+), 28 deletions(-) diff --git a/packages/livekit-rtc/package.json b/packages/livekit-rtc/package.json index e9501da1..bf52d88b 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" }, diff --git a/packages/livekit-rtc/src/index.ts b/packages/livekit-rtc/src/index.ts index adfa0227..e66483e6 100644 --- a/packages/livekit-rtc/src/index.ts +++ b/packages/livekit-rtc/src/index.ts @@ -29,9 +29,15 @@ 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 { + ConnectError, + Room, + RoomEvent, + type RoomOptions, + type RtcConfiguration, +} from './room.js'; export { RpcError, type PerformRpcParams, type RpcInvocationData } from './rpc.js'; export { LocalAudioTrack, diff --git a/packages/livekit-rtc/src/room.ts b/packages/livekit-rtc/src/room.ts index db80fe22..8754edc4 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,35 @@ 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, + ); + 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..a138a391 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,192 @@ describeE2E('livekit-rtc e2e', () => { }, testTimeoutMs, ); + + // -- Reconnect scenarios -- + // + // Verify that: + // * Resume preserves publications and does NOT fire `Reconnected` + // (apps observe recovery via `ConnectionStateChanged`). + // * Full reconnect fires `Reconnected` exactly once and ends with the + // SDK-republished local track still flowing. + // + // These are sequential (not `.concurrent`) since they trigger signal-level + // disturbances that would interact across tests. They will throw at the + // simulateScenario() call if `@livekit/rtc-ffi-bindings` predates the + // SimulateScenarioKindRequest proto — upgrade bindings to the rust-sdks + // release that adds the FFI plumbing. + + itRaw( + 'resume preserves the agent publication and does not fire Reconnected', + async () => { + const { rooms } = await connectTestRooms(2); + const [subRoom, pubRoom] = rooms; + + // Publish a steady 60Hz tone from pubRoom. + 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; + const publication = await pubRoom!.localParticipant!.publishTrack(track, opts); + const sidBefore = publication.sid; + + // Drive the tone in a loop until cancelled. + 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); + } + })(); + + try { + // Wait for the subscriber to see the publication. + await waitFor( + () => + subRoom!.remoteParticipants.get(pubRoom!.localParticipant!.identity)?.trackPublications + .size === 1, + { timeoutMs: 5000, debugName: 'subscriber sees publication' }, + ); + + // Tripwire: Reconnected MUST NOT fire on a resume. + let reconnectedFired = 0; + pubRoom!.on(RoomEvent.Reconnected, () => { + reconnectedFired++; + }); + + // Engine cycles Reconnecting → Connected; observe both transitions. + const backToConnected = waitForRoomEvent( + pubRoom!, + RoomEvent.ConnectionStateChanged, + 15_000, + (state: ConnectionState) => state, + ); + + await pubRoom!.simulateScenario(SimulateScenarioKind.SIMULATE_SIGNAL_RECONNECT); + + // Wait for any state transition; loop until we see Connected again + // (Reconnecting may transition first). + let state = await backToConnected; + const deadline = Date.now() + 15_000; + while (state !== ConnectionState.CONN_CONNECTED && Date.now() < deadline) { + state = await waitForRoomEvent( + pubRoom!, + RoomEvent.ConnectionStateChanged, + 15_000, + (s: ConnectionState) => s, + ); + } + expect(state).toBe(ConnectionState.CONN_CONNECTED); + + // Brief grace window for any stray Reconnected dispatch. + await delay(750); + expect(reconnectedFired).toBe(0); + + // Publication identity is preserved. + const sidAfter = pubRoom!.localParticipant!.trackPublications.get(sidBefore)?.sid; + expect(sidAfter).toBe(sidBefore); + const subscriberPubs = subRoom!.remoteParticipants.get( + pubRoom!.localParticipant!.identity, + )!.trackPublications; + expect(subscriberPubs.size).toBe(1); + } finally { + toneRunning = false; + await toneTask; + await track.close(); + await Promise.all(rooms.map((r) => r.disconnect())); + } + }, + testTimeoutMs * 3, + ); + + itRaw( + 'full reconnect fires Reconnected once and ends with one publication', + async () => { + 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); + } + })(); + + try { + await waitFor( + () => + subRoom!.remoteParticipants.get(pubRoom!.localParticipant!.identity)?.trackPublications + .size === 1, + { timeoutMs: 5000, debugName: 'subscriber sees initial publication' }, + ); + + let reconnectedFired = 0; + pubRoom!.on(RoomEvent.Reconnected, () => { + reconnectedFired++; + }); + + const reconnected = waitForRoomEvent( + pubRoom!, + RoomEvent.Reconnected, + 20_000, + () => true, + ); + await pubRoom!.simulateScenario(SimulateScenarioKind.SIMULATE_FULL_RECONNECT); + await reconnected; + + // Allow any stray duplicate publish to settle. + await delay(750); + + // Bug regression: must end with exactly ONE audio publication on + // the publisher side (the SDK's auto-republished one), not two. + const localPubs = Array.from(pubRoom!.localParticipant!.trackPublications.values()).filter( + (p) => p.kind === TrackKind.KIND_AUDIO, + ); + expect(localPubs.length).toBe(1); + expect(reconnectedFired).toBe(1); + + // Subscriber view: also exactly one audio publication. + 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 { + toneRunning = false; + await toneTask; + await track.close(); + await Promise.all(rooms.map((r) => r.disconnect())); + } + }, + testTimeoutMs * 4, + ); }); 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': {} From 63b677f2c179e1adf06e45cf34dbcec68fac0187 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 11 May 2026 22:15:28 -0400 Subject: [PATCH 2/5] feat: handle track republish --- packages/livekit-rtc/package.json | 2 +- packages/livekit-rtc/src/room.ts | 34 +++++++++++++++++- packages/livekit-rtc/src/tests/e2e.test.ts | 36 +++++++++++++++++-- packages/livekit-rtc/src/track_publication.ts | 12 +++++++ 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/packages/livekit-rtc/package.json b/packages/livekit-rtc/package.json index bf52d88b..670f8ee4 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.54", + "@livekit/rtc-ffi-bindings": "0.12.56", "pino": "^9.0.0", "pino-pretty": "^13.0.0" }, diff --git a/packages/livekit-rtc/src/room.ts b/packages/livekit-rtc/src/room.ts index 8754edc4..1d850eb9 100644 --- a/packages/livekit-rtc/src/room.ts +++ b/packages/livekit-rtc/src/room.ts @@ -5,7 +5,11 @@ import { Mutex } from '@livekit/mutex'; import { EncryptionState, type EncryptionType } from '@livekit/rtc-ffi-bindings'; import type { FfiEvent } from '@livekit/rtc-ffi-bindings'; import { DisconnectReason, type OwnedParticipant } from '@livekit/rtc-ffi-bindings'; -import type { DataStream_Trailer, DisconnectCallback } from '@livekit/rtc-ffi-bindings'; +import type { + DataStream_Trailer, + DisconnectCallback, + TrackPublicationInfo, +} from '@livekit/rtc-ffi-bindings'; import { type ConnectCallback, ConnectRequest, @@ -513,6 +517,21 @@ export class Room extends (EventEmitter as new () => TypedEmitter const publication = this.localParticipant.trackPublications.get(ev.value.publicationSid!); this.localParticipant.trackPublications.delete(ev.value.publicationSid!); this.emit(RoomEvent.LocalTrackUnpublished, publication!, this.localParticipant!); + } else if ((ev.case as string) == 'localTrackRepublished') { + const value = (ev as any).value; + const previousSid: string = value.previousSid!; + const newInfo: TrackPublicationInfo = value.info!; + const publication = this.localParticipant.trackPublications.get(previousSid); + if (publication) { + publication.updateInfo(newInfo); + this.localParticipant.trackPublications.delete(previousSid); + this.localParticipant.trackPublications.set(publication.sid!, publication); + this.emit(RoomEvent.LocalTrackRepublished, publication, previousSid, this.localParticipant); + } else { + log.warn( + `RoomEvent.LocalTrackRepublished: previous publication not found: ${previousSid}`, + ); + } } else if (ev.case == 'localTrackSubscribed') { const publication = this.localParticipant.trackPublications.get(ev.value.trackSid!); if (publication) { @@ -935,6 +954,18 @@ export type RoomCallbacks = { publication: LocalTrackPublication, participant: LocalParticipant, ) => void; + /** + * Fired when the SDK auto-republished a local track during a full + * reconnect. The publication object's identity is preserved (the same + * instance is updated in place with the new server-assigned SIDs); + * `previousSid` is provided for callers that key external state on the + * old SID and need to reconcile. + */ + localTrackRepublished: ( + publication: LocalTrackPublication, + previousSid: string, + participant: LocalParticipant, + ) => void; localTrackSubscribed: (track: LocalTrack) => void; trackPublished: (publication: RemoteTrackPublication, participant: RemoteParticipant) => void; trackUnpublished: (publication: RemoteTrackPublication, participant: RemoteParticipant) => void; @@ -992,6 +1023,7 @@ export enum RoomEvent { ParticipantDisconnected = 'participantDisconnected', LocalTrackPublished = 'localTrackPublished', LocalTrackUnpublished = 'localTrackUnpublished', + LocalTrackRepublished = 'localTrackRepublished', LocalTrackSubscribed = 'localTrackSubscribed', TrackPublished = 'trackPublished', TrackUnpublished = 'trackUnpublished', diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index a138a391..96248e33 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -11,6 +11,7 @@ import { AudioStream, ConnectionState, LocalAudioTrack, + type LocalTrackPublication, ParticipantKind, Room, RoomEvent, @@ -712,7 +713,7 @@ describeE2E('livekit-rtc e2e', () => { const opts = new TrackPublishOptions(); opts.source = TrackSource.SOURCE_MICROPHONE; const publication = await pubRoom!.localParticipant!.publishTrack(track, opts); - const sidBefore = publication.sid; + const sidBefore = publication.sid!; // Drive the tone in a loop until cancelled. let tonePhase = 0; @@ -804,7 +805,8 @@ describeE2E('livekit-rtc e2e', () => { const track = LocalAudioTrack.createAudioTrack('reconnect_tone', source); const opts = new TrackPublishOptions(); opts.source = TrackSource.SOURCE_MICROPHONE; - await pubRoom!.localParticipant!.publishTrack(track, opts); + const initialPub = await pubRoom!.localParticipant!.publishTrack(track, opts); + const initialPubSid = initialPub.sid!; let tonePhase = 0; const samplesPer10ms = Math.floor(pubRateHz / 100); @@ -837,6 +839,15 @@ describeE2E('livekit-rtc e2e', () => { reconnectedFired++; }); + // Capture LocalTrackRepublished events from the publisher. + const republishedEvents: Array<{ + publication: LocalTrackPublication; + previousSid: string; + }> = []; + pubRoom!.on(RoomEvent.LocalTrackRepublished, (publication, previousSid) => { + republishedEvents.push({ publication, previousSid }); + }); + const reconnected = waitForRoomEvent( pubRoom!, RoomEvent.Reconnected, @@ -857,12 +868,31 @@ describeE2E('livekit-rtc e2e', () => { expect(localPubs.length).toBe(1); expect(reconnectedFired).toBe(1); - // Subscriber view: also exactly one audio publication. + // LocalTrackRepublished fired exactly once with the prior SID. + expect(republishedEvents.length).toBe(1); + expect(republishedEvents[0]!.previousSid).toBe(initialPubSid); + expect(republishedEvents[0]!.publication).toBe(initialPub); + + // Publisher-side state under the new SID: + // - the cached publication reference returned by publishTrack() + // has the same JS object identity (in-place mutation). + // - its .sid now reports the new server-assigned SID. + // - trackPublications is rekeyed under the new SID. + const pubAfter = localPubs[0]!; + expect(pubAfter).toBe(initialPub); + expect(pubAfter.sid).not.toBe(initialPubSid); + expect(initialPub.sid).toBe(pubAfter.sid); + expect(pubRoom!.localParticipant!.trackPublications.has(pubAfter.sid!)).toBe(true); + expect(pubRoom!.localParticipant!.trackPublications.has(initialPubSid)).toBe(false); + + // Subscriber view: also exactly one audio publication, with the + // new SID matching the publisher's view. const subscriberAudioPubs = Array.from( subRoom!.remoteParticipants.get(pubRoom!.localParticipant!.identity)!.trackPublications .values(), ).filter((p) => p.kind === TrackKind.KIND_AUDIO); expect(subscriberAudioPubs.length).toBe(1); + expect(subscriberAudioPubs[0]!.sid).toBe(pubAfter.sid); } finally { toneRunning = false; await toneTask; diff --git a/packages/livekit-rtc/src/track_publication.ts b/packages/livekit-rtc/src/track_publication.ts index 1772b9b0..75480688 100644 --- a/packages/livekit-rtc/src/track_publication.ts +++ b/packages/livekit-rtc/src/track_publication.ts @@ -66,6 +66,18 @@ export abstract class TrackPublication { get encryptionType(): EncryptionType | undefined { return this.info?.encryptionType; } + + /** + * Update the publication's info in place. Used by the SDK when the + * server re-issues IDs / metadata for an existing publication (e.g. + * after a full reconnect). Application code holding a cached + * publication reference continues to read fresh values via the + * unchanged object identity. + * @internal + */ + updateInfo(info: TrackPublicationInfo): void { + this.info = info; + } } export class LocalTrackPublication extends TrackPublication { From 2e8030b443a798a078c14fcfb4a2496658858158 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 11 May 2026 22:28:25 -0400 Subject: [PATCH 3/5] fix flakey test --- packages/livekit-rtc/src/tests/e2e.test.ts | 6 ++- pnpm-lock.yaml | 50 +++++++++++----------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index b71977a7..3ef96cc3 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -787,7 +787,11 @@ describeE2E('livekit-rtc e2e', () => { off += s.length; } const detected = estimateFreqHz(concat, pubRateHz); - expect(Math.abs(detected - sineHz)).toBeLessThan(20); + // Wider tolerance than the clean-path sine test: post-reconnect + // audio has brief discontinuities, and the autocorrelation is + // integer-lag (next neighbors to 60Hz are exactly 80Hz/40Hz), so + // ±20Hz lands right on the failure boundary under CI load. + expect(Math.abs(detected - sineHz)).toBeLessThan(25); return { rooms, subRoom: subRoom!, pubRoom: pubRoom! }; } finally { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d3babbe..4c4ce02c 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.54 - version: 0.12.54 + specifier: 0.12.56 + version: 0.12.56 '@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.54': - resolution: {integrity: sha512-uxBBBGDGUZFTpMtTDb8/YUUIMxs6IqxrZ1Nz3NKKXBw7MHHmzSOPbl8gGIGsGbsoVS3fTJMnnv9WZuwVqxdI6A==} + '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.56': + resolution: {integrity: sha512-ohKFUgZPpXGrtinfbgYML/P7n+ov4IXLx1jyL6p/xcpsgVQfFdLR0tRxmFfBypbcnfaBZMpgosOzV+/IgK2ddg==} engines: {node: '>= 18'} cpu: [arm64] os: [darwin] - '@livekit/rtc-ffi-bindings-darwin-x64@0.12.54': - resolution: {integrity: sha512-QMDEKMehW6hZ87ADQC0a7ZWAPzzivSERW+dnVuUFf+DPMFs3yvSfD2aoPGHbrS5/8XU5UJKaCwWWjIR94dPUDw==} + '@livekit/rtc-ffi-bindings-darwin-x64@0.12.56': + resolution: {integrity: sha512-q1scIsnbV2BQhktSpE/YSqni8NgJk/iExD6ASb4LdXU0jbibdwePs07A0SmO+/Sn7ROqQApFGeg2xDIXzACK/w==} engines: {node: '>= 18'} cpu: [x64] os: [darwin] - '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.54': - resolution: {integrity: sha512-7qrjk0izQy5ojVOZSADClvYOD25FtHXYardn3ZSufidn7fI6cykWv+/1RF51wg2M5xh7G6603aT8Hv4+u6dzog==} + '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.56': + resolution: {integrity: sha512-Wk+68CyWWiDYmB3XKkw7nOpidh+RgwE2Dfag5KBazzjgC2Nj0m1pReXlDMgKGPByaVTrVmphER3gs8phPx5Rzw==} engines: {node: '>= 18'} cpu: [arm64] os: [linux] libc: [glibc] - '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.54': - resolution: {integrity: sha512-uOrC9DtebbZv4ojInt9eHyvy2O517mFgIxwbR/XewoKA2ICOlmCkhKUaASYIiFSDEfQxmtsuWZP4HH8/q6cOFA==} + '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.56': + resolution: {integrity: sha512-fF0USwEkELhnVFBEgYUCHT50qfug3VluzforlEL5MKBn8/gu8m12MGckq9K1zkOsuA9jNhLVUhrSpSgaCowbTw==} engines: {node: '>= 18'} cpu: [x64] os: [linux] libc: [glibc] - '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.54': - resolution: {integrity: sha512-+BE0c1p58Q/wuxpViCOOpjmE9nffxxLNog6y27QyhKLRHqetHWV+yhSAYXGSEcUIoOwJuFS/WOxSYjf3OYWqpQ==} + '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.56': + resolution: {integrity: sha512-uPK1k43h7HHt2rhRObVNLhhH+x3YvA8DPcTMP8OpRwMeq16TdD/44BdgFKvmlHQoJJfzMFty74MyEkr4nTdqAw==} engines: {node: '>= 18'} cpu: [x64] os: [win32] - '@livekit/rtc-ffi-bindings@0.12.54': - resolution: {integrity: sha512-sZrhkwFO9RjEiqZSaPjOBvI/dXURpNJH1tmK3P7nEI3vxAOPaoaQfeFzDVvV1hulJb2qGkjFSbVXxMtuA28HvA==} + '@livekit/rtc-ffi-bindings@0.12.56': + resolution: {integrity: sha512-BCirumGTO7iQYk2KidsqraSNqCbveYGBEQGJS/9lMSeJOFXwwlWHWL9N9ZThBNjAdCQdcvSthWB33FApob8H1w==} 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.54': + '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.56': optional: true - '@livekit/rtc-ffi-bindings-darwin-x64@0.12.54': + '@livekit/rtc-ffi-bindings-darwin-x64@0.12.56': optional: true - '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.54': + '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.56': optional: true - '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.54': + '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.56': optional: true - '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.54': + '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.56': optional: true - '@livekit/rtc-ffi-bindings@0.12.54': + '@livekit/rtc-ffi-bindings@0.12.56': dependencies: '@bufbuild/protobuf': 1.10.1 optionalDependencies: - '@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/rtc-ffi-bindings-darwin-arm64': 0.12.56 + '@livekit/rtc-ffi-bindings-darwin-x64': 0.12.56 + '@livekit/rtc-ffi-bindings-linux-arm64-gnu': 0.12.56 + '@livekit/rtc-ffi-bindings-linux-x64-gnu': 0.12.56 + '@livekit/rtc-ffi-bindings-win32-x64-msvc': 0.12.56 '@livekit/typed-emitter@3.0.0': {} From 107c938b3f57d982e697b30a80aeba945265848b Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 11 May 2026 22:29:38 -0400 Subject: [PATCH 4/5] fmt --- packages/livekit-rtc/src/index.ts | 15 +++++++-------- packages/livekit-rtc/src/room.ts | 4 +--- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/livekit-rtc/src/index.ts b/packages/livekit-rtc/src/index.ts index e66483e6..7d131a3a 100644 --- a/packages/livekit-rtc/src/index.ts +++ b/packages/livekit-rtc/src/index.ts @@ -29,15 +29,14 @@ export { IceTransportType, TrackPublishOptions, } 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'; + 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'; export { LocalAudioTrack, diff --git a/packages/livekit-rtc/src/room.ts b/packages/livekit-rtc/src/room.ts index b7d40b14..b0a89dd2 100644 --- a/packages/livekit-rtc/src/room.ts +++ b/packages/livekit-rtc/src/room.ts @@ -529,9 +529,7 @@ export class Room extends (EventEmitter as new () => TypedEmitter this.localParticipant.trackPublications.set(publication.sid!, publication); this.emit(RoomEvent.LocalTrackRepublished, publication, previousSid, this.localParticipant); } else { - log.warn( - `RoomEvent.LocalTrackRepublished: previous publication not found: ${previousSid}`, - ); + log.warn(`RoomEvent.LocalTrackRepublished: previous publication not found: ${previousSid}`); } } else if (ev.case == 'localTrackSubscribed') { const publication = this.localParticipant.trackPublications.get(ev.value.trackSid!); From ea6310d525bd947e83d55bd91733878dd7ac4fe7 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Tue, 12 May 2026 13:11:48 -0700 Subject: [PATCH 5/5] Add changeset for track republish feature Add a changeset for track republish feature in @livekit/rtc-node. --- .changeset/big-ghosts-impress.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/big-ghosts-impress.md diff --git a/.changeset/big-ghosts-impress.md b/.changeset/big-ghosts-impress.md new file mode 100644 index 00000000..4a30492e --- /dev/null +++ b/.changeset/big-ghosts-impress.md @@ -0,0 +1,5 @@ +--- +"@livekit/rtc-node": patch +--- + +feat: handle track republish