Skip to content

Commit 255f712

Browse files
committed
fix: attempt LLM driven update to get frame processor tests to pass both in node and bun test runners
1 parent 4f2d86e commit 255f712

2 files changed

Lines changed: 86 additions & 62 deletions

File tree

packages/livekit-rtc/src/audio_stream_room_lifecycle.test.ts

Lines changed: 66 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
22
//
33
// SPDX-License-Identifier: Apache-2.0
4-
import type { OwnedTrack } from '@livekit/rtc-ffi-bindings';
54
import { TrackPublishOptions } from '@livekit/rtc-ffi-bindings';
6-
import { describe, expect, it, vi } from 'vitest';
5+
import { describe, expect, it } from 'vitest';
76
import type { AudioFrame } from './audio_frame.js';
87
import type { AudioStreamSource } from './audio_stream.js';
98
import { FfiClient } from './ffi_client.js';
@@ -17,28 +16,43 @@ import { Room } from './room.js';
1716
import { LocalAudioTrack, RemoteAudioTrack, type Track } from './track.js';
1817
import { 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

4357
class 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+
107133
function 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

116139
function 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

126145
function 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.

packages/livekit-rtc/src/track.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,22 @@ export abstract class Track {
2626
private roomRef: WeakRef<Room> | null = null;
2727
private audioStreams: Set<WeakRef<AudioStreamSource>> = new Set();
2828
private streamFinalizationRegistry: FinalizationRegistry<WeakRef<AudioStreamSource>>;
29-
private onRoomTokenRefreshed = () => {
29+
30+
// Lazily-created bound listener for the Room's `tokenRefreshed` event. Stored
31+
// so the same function reference is used for both on() and off(). Defined via
32+
// a getter + plain method (rather than an instance arrow field) so the class
33+
// can be constructed through Object.create in tests — bypassing the FFI handle
34+
// — while keeping a stable listener identity.
35+
private boundOnRoomTokenRefreshed?: () => void;
36+
37+
private get roomTokenRefreshedListener(): () => void {
38+
if (!this.boundOnRoomTokenRefreshed) {
39+
this.boundOnRoomTokenRefreshed = () => this.onRoomTokenRefreshed();
40+
}
41+
return this.boundOnRoomTokenRefreshed;
42+
}
43+
44+
private onRoomTokenRefreshed(): void {
3045
const room = this.resolveRoom();
3146
if (!room || !room.token || !room.serverUrl) return;
3247
for (const stream of this.iterateStreams()) {
@@ -36,7 +51,7 @@ export abstract class Track {
3651
}
3752
processor.onCredentialsUpdated({ token: room.token, url: room.serverUrl });
3853
}
39-
};
54+
}
4055

4156
constructor(owned: OwnedTrack) {
4257
this.info = owned.info;
@@ -64,11 +79,11 @@ export abstract class Track {
6479
return;
6580
}
6681
if (oldRoom && oldRoom !== room) {
67-
oldRoom.off('tokenRefreshed', this.onRoomTokenRefreshed);
82+
oldRoom.off('tokenRefreshed', this.roomTokenRefreshedListener);
6883
}
6984
if (room) {
70-
room.off('tokenRefreshed', this.onRoomTokenRefreshed);
71-
room.on('tokenRefreshed', this.onRoomTokenRefreshed);
85+
room.off('tokenRefreshed', this.roomTokenRefreshedListener);
86+
room.on('tokenRefreshed', this.roomTokenRefreshedListener);
7287
}
7388
this.roomRef = room ? new WeakRef(room) : null;
7489
for (const stream of this.iterateStreams()) {

0 commit comments

Comments
 (0)