11// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
22//
33// SPDX-License-Identifier: Apache-2.0
4- import type { OwnedTrack } from '@livekit/rtc-ffi-bindings' ;
54import { TrackPublishOptions } from '@livekit/rtc-ffi-bindings' ;
6- import { describe , expect , it , vi } from 'vitest' ;
5+ import { describe , expect , it } from 'vitest' ;
76import type { AudioFrame } from './audio_frame.js' ;
87import type { AudioStreamSource } from './audio_stream.js' ;
98import { FfiClient } from './ffi_client.js' ;
@@ -17,28 +16,43 @@ import { Room } from './room.js';
1716import { LocalAudioTrack , RemoteAudioTrack , type Track } from './track.js' ;
1817import { LocalTrackPublication } from './track_publication.js' ;
1918
20- // These tests fabricate Tracks with synthetic (invalid) FFI handle ids to avoid
21- // touching the real FFI server. The native FfiHandle has a Rust-side drop that
22- // runs when the JS wrapper is garbage-collected; dropping an unallocated handle
23- // throws "trying to drop an invalid handle" as an uncaught exception at GC time
24- // (intermittent locally, reliably on CI). Replace FfiHandle with an inert stub
25- // so no native drop is ever scheduled; everything else in the bindings stays real.
26- vi . mock ( '@livekit/rtc-ffi-bindings' , async ( ) => {
27- const actual = await vi . importActual < typeof import ( '@livekit/rtc-ffi-bindings' ) > (
28- '@livekit/rtc-ffi-bindings' ,
29- ) ;
30- class FakeFfiHandle {
31- private _handle : bigint ;
32- constructor ( handle : bigint ) {
33- this . _handle = handle ;
34- }
35- dispose ( ) : void { }
36- get handle ( ) : bigint {
37- return this . _handle ;
38- }
39- }
40- return { ...actual , FfiHandle : FakeFfiHandle } ;
41- } ) ;
19+ // These tests fabricate Tracks/publications without going through the real FFI.
20+ // The native FfiHandle (a NAPI class) registers a Rust-side drop on the JS
21+ // wrapper that fires at GC and throws "trying to drop an invalid handle" for any
22+ // unallocated id — an uncaught exception that surfaces intermittently on CI.
23+ // There is no JS-level hook to disarm that native finalizer, and module-mocking
24+ // the bindings isn't portable (the suite also runs under `bun test`, whose `vi`
25+ // shim lacks vi.mock/vi.importActual). So we never construct a real handle:
26+ // Tracks are built via Object.create (bypassing the constructor — the Track
27+ // class is written to support this), and the one production path that does build
28+ // a real handle (publishTrack -> LocalTrackPublication) is pinned to keep its
29+ // wrapper alive so the finalizer never runs.
30+
31+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32+ const inertFfiHandle = ( ) => ( { handle : BigInt ( 0 ) , dispose ( ) { } } ) as any ;
33+
34+ // Keeps real FfiHandle wrappers reachable for the lifetime of the test process
35+ // so their native finalizers never fire. See the note above.
36+ const ffiHandleKeepAlive : Array < unknown > = [ ] ;
37+
38+ /**
39+ * Stub the FfiClient singleton's request/waitFor round-trip via plain property
40+ * assignment. Runner-agnostic — avoids vi.spyOn, which Bun's `vi` shim doesn't
41+ * fully implement. Returns a restore function to call in `finally`.
42+ */
43+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44+ function stubFfiRoundTrip ( waitForResult : any ) : ( ) => void {
45+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46+ const client = FfiClient . instance as any ;
47+ const origRequest = client . request ;
48+ const origWaitFor = client . waitFor ;
49+ client . request = ( ) => ( { asyncId : BigInt ( 1 ) } ) ;
50+ client . waitFor = async ( ) => waitForResult ;
51+ return ( ) => {
52+ client . request = origRequest ;
53+ client . waitFor = origWaitFor ;
54+ } ;
55+ }
4256
4357class RecordingProcessor extends FrameProcessor < AudioFrame > {
4458 enabled = false ;
@@ -104,23 +118,28 @@ function attachRemoteParticipant(
104118 room . remoteParticipants . set ( identity , participant as any ) ;
105119}
106120
121+ // Build a Track via Object.create so no real FfiHandle is constructed. Sets only
122+ // the instance state the Track methods read; the bound tokenRefreshed listener
123+ // is created lazily by the class getter, so it survives this bypass.
124+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
125+ function initTrack ( track : any , sid : string ) : void {
126+ track . info = { sid } ;
127+ track . ffi_handle = inertFfiHandle ( ) ;
128+ track . roomRef = null ;
129+ track . audioStreams = new Set ( ) ;
130+ track . streamFinalizationRegistry = { register ( ) { } , unregister ( ) { } } ;
131+ }
132+
107133function makeTrack ( sid : string ) : RemoteAudioTrack {
108- const owned = {
109- info : { sid } ,
110- handle : { id : BigInt ( 0 ) } ,
111- // eslint-disable-next-line @typescript-eslint/no-explicit-any
112- } as any as OwnedTrack ;
113- return new RemoteAudioTrack ( owned ) ;
134+ const track = Object . create ( RemoteAudioTrack . prototype ) as RemoteAudioTrack ;
135+ initTrack ( track , sid ) ;
136+ return track ;
114137}
115138
116139function makeLocalAudioTrack ( sid : string ) : LocalAudioTrack {
117- // Safe under the FfiHandle mock — no native handle is created.
118- const owned = {
119- info : { sid } ,
120- handle : { id : BigInt ( 0 ) } ,
121- // eslint-disable-next-line @typescript-eslint/no-explicit-any
122- } as any as OwnedTrack ;
123- return new LocalAudioTrack ( owned ) ;
140+ const track = Object . create ( LocalAudioTrack . prototype ) as LocalAudioTrack ;
141+ initTrack ( track , sid ) ;
142+ return track ;
124143}
125144
126145function makeStream ( processor : FrameProcessor < AudioFrame > | null ) : AudioStreamSource {
@@ -645,21 +664,12 @@ describe('AudioStream room lifecycle', () => {
645664 const clearedInfoBefore = proc . streamInfoClearedCount ;
646665 const clearedCredsBefore = proc . credentialsClearedCount ;
647666
648- // Mock the FFI round-trip so unpublishTrack resolves without a real server.
649- // eslint-disable-next-line @typescript-eslint/no-explicit-any
650- const requestSpy = vi . spyOn ( FfiClient . instance , 'request' ) . mockReturnValue ( {
651- asyncId : BigInt ( 1 ) ,
652- } as never ) ;
653- const waitForSpy = vi
654- . spyOn ( FfiClient . instance , 'waitFor' )
655- // eslint-disable-next-line @typescript-eslint/no-explicit-any
656- . mockResolvedValue ( { error : undefined } as never ) ;
657-
667+ // Stub the FFI round-trip so unpublishTrack resolves without a real server.
668+ const restore = stubFfiRoundTrip ( { error : undefined } ) ;
658669 try {
659670 await local . unpublishTrack ( TRACK_SID ) ;
660671 } finally {
661- requestSpy . mockRestore ( ) ;
662- waitForSpy . mockRestore ( ) ;
672+ restore ( ) ;
663673 }
664674
665675 expect ( local . trackPublications . has ( TRACK_SID ) ) . toBe ( false ) ;
@@ -727,23 +737,22 @@ describe('AudioStream room lifecycle', () => {
727737 // Track starts with a provisional SID that differs from the publication SID.
728738 const track = makeLocalAudioTrack ( 'PROVISIONAL' ) ;
729739
730- const requestSpy = vi . spyOn ( FfiClient . instance , 'request' ) . mockReturnValue ( {
731- asyncId : BigInt ( 1 ) ,
732- } as never ) ;
733- const waitForSpy = vi . spyOn ( FfiClient . instance , 'waitFor' ) . mockResolvedValue ( {
740+ const restore = stubFfiRoundTrip ( {
734741 message : {
735742 case : 'publication' ,
736743 value : { info : { sid : 'PUB_NEW' } , handle : { id : BigInt ( 0 ) } } ,
737744 } ,
738- } as never ) ;
739-
745+ } ) ;
740746 let pub : LocalTrackPublication ;
741747 try {
742748 pub = await local . publishTrack ( track , new TrackPublishOptions ( ) ) ;
743749 } finally {
744- requestSpy . mockRestore ( ) ;
745- waitForSpy . mockRestore ( ) ;
750+ restore ( ) ;
746751 }
752+ // publishTrack builds a real LocalTrackPublication, the one path here that
753+ // constructs a native FfiHandle. Pin it so its finalizer never fires.
754+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
755+ ffiHandleKeepAlive . push ( ( pub as any ) . ffiHandle ) ;
747756
748757 expect ( pub . sid ) . toBe ( 'PUB_NEW' ) ;
749758 // The invariant: the track's own SID now matches the publication SID.
0 commit comments