session
Single-session state and room fanout.
Single-session state and room fanout.
The container hosts exactly one play session at a time, so session and room state are module globals.
attributelog= logging.getLogger(__name__)attributeSESSIONOptional[dict[str, Any]]= NoneThe active play session, or None.
Shape:
\{
"id", "active", "created_at",
"user": \{...\}, "emulator": "pcsx2", "rom": \{...\}, "rom_file": str | None,
"save": \{...\} | None, "callback": \{...\} | None, "multiplayer": bool,
"controller_token", "controller_public_id",
"invites": \{"participant": str, "readonly": str\},
"viewers": [\{"token","public_id","user_id","anonymous","last_seen","slot",
"mk_control","username","permission"\}...],
"controller_slot", "mk_owner_token", "designated_speaker",
"save_baseline": float, "emulator_obj": Emulator,
\}token is each seat's bearer credential; public_id is a non-sensitive
stand-in safe to broadcast to the room in its place (see broadcast_state).
attributeLAST_EXITOptional[dict[str, Any]]= NoneWhat is left of the session that just exited: {"id", "rom", "emulator_obj"}.
RomM files the exit state in its library after the teardown has answered, so the emulator that captured it has to outlive the session for the read routes to find the file. Dropped at the next activate.
attributeLAST_OUTCOMEOptional[dict[str, Any]]= NoneHow the last exit of this broker process ended.
\{"session_id", "ended_at", "duration_s", "state_saved", "dump_error", "upload", "archive_path"\}, where upload is the exit report's upload mode.
Unlike LAST_EXIT it survives the next activate, so a save archive that never
reached RomM stays visible on the status route until another exit replaces it.
None until the first exit of this broker process.
attributeSESSION_END_FLUSH_WAIT= 2.0Seconds the session_ended notice gets to reach each member before its socket closes.
attributeKEYFRAME_REQUEST_GAP= 1.0Minimum seconds between two keyframe requests to the same member.
attributeROOMdict[str, Any]= {'controller': None, 'viewers': {}, 'cooldowns': {}}Live websocket connections for the room.
controller is the controller's connection info dict (or None while offline),
viewers maps each online viewer's token to its connection info dict, and
cooldowns tracks per-token rate limits.
attributePUBLIC_ID_HEX_CHARS= 8Fixed width, in ASCII characters, of every public id the broker mints.
The room's binary media frames carry the sender's id in a prefix of exactly
this width and read the frame type from the byte straight after it (see
room.MEDIA_ID_BYTES), so an id of any other length would put every frame's
type byte at the wrong offset for every recipient.
attributeSEAT_RECLAIM_GRACE_SECONDS= 60.0How long a seat is protected from reclaim after its last_seen was stamped.
last_seen is stamped when the seat is minted and again when its socket
drops, so the same window covers the gap before a freshly invited browser has
finished loading and the gap a reload leaves behind. Without it a burst of
joins at the cap keeps reclaiming seats handed out seconds earlier, and every
arrival evicts the one before it instead of anybody getting in.
funcnew_public_id() -> strMint a broadcast-safe id at the width the room's media wire format assumes.
Returns
strA hex id exactly PUBLIC_ID_HEX_CHARS characters wide.
func_session_id(raw) -> strReduce a caller-supplied id to what is safe in the export filename.
The exit archive is named after the session, and the export routes reject anything with path structure in it, so an id carrying a slash or a dot would produce an archive the parent could never fetch back.
paramrawobjectThe session_id from the activate payload, of any type or None.
Returns
strraw stringified and stripped to [A-Za-z0-9_-], at most 64
funcnew_session(payload, emulator_obj, rom_file) -> dict[str, Any]Replace the module-level session with a fresh one built from the activate payload.
The controller starts with gamepad 1 and mouse/keyboard control; the viewer list starts empty and the save baseline is the moment of creation.
parampayloaddict[str, Any]The activate request body; emulator is required, the rest
(session_id, user, rom, save, callback, multiplayer) is
optional.
paramemulator_objEmulatorThe emulator instance driving this session.
paramrom_fileOptional[str]The resolved path of the ROM file being played, or None for a session with nothing to boot from (a resumed state, or an emulator launched without a ROM). Only ever read back out for the status routes to report.
Returns
dictThe new session dict, which is also stored in SESSION.
funcretire_session() -> NoneEnd the session, keeping what the state routes still have to answer with.
Exit captures a state and then tears the session down, but RomM only asks for that state once the teardown has replied, so clearing outright is what made every post-exit read a 409. Only the emulator is kept, and only the read routes consult it: nothing here can be played, written to or resumed.
Returns
Nonefuncfind_viewer(token) -> Optional[dict[str, Any]]Look up a viewer entry in the active session by its token.
paramtokenstrThe viewer's streaming token.
Returns
typing.OptionalThe viewer dict, or None when there is no session or no viewer holds
funcpublic_id_for(token) -> Optional[str]Resolve a seat's real token to the non-sensitive id safe to broadcast for it.
paramtokenstrA controller or viewer token already known to be valid.
Returns
typing.OptionalThe matching public id, or None when there is no session or no seat
funcresolve_public_id(public_id) -> Optional[str]Resolve a broadcast-safe public id back to the seat's real token.
The inverse of public_id_for, used to translate a room-management
target (e.g. who a gamepad slot goes to) that a client can only name by
its public id back into the token the rest of the session logic keys on.
parampublic_idOptional[str]The publicId a client sent to name a room member, or None.
Returns
typing.OptionalThe matching seat token, or None when there is no session, no seat
funcinvite_token(permission) -> strReturn the session's shareable invite token for a permission, minting it on first use.
The token identifies the session and the permission, not a person: every
arrival on it is seated separately by add_viewer, so the same link can go
to any number of friends and stays valid until the session ends.
parampermissionstrThe permission the link grants, participant or readonly.
Returns
strThe invite token for that permission.
funcfind_invite(token) -> Optional[str]Resolve an invite token to the permission it grants.
paramtokenstrThe invite query parameter from a landing URL.
Returns
typing.OptionalThe permission, or None when no session is active or the token is unknown.
func_release_seat(token) -> Optional[dict[str, Any]]Drop a removed seat's rate-limit cooldowns and role, and detach its live connection.
Called for a seat leaving SESSION["viewers"] outside the normal disconnect
path (a same-user rejoin replacing it, or the reclaim of a stale seat), both
of which can happen while the seat still holds the speaker or MK role from
the unvalidated set_designated_speaker/assign_mk actions, or a cooldowns
entry, both cleared here. A rejoin can also evict a seat whose socket is
still connected (a second tab, a reload racing the old tab's close): that
connection is detached from ROOM["viewers"] here, synchronously, so
nothing can look it up as the seat's connection anymore, but closing it is
left to the caller. It must stay a synchronous pop rather than an awaited
close here, since add_viewer's cap check right after this needs the whole
admission decision to run without yielding control, or a concurrent join
could interleave mid-decision and seat past the cap.
paramtokenstrThe removed seat's token.
Returns
typing.OptionalThe token's live connection info, for the caller to close, or None if
funcadd_viewer(permission, user=None) -> Optional[dict[str, Any]]Mint a viewer token and add the viewer to the active session.
A re-join by the same user (matched by id, else username) replaces the old
entry and invalidates its token. If that old seat still has a live room
connection (a second tab, a reload racing the first tab's close), that
connection is force-closed rather than left running on a token nothing
recognizes anymore. The close is deferred until every seat has already
been decided (see _release_seat), so it never interleaves with a
concurrent join's own admission decision.
parampermissionstrThe viewer's permission level, e.g. readonly.
paramuserOptional[dict[str, Any]]= NoneThe joining user's details (id, display_name, username), or
None for an anonymous viewer who gets a generated username.
Returns
typing.OptionalThe new viewer dict: `{"token", "public_id", "user_id", "anonymous",
func_room_connections() -> list[dict[str, Any]]List every live room connection, the controller's first.
Returns
listThe connection-info dicts currently registered in ROOM.
funcbroadcast_to_room(payload) -> NoneQueue a JSON message for every connected room member.
Each member's outbox sends it on its own time, so a member whose
connection is backed up delays nobody but itself (see outbox).
parampayloaddict[str, Any]The JSON-serializable message, normally carrying a type key.
Returns
Nonefuncbroadcast_binary_to_room(payload, sender_ws) -> NoneQueue a binary media frame for every connected room member except its sender.
Runs inline in the sender's receive loop, so it never waits on a recipient.
parampayloadbytesThe raw frame in the room's binary wire format.
paramsender_wsWebSocketThe websocket the frame arrived on; it is skipped.
Returns
Nonefuncrequest_keyframe(media_id) -> NoneAsk the member streaming under media_id to encode its next video frame as a keyframe.
Called by a recipient's outbox after it dropped one of that member's video
frames. Requests to one member are spaced KEYFRAME_REQUEST_GAP apart,
since keyframes are the largest frames and would add to the congestion.
parammedia_idbytesThe sender's per-connection media id, as stamped on its frames.
Returns
Nonefuncbroadcast_state() -> NoneBroadcast a state_update describing every room member to the room.
The controller is listed first, then each viewer with its online status
and mouse/keyboard ownership. Members are identified by publicId, a
non-sensitive stand-in for their seat token: broadcasting the real token
would hand every room member, including anonymous viewers, the bearer
credential needed to reconnect as (or impersonate) anyone else. While a
member is connected its mediaId, the per-connection id its live socket
tags binary media frames with, is included too. Does nothing when there
is no session.
Each entry is assembled field by field rather than copied from the seat:
everyone in the room reads this, so a seat's RomM user_id and the
bookkeeping the room has no use for stay on the server side.
Returns
Nonefunchandle_assign_slot(viewer_token, slot) -> NoneAssign a gamepad slot to a room member, or take theirs away.
A slot can only be held by one member, so whoever held it before is
unassigned first. The new token map is pushed to selkies and a
gamepad_change notification is broadcast for every change made, followed
by a state update. Unknown tokens are logged and ignored.
A push selkies refuses is logged and the room is still told: the notification describes what the session now holds, and the next successful push carries the whole map, so nothing here is worth leaving the room's view of itself out of step with the broker's over.
paramviewer_tokenOptional[str]The token of the member to change; the controller's own token targets the controller.
paramslotOptional[int]The gamepad slot to assign, or None to unassign.
Returns
Nonefunchandle_assign_mk(target_token) -> NoneHand mouse and keyboard control to a room member.
The controller's own token and None both mean control returns to the
controller. A no-op when the target already holds it; otherwise the token
map is pushed to selkies and an mk_change notification and a state update
are broadcast. A push selkies refuses is logged, since input keeps routing
by the map it already has until some later push lands.
paramtarget_tokenOptional[str]The token of the viewer to receive control, or None (or the controller's token) to give it back to the controller.
Returns
Nonefuncnotify_session_ended() -> NoneTell the room the session has ended, then close and forget every connection.
Forgetting a socket does not end it: the handler behind it stays parked in
receive() forever, so every connection of every ended session would
survive as a live socket and a running task. Each one is detached from
ROOM first and closed after, which also means a handler waking on the
close finds itself already replaced and skips its per-member cleanup: the
seat, role and token-map releases it would do are moot against a session
being torn down, and its departure notice would go to a room that has
already been told the whole session ended. Broadcasts only queue, so the
notice is given up to SESSION_END_FLUSH_WAIT to go out before the
sockets close under it.
Returns
Nonefunc_close_at_session_end(conn) -> NoneClose one room socket, giving up after outbox.CLOSE_WAIT on one that cannot take it.
paramconndict[str, Any]The room connection to close.
Returns
None