Feat(streaming): emulator streaming v2 - #4173
Open
LoneAngelFayt wants to merge 82 commits into
Open
Conversation
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.
…latforms composition
- 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
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
marked this pull request as ready for review
August 16, 2026 22:32
Contributor
Author
|
@gantoine |
|
@LoneAngelFayt |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Review passes hardened what is above:
..\..\evilcannot pass as a single opaque path part.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.
Screenshots (if applicable)
icon to jump right to streaming on game card

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

Multiplayer toggle in bottom right

webcam/voicechat along bottom with host handing out controllers, textchat/settings/ad-hoc invite on right. both panels hidable
when a multiplayer session is active, a join button appears on the game details page
example config