Skip to content

Feat: multi-user access to webrtc mode#219

Draft
PMohanJ wants to merge 1 commit intoselkies-project:mainfrom
PMohanJ:multi-user
Draft

Feat: multi-user access to webrtc mode#219
PMohanJ wants to merge 1 commit intoselkies-project:mainfrom
PMohanJ:multi-user

Conversation

@PMohanJ
Copy link
Member

@PMohanJ PMohanJ commented Mar 6, 2026

Closes #39.

Reference the issue numbers and reviewers
@ehfd @thelamer

Explain relevant issues and how this pull request solves them
This PR is about adding support for multi-user access to desktop in webrtc mode along with wiring the gamepad controller logic.

Describe the changes in code and its dependencies and justify that they work as intended after testing

Describe alternatives you've considered
NA

Additional context
NA

  • I confirm that this pull request is relevant to the scope of this project. If you know that upstream projects are the cause of this problem, please file the pull request there.
  • I confirm that this pull request has been tested thoroughly and to the best of my knowledge that additional unintended problems do not arise.
  • I confirm that the style of the changed code conforms to the overall style of the project.
  • I confirm that I have read other open and closed pull requests and that duplicates do not exist.
  • I confirm that I have justified the need for this pull request and that the changes reflect the fix for the specified problem.
  • I confirm that no portion of this pull request contains credentials or other private information, and it is my own responsibility to protect my privacy.
  • I confirm that the authors of this pull request does not willfully breach or infringe legal regulations, in any and all global law, regarding trademarks, trade names, logos, patents, or any and all other forms of external intellectual property, as well as adhering to software license terms of open-source and proprietary software projects.

- Added support for multi peer connections for webrtc leveraging MediaRelay
  component of aiortc.
- Optimised sctp inbound stream logic to tolerate lossy network conditions,
  predominantly eliminating HOL(head of line) blocking.
- Render video birate under Stats section (UI).
@PMohanJ PMohanJ marked this pull request as draft March 6, 2026 04:52
@ehfd ehfd requested review from Copilot, ehfd and thelamer March 6, 2026 05:59
@ehfd
Copy link
Member

ehfd commented Mar 6, 2026

/gemini review

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR extends the WebRTC “desktop mode” to support multi-user access (controller + multiple viewer slots) by updating the signaling protocol and server/client routing, and wires gamepad + additional dashboard stats.

Changes:

  • Update signaling handshake/session protocol to identify peer/client types and prefix session traffic with peer IDs (enabling multiple concurrent clients).
  • Refactor RTC handling to track multiple peer connections and relay media from a single controller to viewers.
  • Add dashboard UI support for video bitrate stats and shared-mode behavior restrictions on the web client.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/selkies/webrtc_signaling.py Updates signaling client protocol and session start/end callbacks for multi-user.
src/selkies/webrtc_mode.py Wires new session lifecycle callbacks and stops/starts multiple RTC connections.
src/selkies/signaling_server.py Introduces peer metadata + multi-client session routing + sharing-slot validation.
src/selkies/rtc.py Refactors RTCApp to manage multiple peer connections + media relay + logging extras.
src/selkies/media_pipeline.py Adds is_media_pipeline_running() and abstracts dynamic IDR frame trigger.
src/selkies/webrtc/rtcsctptransport.py Modifies SCTP reassembly logic and adds new logging/comment changes.
addons/selkies-dashboard/src/translations.js Adds i18n strings for “Video Bitrate” stat display.
addons/selkies-dashboard/src/components/Sidebar.jsx Displays video bitrate gauge + tooltip fed from window.video_bitrate.
addons/gst-web-core/selkies-wr-core.js Adds shared/player slot behavior, gamepad toggling, and stats handling changes.
addons/gst-web-core/lib/webrtc.js Improves data channel error reporting details.
addons/gst-web-core/lib/signaling.js Sends HELLO metadata, targets “server” session, and prefixes ICE/SDP with server peer id.
addons/gst-web-core/lib/input.js Fixes gamepad disconnect callback property name.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

else:
break

logger.info(f"updated cumulaticve tsn : {self._last_received_tsn}")
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

_receive_forward_tsn_chunk() now logs "updated cumulaticve tsn" at INFO on every FORWARD-TSN, which is likely to be very chatty in production and add overhead. This should be DEBUG (or removed), and the message has a spelling error ("cumulative").

Suggested change
logger.info(f"updated cumulaticve tsn : {self._last_received_tsn}")
logger.debug(f"updated cumulative tsn : {self._last_received_tsn}")

Copilot uses AI. Check for mistakes.
@@ -178,13 +190,13 @@
await self.on_error(WebRTCSignalingError("failed to prase message: %s" % message))
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Typo in error handling: "failed to prase message" should be "failed to parse message".

Suggested change
await self.on_error(WebRTCSignalingError("failed to prase message: %s" % message))
await self.on_error(WebRTCSignalingError("failed to parse message: %s" % message))

Copilot uses AI. Check for mistakes.
logger.info("Starting RTC pipeline", extra={'client_peer_id': client_peer_id, 'client_type': client_type})
await self._start_rtc_pipeline(client_peer_id, client_type)
except Exception as e:
logger.error("Error starting RTC pipeline", extra={'client_peer_id': client_peer_id, 'client_type': client_type}, exc_info=True)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

start_rtc_connection() logs exceptions but does not re-raise or return an error status. Callers like WebRTCMode.handle_session_start() wrap this in a try/except expecting failures to propagate for cleanup; with the current code, session setup failures will be silently ignored and the caller will continue as if the session started. Re-raise the exception (or return a success boolean) so upstream can handle failures correctly.

Suggested change
logger.error("Error starting RTC pipeline", extra={'client_peer_id': client_peer_id, 'client_type': client_type}, exc_info=True)
logger.error("Error starting RTC pipeline", extra={'client_peer_id': client_peer_id, 'client_type': client_type}, exc_info=True)
raise RTCAppError(f"Error starting RTC pipeline: {e}") from e

Copilot uses AI. Check for mistakes.
Comment on lines 57 to 63
self.on_ice = lambda mlineindex, candidate: logger.warning('unhandled ice event')
self.on_sdp = lambda sdp_type, sdp: logger.warning('unhandled sdp event')
self.on_connect = lambda res, scale: logger.warning('unhandled on_connect callback')
self.on_disconnect = lambda: logger.warning('unhandled on_disconnect callback')
self.on_session = lambda peer_id: logger.warning('unhandled on_session callback')
self.on_session_start = lambda client_peer_id, client_type: logger.warning('unhandled on_session_start callback')
self.on_session_end = lambda client_peer_id, client_type: logger.warning('unhandled on_session_end callback')
self.on_error = lambda v: logger.warning('unhandled on_error callback: %s', v)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

on_ice / on_sdp default callbacks still use the old signatures, but listen() now calls on_ice(ice, client_peer_id) and on_sdp(type, sdp, client_peer_id). If a consumer forgets to override these (or a message arrives before callbacks are wired), this will raise a TypeError. Update the default lambdas to accept the new parameters (or use *args, **kwargs).

Copilot uses AI. Check for mistakes.
other_peer = self.peers.get(other_id, None)
if other_peer:
wso = other_peer.ws
msg = 'SESSION_ENDED {} {}'.format(uid, peer.client_type)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

cleanup_session() sends SESSION_ENDED ..., but the updated signaling client (webrtc_signaling.py) listens for SESSION_END .... This mismatch will prevent session teardown from being handled and will likely trigger JSON parse errors when the client receives SESSION_ENDED. Rename the message to SESSION_END (and ensure it matches the expected 3-token format).

Suggested change
msg = 'SESSION_ENDED {} {}'.format(uid, peer.client_type)
msg = 'SESSION_END {} {}'.format(uid, peer.client_type)

Copilot uses AI. Check for mistakes.
Comment on lines 383 to +392
if peer_status == 'session':
other_id = self.sessions[uid]
wso, oaddr, status = self.peers[other_id]
# Session message are prefixed with sender peer_id, eg: client-<UUID> <message>
other_id, msg_string = msg.split(maxsplit=1)
other_peer = self.peers[other_id]
wso = other_peer.ws
status = other_peer.peer_status
assert(status == 'session')
logger.info("{} -> {}: {}".format(uid, other_id, msg))
await wso.send(msg)
msg_string = '{} {}'.format(uid, msg_string)
await wso.send(msg_string)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

In session relay mode, other_id, msg_string = msg.split(maxsplit=1) will raise ValueError if a malformed message (or an unexpected ping/command) lacks a space. This would crash the connection handler task. Add validation / error handling around the split and respond with an ERROR instead of throwing.

Copilot uses AI. Check for mistakes.
Comment on lines +522 to +525
logger.info("Allowd slots are: " + str(allowed_slots) + " provided is: " +str(client_slot))
if client_slot != -1 and (client_slot not in allowed_slots):
await ws.close(code=4000, reason="Invalid player id provided, check URL.")
raise Exception("Invalid client slot provied {!r}".format(client_slot))
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Typo in log / exception messages: "Allowd" / "provied". These show up in server logs and make troubleshooting harder. Please correct to "Allowed" and "provided".

Suggested change
logger.info("Allowd slots are: " + str(allowed_slots) + " provided is: " +str(client_slot))
if client_slot != -1 and (client_slot not in allowed_slots):
await ws.close(code=4000, reason="Invalid player id provided, check URL.")
raise Exception("Invalid client slot provied {!r}".format(client_slot))
logger.info("Allowed slots are: " + str(allowed_slots) + " provided is: " + str(client_slot))
if client_slot != -1 and (client_slot not in allowed_slots):
await ws.close(code=4000, reason="Invalid player id provided, check URL.")
raise Exception("Invalid client slot provided {!r}".format(client_slot))

Copilot uses AI. Check for mistakes.
Comment on lines 157 to 160
# WebRTC ICE and SDP events
self.on_ice = lambda mlineindex, candidate: logger.warning('unhandled ice event')
self.on_sdp = lambda sdp_type, sdp: logger.warning('unhandled sdp event')

Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

RTCApp.on_sdp's default lambda still only accepts (sdp_type, sdp), but _start_rtc_pipeline() now awaits self.on_sdp('offer', sdp, client_peer_id). If callbacks aren't wired for any reason, this will raise a TypeError. Update the default on_sdp (and ideally on_ice) to accept client_peer_id (or use *args).

Copilot uses AI. Check for mistakes.
Comment on lines 598 to +614
if chunk.flags & SCTP_DATA_LAST_FRAG:
user_data = b"".join(
[c.user_data for c in self.reassembly[start_pos : pos + 1]]
)
self.reassembly = (
self.reassembly[:start_pos] + self.reassembly[pos + 1 :]
)
message_chunks =[]
for i in range(start_pos, pos + 1):
c = self.reassembly[i]
message_chunks.append(c.user_data)
consumed_tsns.add(c.tsn)
user_data = b"".join(message_chunks)
if ordered and chunk.stream_seq == self.sequence_number:
self.sequence_number = uint16_add(self.sequence_number, 1)
pos = start_pos
yield (chunk.stream_id, chunk.protocol, user_data)
else:
pos += 1
start_pos = None
pos += 1
if start_pos is not None:
expected_tsn = tsn_plus_one(expected_tsn)

expected_tsn = tsn_plus_one(expected_tsn)
if consumed_tsns:
self.reassembly =[c for c in self.reassembly if c.tsn not in consumed_tsns]
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Minor formatting issues in the new reassembly logic: missing spaces in message_chunks =[] and self.reassembly =[c ...]. Please fix to keep code style consistent and avoid failing linters/formatters.

Copilot uses AI. Check for mistakes.
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces multi-user access for the WebRTC mode, a significant feature enhancement supporting multiple clients with different roles (controller, viewer) across frontend, signaling, and backend. A critical security vulnerability was identified in the postMessage handler in selkies-wr-core.js due to a lack of origin validation, which could lead to command execution. Implementing origin checks for all window.onmessage listeners is highly recommended. Furthermore, suggestions have been provided to improve code clarity and maintainability in the JavaScript files.

overlayInput.addEventListener('drop', handleDrop);
if (!isSharedMode) {
// listen for dashboard messages (Dashboard -> core client)
window.addEventListener("message", handleMessage);

Choose a reason for hiding this comment

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

security-critical critical

The handleMessage function, which listens for message events on the window, does not validate the origin of incoming messages. This allows any website to send messages to the Selkies application. One of the supported message types is command, which is forwarded to the server and executed as a shell command. This could lead to Remote Code Execution (RCE) on the server if a user visits a malicious website while the Selkies application is open in another tab.

To remediate this, add a check for event.origin at the beginning of the handleMessage function to ensure it only accepts messages from trusted origins (e.g., the same origin as the application).

Comment on lines +217 to +221
var meta = {
'client_type': this.client_type,
'client_slot': this.client_slot,
'client_strict_viewer': this.client_strict_viewer,
}

Choose a reason for hiding this comment

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

medium

For better code clarity and to prevent accidental reassignment, it's recommended to use const instead of var for variables that are not reassigned. The meta object is a good candidate for this change.

Suggested change
var meta = {
'client_type': this.client_type,
'client_slot': this.client_slot,
'client_strict_viewer': this.client_strict_viewer,
}
const meta = {
'client_type': this.client_type,
'client_slot': this.client_slot,
'client_strict_viewer': this.client_strict_viewer,
};

Comment on lines +215 to +240
const hash = window.location.hash;
if (hash === '#shared') {
detectedSharedModeType = 'shared';
playerInputTargetIndex = undefined;
} else if (hash === '#player2') {
detectedSharedModeType = 'player2';
playerInputTargetIndex = 1;
} else if (hash === '#player3') {
detectedSharedModeType = 'player3';
playerInputTargetIndex = 2;
} else if (hash === '#player4') {
detectedSharedModeType = 'player4';
playerInputTargetIndex = 3;
}

if (hash === '#shared') {
clientRole = CLIENT_VIEWER;
clientSlot = -1
} else if (hash.startsWith('#player')) {
clientRole = CLIENT_VIEWER;
clientSlot = parseInt(hash.substring(7), 10) || null;
} else {
clientRole = CLIENT_CONTROLLER;
clientSlot = 1;
playerInputTargetIndex = 0;
}

Choose a reason for hiding this comment

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

medium

The logic for parsing the URL hash to determine client roles and settings is split into two separate if/else blocks, which makes it a bit repetitive and harder to follow. This can be refactored into a single, more streamlined block to improve readability and maintainability.

    const hash = window.location.hash;
    if (hash === '#shared') {
        clientRole = CLIENT_VIEWER;
        clientSlot = -1;
        detectedSharedModeType = 'shared';
        playerInputTargetIndex = undefined;
    } else if (hash.startsWith('#player')) {
        clientRole = CLIENT_VIEWER;
        const playerNum = parseInt(hash.substring(7), 10);
        clientSlot = playerNum || null;
        if (playerNum >= 2 && playerNum <= 4) {
            detectedSharedModeType = `player${playerNum}`;
            playerInputTargetIndex = playerNum - 1;
        }
    } else {
        clientRole = CLIENT_CONTROLLER;
        clientSlot = 1;
        playerInputTargetIndex = 0;
    }

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.

SFU Server: Multiple users connected simultaneously, implement view-only password, isolated sessions

3 participants