Skip to content

Feat(streaming): emulator streaming v2 - #4173

Open
LoneAngelFayt wants to merge 82 commits into
rommapp:masterfrom
LoneAngelFayt:feat/emulator-streaming-v2
Open

Feat(streaming): emulator streaming v2#4173
LoneAngelFayt wants to merge 82 commits into
rommapp:masterfrom
LoneAngelFayt:feat/emulator-streaming-v2

Conversation

@LoneAngelFayt

@LoneAngelFayt LoneAngelFayt commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Description
Explain the changes or enhancements you are proposing with this pull request.

Replaces and builds upon #3856, carrying over all previous improvements while introducing version 2 enhancements for containerized emulator streaming.

Leverages https://github.com/romm-streaming/romm-broker, which builds into linuxserver/docker-webstation:romm.

Closes #3968

Additions include a single container for emulator streaming, widened compatibility, and multiplayer. On top of that, this update adds:

  • Streaming config now takes one entry per container instead of one per emulator. A container's settings (host, protocol, label, etc.) become the defaults for every platform it serves, and a platform block only needs to override what's different, like which emulator handles it.
  • Platform cards in both grid and list view show a play-mode badge: a solid icon when a platform is browser-only or stream-only, and a diagonal split when it supports both.
  • Save states and save files captured while streaming get pulled back into the RomM library automatically, with state history and thumbnails you can browse per game.
  • Multi-disc games get a disc-swap control in the stream bar. Whichever disc is mounted when a state is captured gets stamped onto it and remounted automatically on resume.
  • A memory card library: create, rename, share, and version memory cards, download one as a zip, or upload your own.
  • PS2 and GameCube can opt into syncing the whole memory card instead of per-file saves. The first time a container syncs, an admin is asked once whether to adopt or wipe whatever card was already on it.
  • Admins get a desktop session for configuring an emulator inside a container with no game running, plus a fleet view to see and force-release active sessions across every container.
  • Platforms can pool multiple containers and claim whichever is free, instead of being pinned to one.
  • Where a platform supports both browser and streaming, Play and Stream now show as separate buttons, and streamable platforms count as playable for sorting and grouping.

Review passes hardened what is above:

  • Memory card uploads are bounded by the same size limit as the other asset uploads, an emulator name has to be a plain folder name before it is used as one, and a zip entry is split on both separators so a hand-written ..\..\evil cannot pass as a single opaque path part.
  • Deleting a memory card now lists and locks its version archives inside the delete's own transaction, so a snapshot written alongside the delete cannot end up gone from the database and left on disk. A claim that aborts after adopting a card drops the archive it picked up on the way.
  • Broker responses are read in bounded chunks against a wall-clock deadline rather than in one call with a per-socket timeout.
  • Release, save-state and load-state failures are reported to the user instead of being swallowed, a release names which container to end so a pooled platform ends the session the user is actually in, and a failed refresh of the joinable list keeps the last known one rather than emptying it.
  • Every path that frees a container (owner release, save-and-exit, stale takeover) now goes through one compare-and-set primitive: a session is only replaced or deleted while it is still the claim the route resolved, and the exit drain carries a token so an overrun cannot free whoever took the container afterwards. A save-and-exit that could not hand the container back says so, and the player retries the release rather than being told it exited.
  • While an exit state is still being pulled out of a container, the drain marker guarding it is short and refreshed by the backend doing the work, so a backend that dies mid-pull frees the container in a minute instead of parking it for the length of a transfer nobody is doing.
  • A memory card archive is measured by decompressing it against a cap rather than by trusting the sizes in its own headers, so a zip bomb cannot be handed to a container to unpack.
  • Closing the tab hands the container back based on whether the claim is still held rather than on what the player is showing, so an exit whose release failed no longer strands a container.

Some AI assistance (Claude Code) was used in this PR, mainly for implementation and test coverage on top of my own design decisions and review.

Checklist
Please check all that apply.

  • I've tested the changes locally
  • I've updated relevant comments
  • I've assigned reviewers for this PR
  • I've added unit tests that cover the changes

Screenshots (if applicable)

icon to jump right to streaming on game card
image

Icon to show streamable in platform view (split when both emu.js and streamable)
image

Multiplayer toggle in bottom right
image

webcam/voicechat along bottom with host handing out controllers, textchat/settings/ad-hoc invite on right. both panels hidable

image

when a multiplayer session is active, a join button appears on the game details page

image

example config

image

LoneAngelFayt and others added 30 commits July 20, 2026 14:45
Extend the streaming session layer with library-backed asset sync and
per-user memory cards.

State library sync: manual and autosave states are pulled from the
container into the user's library, with PCSX2 screenshots extracted from
the .p2s archive as state thumbnails. A session can be claimed with a
state id to resume directly from a library state.

Save file sync: save files are hydrated into the container at claim and
pulled back on release, deduplicated by content hash.

Memory cards: a new per-user, per-emulator whole-card model with version
history (memory_cards, memory_card_versions). Opt-in per platform,
currently PCSX2 only. Cards are hydrated at claim (wipe-then-replace) and
evacuated before the session is released.

Playtime: session start and end are ingested as play sessions, skipping
runs shorter than five seconds.

Session hardening: heartbeats mark liveness, and a session whose
heartbeat has gone stale can be taken over after the previous container
is torn down.

Frontend: resume-from-state picker, memory card picker and manager, asset
previews, and an admin streaming panel on the activity view.
…session

An admin ending a session from the activity panel used to leave the
player's tab stranded on a dead stream with no indication anything had
happened. The player's tab now learns of this near-instantly via a
socket push to a per-user room, with a slower REST poll (30s) as a
fallback for a dropped event or a throttled background tab, exits
fullscreen, and shows a dialog naming the admin and the reason given
before returning to the game's details page.

Backend: a 15-minute Redis tombstone records who ended the session and
why, read by both the new side-effect-free status endpoint and the
existing heartbeat, and pushed live over Socket.IO the moment it is
written. The self-release guard is corrected to key off whether a
reason was supplied rather than off user identity, since an admin can
be logged into the same account they are evicting.

The admin panel's release action now prompts for an optional reason
that is shown to the displaced player alongside the admin's name.
Pulled states overwrote the slot they came from, so a player who saves
often could only ever resume from the last capture in each slot. Every
capture is now its own library asset, and the resume picker is the way
back to any earlier point.

The container filename encodes the emulator slot, so the library name and
the container name have to diverge. A capture stamp is inserted directly
before the slot token, which keeps both slot patterns matching at the end
of the name: states written before this resolve unchanged, and the
container-side name is recovered by dropping the stamp. Retention is
capped per ROM, emulator and user by STREAMING_STATE_HISTORY_LIMIT
(default 50), pruning oldest first, and a capture identical to the
previous one is dropped rather than taking a slot in the history.

Hydration now pushes only the newest state: every history entry collapses
to the same container-side name, so pushing more would just overwrite in
place. A resume pick already sent at claim time suppresses the push
entirely, since it would land before the broker's deferred load fires.

With the library holding the history, slots stop being a user-facing
concept and become the register the emulator writes through. The player
pins to the autosave slot, which is also what hydration targets and what
save-and-exit already used, so in-game quick-load lands on the same file
the picker last sent down. That made the slot picker and the separate
load-autosave button redundant, and required save-state to accept the
autosave slot: the two coarse slot bounds collapse into one, and
_assert_valid_slot no longer takes an allow_autosave flag.

Frontend: the state strip gains grid and list layouts for histories the
horizontal row buries, persisted per user. The pre-game screen is one
layout instead of two, with the resume panel always present so a first
run and a long history read as the same screen, and the memory card
picker split into its own section.

Dolphin inherits all of this. xemu does not: it has no slot convention to
place a stamp against, so its captures still update in place.
PCSX2 embeds a PNG inside every .p2s savestate, so a pulled state gets its
resume-picker thumbnail for free. Dolphin savestates carry no frame, so its
broker captures one at save time and serves it from GET /state-screenshot.

Move the sourcing decision out of _store_state_asset and into
_pull_state_to_library, so extract-or-fetch is resolved in one place and the
image is passed down. A 404 from the broker is the normal "this one captures
no frames" answer, so it is not logged.

Also drop the PCSX2-only wording from the memory-card comments, since Dolphin
now shares those paths.
Neither screenshot source validated what it handed over. The zip path trusted
an entry that only claims to be a PNG by name, and the broker path trusted
whatever came back over HTTP, so an error page from a proxy in front of a
broker would have been written into the user's screenshots directory and bound
to a state as its thumbnail.

Check the magic bytes in _store_state_screenshot rather than at each source,
since both feed that one function.
The seven hand-rolled urllib blocks for state files, save archives and
memory cards become three primitives: a raising binary GET, a
best-effort GET that swallows the routine 404, and a best-effort PUT.
The nosec annotation and the secret header now live in one place each.
…eaming-additions

Conflicts were confined to files upstream barely touched:

- streaming.py / test_streaming.py: upstream moved every mutating session
  route from ROMS_READ to ROMS_USER_WRITE so KIOSK_MODE visitors, who all
  share one synthetic user, cannot claim sessions or overwrite each other's
  save states. Applied the same rule to the routes this branch adds, so
  heartbeat now gates on ROMS_USER_WRITE while the side-effect-free status
  route stays on ROMS_READ, and added heartbeat to the kiosk test matrix.
- Stream.vue: this branch replaced the launch screen with a pre-game config
  panel, so its structure wins; ported upstream's usePageTitle call and the
  GameCover style-context="player" prop onto it.
- locales/*/play.json: kept both sides' keys.

Also renumbered this branch's migrations onto the new upstream head:
0104_memory_cards and 0105_container_adoptions collided with upstream's
0104/0105 and left two alembic heads, so they become 0108 and 0109 chained
onto 0107_roms_dedup_cover_index.
…pted adoptions

Detached teardown deleted the Redis claim inside the try block, so a raise
from broker shutdown, card evacuation, wiping or the force-release note
skipped it. The API had already answered "released", leaving the container
occupied to everyone else until stale takeover or the six-hour TTL. The
delete moves to a finally: a card left un-evacuated is recoverable, since
the next claim prompts to adopt whatever is still on the container, but a
phantom claim is not.

Adoption recorded the card version and the decision row in separate steps.
When the version committed and the decision did not, the retry read the same
container card, dedup refused a second copy, and the claim aborted 502 on
every attempt. That case is idempotent, since hydrate would push back the
bytes already on the container, so it now records the outstanding decision
and continues. A dedup match against a version that is not the latest still
aborts, because hydrate would push different bytes over the adopted card.

test_adopt_deduplicated_against_older_version_aborts asserted the trapped
502, but its fixture stored only one version, which was therefore the latest
and the recoverable case. It is replaced by two tests covering the retry and
a genuine older-version match.

Public memory cards stay view-only at claim: a mounted card is written back
as a new version belonging to its owner, so hydrating someone else's card
needs copy-on-mount semantics that do not exist yet. Docstrings on the
shared-card listing and get_card_by_id claimed the claim picker already
mounted shared cards; they now describe the owner-scoped behavior.
The merge brought in a useCanPlay mock that predates the streaming branch,
so the composable destructured an undefined ref.
- list_containers now reads the container's own default label instead of
  an arbitrary expanded platform row's possibly-overridden label
- remove dead getMode/streamable exports from usePlatformPlayable
- simplify PlatformListRow's off-state label instead of routing through
  unused mode/emulator/streamLabel args
- add role=img to the off-state badge span for accessible naming
- add an active broker_host line to the config example's headline
  self-signed-cert template
- drop the unused overrides param from the webstation test helper
Previously only pinned under pytest-xdist, so a single-worker run could
inherit DB_NAME from the ambient environment (e.g. a sourced .env pointing
at a real dev/prod database) and the autouse clear_database fixture would
wipe every row in it.
Enforce ROM visibility on the multiplayer join and joinable-session
routes, matching the policy the claim route already applies. Validate
that a swap-disc target is an actual disc, bound zip entry hashing so a
crafted archive cannot exhaust memory, and make the memory card batch
delete resolve every id before destroying anything.
Backend:
- reject an emulator name that is not a plain folder name at card creation
- split both separators before judging a zip entry, so a hand-written
  `..\..\evil` cannot pass as one opaque path part
- return the version archive paths from delete_card, listed and locked in
  the delete's own transaction, so a snapshot written alongside cannot be
  deleted in the database and left on disk
- drop the archive a claim's blank card picked up when the claim aborts
- bound the memory-card upload path like the other asset uploads
- read broker responses in bounded chunks against a wall-clock deadline

Frontend:
- forward the container to the release calls, so a pooled platform ends the
  session the user is actually in
- keep the last known joinable list when a refresh fails
- report a failed release, save-state and load-state instead of swallowing it
- start the play session for a joiner, not only for the claimant
- decide the 404 from the container config the view holds, not from the
  wording of the backend's detail string
- guard against a double delete of the same memory card
- reclaim stream focus only when nothing outside the stage holds it

Tests: broker response doubles now drain like a socket rather than
answering the same bytes forever, and the save-and-exit slot guard the
route gained is covered.
@gantoine gantoine added the on-hold Pending further research or blocked by another issue label Aug 16, 2026
@gantoine
gantoine self-requested a review August 16, 2026 15:14
Consolidate the zip safety gate into utils/memory_cards so the upload
route, the import tool and the broker hydration path all share it, and
extend it to reject symlink entries. Have store_memory_card_version
return the version it wrote so the upload route stops re-reading the
card's latest, which a concurrent teardown could swap underneath it.

Guard the session disc write with a claim predicate, bound the broker
error bodies, quote the import and export path segments, and wait on an
abandoned teardown with a shielded budget instead of cancelling it.

On the frontend, treat holdsClaim as the only record of whether the
container is still held, gate the Streaming settings tab and its deep
link on app.admin, and show a load failure in StreamingSection.
Make the exit drain marker a CAS write that only lands while the key
still holds the claim that is exiting, give it a token so the pull that
follows deletes its own marker rather than whoever holds the container
by then, and derive its TTL from the pull's own worst case instead of a
flat five minutes that a slow transfer outlives.

Spend the abandoned-teardown budget across the whole candidate sweep
rather than per container, close the broker error responses the two
remaining wrappers leaked, cap what a card archive may unpack to, and
say "still saving" on a claim that lands on a drain marker instead of
naming a holder that does not exist.

On the frontend, let pagehide retry a release that failed, and reselect
a memory card when the fetch for a new emulator fails.
Keep a claim's liveness stamp current while backend-side exit work runs
under it, so an exit whose drain marker never landed cannot be judged
abandoned and torn down mid-pull. Contention on a marker refresh retries
instead of ending the refresh loop, and the archive safety check runs off
the event loop.
A marker or a claim is refreshed only up to a ceiling: every step under a
keepalive carries its own timeout, so overrunning it means something is
wedged, and the container ages back out instead of staying reserved. Also
caps how many uploads may decompress in the shared executor at once.
…eaming-v2

# Conflicts:
#	frontend/src/locales/en_GB/play.json
#	frontend/src/locales/ja_JP/platform.json
#	frontend/src/locales/pt_BR/platform.json
@LoneAngelFayt
LoneAngelFayt marked this pull request as ready for review August 16, 2026 22:32
@LoneAngelFayt

Copy link
Copy Markdown
Contributor Author

@gantoine
out of draft, this is ready when you are!

@thymon13

thymon13 commented Aug 16, 2026

Copy link
Copy Markdown

@LoneAngelFayt
I know this isn't really the place for it, but ; your release is going to be incredible. Seriously, hats off.
Happy to test any of it on Dolphin/Eden with a reverse proxy setup whenever you want another pair of eyes.

PSP had no entry in the emulator capability table, so the launch page
always showed save-data-only for it, even for a PPSSPP session whose
save states genuinely work. Adds a "ppsspp" entry alongside the
existing "retroarch" one (emulator-keyed rather than platform-keyed,
since PSP can also be served through RetroArch with different slot
semantics) reporting the single working slot the broker's controls.ini
hotkey always lands on.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

on-hold Pending further research or blocked by another issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Protect the emulator streaming container behind RomM authentication

3 participants