Skip to content

feat(rtc): surface ParticipantActive and Participant.state - #715

Open
tinalenguyen wants to merge 3 commits into
mainfrom
tina/participant-active
Open

feat(rtc): surface ParticipantActive and Participant.state#715
tinalenguyen wants to merge 3 commits into
mainfrom
tina/participant-active

Conversation

@tinalenguyen

Copy link
Copy Markdown
Member

Problem

The FFI already emits a ParticipantActive event — the proto message is right there in rtc-ffi-bindings (room_pb.d.ts, participant_active = 42) — but Room drops it on the floor. Two consequences:

  • JS has no way to observe a remote participant transitioning past JOINED.
  • Participant exposes no state at all. info.state is set once in createRemoteParticipant and never refreshed, so it is not a usable substitute.

This matters because a remote participant can only receive data messages once it reaches ParticipantState.ACTIVE. Code that waits on ParticipantConnected gets back a participant that is present in room.remoteParticipants but not yet reachable, and anything sent to it is silently dropped.

The Python SDK has had participant_active for exactly this (room.py, "Called when a remote participant becomes active and is ready to receive data messages"). This brings Node to parity.

Changes

room.ts

  • Handle the participantActive FFI event: flip info.state to ACTIVE and emit the new RoomEvent.ParticipantActive. Unknown identity warns and no-ops, matching the existing participantDisconnected branch.
  • Set info.state = DISCONNECTED alongside the existing disconnectReason assignment, so a departed participant's state isn't left reading ACTIVE.
  • Declare participantActive in RoomCallbacks and ParticipantActive in the RoomEvent enum.

participant.ts — add the state getter, defaulting to JOINING since ParticipantInfo.state is optional in the generated types.

index.ts — re-export ParticipantState, next to the existing ParticipantKind export. Without it callers can read .state but have no enum to compare against.

Compatibility

Purely additive — a new event, a new getter, a new export. No existing behavior changes: ParticipantConnected fires exactly as before, and nothing that ignores the new event is affected.

Tests

Five new tests in participant_active.test.ts. They drive the private onFfiEvent handler with the same roomEvent shape FfiClient delivers, so the real switch statement is exercised rather than a reimplementation:

  • state carried through participantConnected
  • the connected → active transition, asserting no event fires on connect alone
  • unknown-participant no-op
  • the disconnect state flip
  • the JOINING default when the FFI omits a state

The FfiHandle stub is the same one audio_stream_room_lifecycle.test.ts uses — fabricated handle ids otherwise trigger a native drop at GC time.

pnpm build succeeds; 61 tests pass across 6 files; lint is clean on the changed files.

Motivation

Follow-up in agents-js depends on this: waitForParticipant there resolves on ParticipantConnected, so DataStreamAudioOutput can start streaming avatar audio to a participant that cannot yet receive it. That fix can't be written until this ships.

The FFI already emits a `ParticipantActive` event, but `Room` dropped it on the
floor: JS had no way to observe when a remote participant transitions past
JOINED, and `Participant` exposed no `state` at all (`info.state` was set once at
construction and never refreshed).

A remote participant can only receive data messages once it reaches
`ParticipantState.ACTIVE`, so callers waiting on `ParticipantConnected` could
start sending to a participant that was not yet reachable. The Python SDK has
had `participant_active` for this; this brings Node to parity.

- handle the `participantActive` FFI event, flip `info.state` to ACTIVE, and
  emit `RoomEvent.ParticipantActive`
- flip `info.state` to DISCONNECTED alongside the existing `disconnectReason`
- add the `Participant.state` getter and export `ParticipantState`
@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 09105e4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@livekit/rtc-node Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

devin-ai-integration[bot]

This comment was marked as resolved.

`Participant.state` reads `ParticipantInfo.state`, which only ever moved on
per-participant events. A room-level disconnect — explicit `disconnect()` or an
FFI-driven one — is not reported as each participant departing, so the local
participant and every still-listed remote participant kept reporting ACTIVE.

That state outlives the disconnect in practice: the participant maps are never
cleared, and `Disconnected` handlers routinely capture participants. A retained
participant claiming to be ACTIVE reads as reachable when it no longer is.

Transition the local participant and all retained remote participants to
DISCONNECTED in cleanupOnDisconnect, before `ConnectionStateChanged` and
`Disconnected` fire, so a handler that inspects `state` sees the truth.

Two remote-participant stubs in audio_stream_room_lifecycle.test.ts gain the
`info` that real participants always carry (createRemoteParticipant sets it
unconditionally), since cleanup now writes through it.

Renames participant_active.test.ts to participant_state.test.ts — it now covers
the full state lifecycle rather than just the active transition.
devin-ai-integration[bot]

This comment was marked as resolved.

disconnect() ran cleanupOnDisconnect() outside ffiEventLock and only removed the
FfiClient listener afterwards. onFfiEvent is dispatched synchronously but awaits
the lock before doing anything, so callbacks delivered before removeListener were
still pending when cleanup ran, and went on to process participantActive or
participantsUpdated — both of which write participant info — overwriting the
DISCONNECTED state cleanup had just set.

Remove the listener first so no new callbacks are created, then acquire
ffiEventLock before cleanup. The mutex is FIFO, so already-queued callbacks run
to completion first and cleanup lands last. This is the JS analogue of the Python
SDK's disconnect(), which unsubscribes its queue and awaits the listen task
before flipping connection state.

cleanupOnDisconnect stays lock-free: the FFI-driven disconnected path reaches it
from inside onFfiEvent, which already holds the lock, and Mutex is not reentrant.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Devin Review

// reaches it from inside onFfiEvent, which already holds it.
FfiClient.instance.removeListener(FfiClientEvent.FfiEvent, this.onFfiEvent);

const unlock = await this.ffiEventLock.lock();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Concurrent publishing can hang disconnect

When native disconnection leaves publishTrack pending, disconnect() waits behind its mutex. Cleanup cannot abort the pending operation, so both promises hang.

Suggested change
const unlock = await this.ffiEventLock.lock();
this.disconnectController.abort();
const unlock = await this.ffiEventLock.lock();
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant