Skip to content

How the panel gets into Steam

The backend serves the panel and also puts it there. Nothing else loads it: there is no plugin loader in this path, no file copied into a plugin directory, and no manifest anybody reads. backend/host/inject/ opens Steam's CEF debugger, finds the renderer, and evaluates one expression into it.

Frontend bundles owns what the files ARE. This page owns how one of them reaches Steam, which of them is chosen, and what happens when that goes wrong.

Steam's remote-debugging marker

There is no debugger to open unless <steam root>/.cef-enable-remote-debugging exists — Steam reads it at start-up and opens the port only for it. So the backend creates it when it is missing, on every start that is loading the panel (ensure_debugger_marker, backend/host/inject/machine.py), logs at WARNING that Steam has to be restarted once, and writes a note named debugger-marker under the state root recording that the file is ours. TENDER_INJECT=off writes nothing: a switch that says to leave Steam alone may not put a file in Steam's directory.

It is re-created on every start rather than once because something else takes it away. Decky Loader's installer creates the marker unconditionally and its uninstaller removes it unconditionally (SteamDeckHomebrew/decky-installer, cli/install_release.sh and cli/uninstall.sh), and the loader itself never touches it — so a user who removes Decky from a machine that also runs Tender removes Tender's only way into Steam with it, and the symptom is a panel that stops appearing with nothing said. Nothing on that side can be changed, which is why this side re-creates it.

The note is what install.sh --uninstall reads to decide whether the marker is its to remove, and its FIRST LINE is the marker's absolute path — the uninstaller unlinks exactly that path and nothing else, because a note that named only a filename would send it looking, and looking is what it must not do. It takes the marker away only where such a note exists AND no Decky Loader is installed, so a marker somebody else needs is left where it is. Both sides spell the note's filename as a literal, and the suite holds the two spellings equal.

The sequence

From the debugger port answering to the panel being there:

Step What it is
List the targets GET /json on 127.0.0.1:8080, retried until Steam has named its renderer
Attach one WebSocket to the SharedJSContext target's webSocketDebuggerUrl
Page.enable so Page.domContentEventFired arrives; subscribed to before it is enabled, so none is missed
Ask for the marker typeof window["__tender_panel__"] !== "undefined", and if it is there, whose it is — see a panel an earlier backend left behind
Wait until ready Steam's module registry, and beside Decky its copy of @decky/ui as well
Evaluate one expression that claims the marker, imports the chosen bundles in order, and calls the globals installer between importing the bundle that defines it and importing the panel
Ask again, later is Steam's interface still there? — see the crash watchdog

After that the loop waits on two subscriptions and nothing else — Page.domContentEventFired, which says the marker has been wiped, and Runtime.bindingCalled, which is the card's one button. It polls in exactly two places, both before the panel is in: while Steam is still naming the renderer, and while the page is becoming something the panel can load into.

A target the debugger lists with no title is not "nothing is open". Measured on the device from the port answering: a target appears at +0.20 s with an empty title, the same target is renamed SharedJSContext at +0.6 s, and webpackChunksteamui is ready at +0.84 s. Discovery that read the first miss as a verdict would give up half a second before the answer existed, which is why every miss here is retried and the log distinguishes "targets, none named yet" from "nothing there".

Which bundles, and the rule that cannot bend

dist/globals.js is never loaded where Decky Loader is running. It carries @decky/ui's module sweep at import scope, and re-running that sweep under an interface already rendering from those modules is the crash that takes the Big Picture window down — the only crash cause ever observed here (frontend bundles).

So there are two answers and they are not two spellings of one thing:

The machine Loaded Why
Decky Loader is not serving globals.js, then index.js nothing else is loading into Steam, so Tender installs the React globals
Decky Loader is serving index-coexistence.js alone the loader has installed those globals and holds a loaded @decky/ui

Importing the React bootstrap installs nothing — calling it does. That bundle only defines installGlobals and leaves it on the window; the expression calls it by name between importing the bundle that defines it and importing the panel, and imports the panel only where the report it answers with says every global it names is installed. The panel reads those globals while its own modules evaluate, so importing it with one missing throws there, before any code of ours can notice — which is why a missing one is refused before the panel is imported, and the sentence it is refused with is what the card shows and what the injector logs. Which of the loaded files carries the installer is the bundle choice's own answer, so the expression counts nothing out for itself; where the choice carries no such file — beside a serving Decky, where the panel is the only import — nothing is called at all.

The answer comes from the machine, never from the window. At the earliest moment an injection is possible, every marker Decky eventually sets — DFL, DeckyPluginLoader, DeckyBackend, deckyAuthToken, deckyHasLoaded — is still undefined (measured); a machine with Decky and one without look identical there. The injector is a local process, so it asks the system: does Decky Loader's own server answer on its port? Three questions look alike and only that one is worth asking — "is it installed" is a directory, and a machine that installed it and turned it off answers yes; "is the unit active" is systemd's word for a process that may be starting or wedged. Everything Decky renders into Steam is served from that port, so a loader that is rendering has answered there.

It is still not "Decky is rendering", and the remaining gap is left on the safe side: a loader that started five seconds ago has not injected yet and this reads it as serving. That costs a machine with Decky nothing — it gets the bundle that shares Decky's copy, which is what it wants either way.

Beside a serving Decky the injector then WAITS for DFL before loading, which is not a reading of whether Decky is there (the machine has already answered that) but a wait for something known to be coming: DFL was still undefined at +4.65 s on the reference machine, and Decky had finished at +10.6 s with ten plugins.

The marker

window.__tender_panel__ is how a context says it already carries the panel. Measured, in two device runs with a mark of their own rather than with this marker: a JS-context rebuild WIPES such a mark, while a mark planted on the first Page.domContentEventFired survived the settle that followed — eleven Page.windowOpen events over 16 s. In the other run a mark planted at +0.96 s was still there after Decky had finished at +10.6 s with ten plugins, which is what says Decky starting beside us does not rebuild the context.

The expression claims the marker BEFORE it imports anything, so a second evaluation cannot load the panel twice. It holds the marker even when the import fails, which is what stops a broken bundle being retried into the same context — the load-failure card explains that state instead. It also keeps a restarted backend out of that context: a failed load can leave the modules @decky/ui's sweep re-executed, and part of the panel, behind, and a marker naming an earlier backend is what sends the new one to the recovery's rebuild instead.

The marker is an object: the version of Tender that wrote it, which panel bundle it loaded, and an instance — a random value each backend process makes for itself at start-up. The instance is what lets a process tell its own panel from one an earlier process loaded. It is not the token and authorises nothing; a marker written before markers carried one reads as an empty instance, which no running process has.

A panel an earlier backend left behind

When the backend restarts while Steam keeps running — a reinstall, systemctl --user restart romm-tender, or the unit's Restart=always after a crash — the panel the previous process loaded stays in Steam. It carries the previous process's token, so the new server refuses its socket, and the new injector finds the marker and loads nothing over it. The game page's Tender section stays at "Loading..." in a panel built before panels read the close codes below, and says that Tender was restarted in one that reads them — and a game launched from Steam starts without Tender: no save sync around it and no playtime.

How the backend knows. Whenever the injector finds a marker, it asks whose it is. Its own instance means a panel it loaded — the ordinary case after the debugger connection was lost and re-attached — and is left alone. Any other instance is a panel no running backend can reach, because the single-instance lock allows one backend at a time. A marker whose owner cannot be read is treated as the process's own, so an unanswered question never reloads anything. A refused knock on the port is deliberately not a signal: any page can send one, and it says nothing about what is loaded in Steam. The knock is answered, though — the panel is told, below.

What it does about it (backend/host/inject/recovery.py):

  1. Waits until no app is running. It reads SteamUIStore.RunningApps — the source the panel itself reads running apps from — every five seconds, and only two readings in a row with no entry that holds let it act: the store has been measured listing none for several seconds with a game still up, under a condition the reader's own module comment names (frontend/src/utils/runningApps.ts). An entry counts by its display status, as it does in the panel (Is the game running), and the way the panel's restart reads it rather than the way the panel asks whether a game runs: the wait holds for Launching and Terminating as well as Running (DISPLAY_STATUSES_THAT_HOLD), because a game on its way in or out is still one a reload would take the interface away from, and an entry whose status it cannot read holds too — its line in the log says (status unread) beside the name. So a game Steam keeps listed after it exited, reading ReadyToLaunch, does not hold the reload. A store it cannot read, a shape it does not know, or no renderer attached is no answer, and it keeps waiting. Then it asks the page whose panel it carries, rather than trusting the last reading, and acts only if it is still the earlier backend's. The same gate stands in front of the fallback. Each entry of the reading also names its appid, which the reload does not read; the save sync and the removed-game cleanup match it to a ROM, to leave a running game's saves and files alone (Is the game running).
  2. Asks Steam to rebuild its JS context with SteamClient.Browser.RestartJSContext(), evaluated in SharedJSContext. Evaluated directly, the call answers "Cannot find default execution context" — the answer that led ADR-0024 to rule the call out. Scheduled with setTimeout, as Decky Loader schedules the same call (backend/decky_loader/helpers.py, since 2024), the evaluation returns first and the rebuild follows. The rebuild wipes the marker and fires Page.domContentEventFired, so the panel is loaded again through the ordinary sequence — nothing here loads it.
  3. Watches for its own panel to connect for 20 seconds. Where Steam answers that it has no RestartJSContext, there is nothing to wait for, and it goes straight to the fallback.
  4. Falls back once. If the earlier panel's marker is still there after that window, it passes the same gate again, then sends SIGTERM to every steamwebhelper process this user owns, and Steam starts the web helper again. It then watches 60 seconds for the panel. If the reload did rebuild the context and only the panel is slow — beside Decky Loader it came back about 6 s after a reload — it does not fall back, because that would take the interface away from a load already under way.
  5. Then stops. One reload and one fallback per stranded panel; if the same panel is still there, or no panel came back, it says so and gives up. A later backend restart is a new stranded panel and starts over.
  6. And never more than twice in ten minutes on this machine, counted across backend starts. The once-rule above cannot stop a crash loop: a backend that crashes after loading its panel, started again by its service manager, leaves a new stranded panel behind every time. So every takedown is written, with its time, to <state_dir>/reload-guard.json (backend/host/inject/reload_limit.py): a fallback SIGTERM before it is sent, a reload once Steam has taken the request — or given no answer to it, since the reload may have happened all the same. A reload Steam answers it cannot do took nothing down and is not counted. A third takedown inside the window is refused with one WARNING line that says to restart Steam. Two leaves room for a deliberate restart or a reinstall and one more right after it. A record it cannot read counts nothing and one it cannot write records nothing, so neither blocks a takedown: that is the crash watchdog's lenient direction, for the same reason — a read-only state directory is no reason to leave a panel stranded.

Every step is one log line — stranded panel seen, waiting for an app to exit (naming it), reload issued, panel back and after how long, fallback taken, all at INFO; giving up (the limit's refusal included), a Steam without RestartJSContext, and a panel whose owner cannot be read, which is left alone, at WARNING; an unexpected failure at ERROR with its traceback — so a run can be judged from the log alone.

Why RestartJSContext, measured on a device in windowed Big Picture beside Decky Loader:

Route What happened
RestartJSContext() the window closed and reopened within about a second; a fresh renderer process, and total web-helper memory went from 1310 to 1109 MB. The debugger connection to SharedJSContext survived, Tender's panel was back after 3–6 s and Decky Loader's after about 6 s
CDP Page.reload the same renderer process, about 170 MB larger after 90 s. Decky Loader replaced its own location.reload() with a scheduled RestartJSContext() in 2024 over leaks and broken toasts
SIGTERM to steamwebhelper the interface was gone for about 8 s, and Decky Loader counts it as a web-helper crash towards its own fallback (when it comes within a minute of another web-helper exit) — so it is only the fallback here
Restarting Steam about 16 s

Not a crash. Both the reload and the fallback take the interface away, which is exactly what the crash watchdog watches for. Each is counted by the injector before it happens, and an alive check that finds such a count moved since its injection closes the record without judging it.

Not measured: SteamOS Game Mode, which is why every step is logged.

What the stranded panel is told

A browser does not show a page why a WebSocket handshake failed: a refused upgrade reaches it exactly as a port nobody listens on would. So the server completes the upgrade of a stranded panel — Host and Origin passed, a token offered that is not this process's — and closes it with a code of Tender's own once it knows which is true (ADR-0043; backend/host/server.py, the codes in backend/host/protocol.py):

Answered with What it says When
plain 401 none yet — the panel keeps knocking the injector has not yet read the attached context's marker since this backend started
4001 this backend reloads Steam's interface once no game is running the recovery above is under way for a panel it has seen, and the limit would let it act now
4002 Steam has to be restarted everything else: no stranded panel there, the recovery gave up or ended, or the limit refuses

Nothing is attached to that socket — no connection, no dispatcher, no events — so it authorises nothing. A request with no token, the static route, and the Host and Origin refusals keep their plain HTTP status. The answer is asked of the recovery on every knock (StrandedPanelRecovery.reload_outlook), and it is strict: a window the limit frees later is a later answer, never a promise now.

Before the first reading there is no answer. Until the injector has read the marker of the context it attached to — the first reading after the backend starts, whether it found a stranded panel there or not — the backend does not know whether a reload is coming, so it refuses the upgrade with the plain 401, as it refuses any other wrong token. The panel reads that as no backend and keeps knocking, and its next knock after the reading gets a code. A backend that never reads the context — Steam's debugger never answers, or loading the panel is switched off — refuses with 401 for good, and logs each knock. That is accepted: a panel is stranded only in a context an earlier backend reached over the same debugger.

Once there is an answer, the log carries one WARNING per stranded panel, and one more when its answer changes. A panel built before panels read these codes does not stop: it reconnects several times a second until Steam's context is rebuilt, and the log-once rule keeps that to one line.

What the panel does with it: on either code the socket gives up for good (frontend-bundles.md, Talking to the backend). One notification says the answer when the panel becomes stranded, and one more each time it changes.

It offers no press that cannot work (ADR-0043, amended): such a panel is never admitted again, so every press that reaches the backend could only fail. The stranded card (bigpicture/StrandedPanelCard.tsx) stands in place of the whole Quick Access panel, Main included (where that is decided: qam-panel.md, Pages), and of the game page's section below the play row (RomMGameInfoPanel.tsx), ahead of every other card there, on a page loaded before the strand as well as after. While an update attempt has started the installer the card adds that the update's result shows after the reload or restart it names, read from the install store without a call (installerStarted, utils/updateInstallStore.ts). While a game runs or is starting it adds that Tender cannot stop it. Where no game is seen to run but the store lists apps whose display status could not be read — which hold the reload all the same — it says instead that Steam lists them, that Tender cannot tell whether they still run, to quit them if they are open and to restart Steam if they have closed (Is the game running). The play row keeps the Tender restarted badge, Steam's own Last played and Playtime, and Space Required, but not the Achievements and BIOS badges, whose tabs the card has replaced. It offers Resume for its game while that game runs and nothing else: no Play, no Stop, no gear, no version or disc picker, no download control. Under a version error or a pending RetroDECK migration the row stays hidden, as it is otherwise. A Resume on an overlay whose game no longer runs only takes the overlay down. On a page opened after the strand the play button never learns which game it is for, so it offers nothing there, a running game included. Stop Game in a menu opened before the panel learnt it was stranded says at once that it cannot stop the game. Steam's own Play on a RomM shortcut is refused before the launch gate asks anything: the launch interceptor cancels the start, as it always does, and then reads the answer the socket holds, with a notification that it could not start the game. A Play press on a game already running still passes, since the interceptor's running-game guard comes before the cancel. On a game's page a connection check refused as stranded is no verdict on RomM, so it writes none, and the badge says Tender was restarted whatever RomM last answered. What each of them says: troubleshooting.md. A Tender dialog already open when the panel is stranded stays open, and its buttons fail as they always did.

The fallback page, which stands in the panel's place where the start-up check refused it, is not the card, and its path does none of what the card's does: it raises no notification and asks no re-check. The page drops its update buttons, keeps Restart Steam, and says the same answer, with the line that an update's result shows after the reload or restart that answer names where the installer had started (frontend-bundles.md).

The panel asks again — one connection opened only to read the close, bounded at two seconds, never two at once — when the Quick Access menu is opened on Tender's page, when Steam's own Play is pressed, and when Stop Game is pressed in a menu opened before the strand.

The crash watchdog

The one way this feature can go catastrophically wrong is taking the Steam interface down, so it is watched.

Not a crash counter. The signature, provoked deliberately on the device: the Runtime.evaluate returns successfully after 0.13 s, 3.04 s later four page targets vanish at once leaving only SharedJSContext, the debugger keeps answering, and nothing recovers within 45 s. Because SharedJSContext survives, our marker survives with it — so the injector sees "already injected" and never tries again. Within one Steam session the crash happens at most once, and "three crashes in a minute" could never be observed.

What is watched instead is the other side of it:

  1. A record is opened before the expression is evaluated and closed once the interface is still there afterwards. "Still there" is measured as at least one page target besides the renderer, discounted by target ID rather than by title.
  2. The check sits ten seconds after the injection — three times the one measured interval between the evaluate returning and the targets going. Waiting longer costs only a record staying open a few seconds more; waiting less would record a crash as a survival.
  3. An attempt nothing could be established about is not counted. If the debugger stopped answering, if the backend is shutting down, if there was no other page target when the panel was loaded, or if this backend itself took the interface away since the panel was loaded (a panel an earlier backend left behind), there was no collapse of the panel's making to observe — and an unobserved attempt recorded as a failure would stop the injection over a user closing Steam.
  4. Two consecutive failures stop it, not three. One can be anything; two is evidence; three dead Steam starts is too much to ask of someone who has no reason to suspect this program.
  5. It starts trying again by itself. The record carries a fingerprint of the three things that could have repaired the fault — Tender's version, a digest of the bundles' bytes, Steam's client build — and a change in any of them drops the count. The user updates something and it works again, with no file to find and nothing to delete.

The record lives at <state_dir>/injection-guard.json. A state directory it cannot be written to leaves the guard unable to count, which is deliberately the lenient direction: this file exists to stop a crash loop, and refusing to load the panel because a directory is read-only would be a fault of its own.

The way out cannot be inside Steam, because in this state Steam's interface is the thing that is gone. It is a log line naming the state and one environment variable:

TENDER_INJECT What it does
off load nothing into Steam at all, and say so once at start-up
force load the panel even where the watchdog has stopped
anything else nothing — a typo in a unit file may not stop the backend starting

Steam's build is read from Steam's own record of what it installed: <steam>/package/steam_client_<branch>_ubuntu12.manifest carries a "version" field, and <steam>/package/beta names the branch. Deliberately not the debugger's version string, which is CEF's (Chrome/126… on the reference machine) and moves only when Valve changes CEF — where Steam's interface is rebuilt far more often, and those rebuilds are exactly what this reading is for.

The load-failure card

For the other failure: the interface is alive and our bundle does not mount. The evaluated expression catches it and draws a small card into Steam's own document naming Tender's version, Steam's build, the log path, where releases are listed, and the reason the import gave.

It is not a React component and it fetches nothing. Its own subject is that Steam's React globals may be missing, and a second file fetched at the moment of failure could fail for the reason the first one did. It is plain nodes built by hand in the expression that is already running. frontend/src/boot/StartupFailurePanel.tsx is its near relative and answers a different question — see GLOSSARY.md → Load-failure card for why the two are not called the same thing.

It never takes the machine over. Whether Steam's controller focus can reach a node appended to its document from outside its own React tree is not established here, so the card is built so that the answer does not matter: it is drawn with pointer-events: none everywhere except its one button, and every control underneath stays reachable whatever happens to that button. A fixed overlay that swallowed input would be worse than the fault it reports.

One button, and an address in plain text

The card carries exactly one action — Stop trying until Tender restarts — and the releases address as text.

The button reaches this process through a debugger binding (Runtime.addBinding), which is the whole of the card's way back: the card holds no token, opens no socket of its own, and the backend grows no route for it. Pressed, it sends one word, the injector stops loading anything into Steam for the rest of this process, and the card is taken off the screen. "Restart" there means this backend's own process — not Steam, not the machine — and the card says so under the button, because that is the word a reader is most likely to get wrong.

Two properties of the binding decide where it is installed:

  • It goes on before the source that may draw the card is evaluated, so the button is wired from the moment it exists. Whether it is drawn at all is the injector's answer: Runtime.addBinding is asked first, and a refusal is carried into the card as "no button", because a button that cannot report a press is worse here than none.
  • It is re-installed on every injection rather than once per attachment. Whether a binding survives a JS-context rebuild is not established here; adding one that survived costs a round trip, and missing one costs the card its only button.

Runtime.bindingCalled is a Runtime-domain event, so it arrives only while that domain is enabled — and enabling it turns on every other Runtime event for that connection. So it is enabled only once a card is up: after a load that failed, where the card is the only thing left to act on, and never on the path where the panel came up.

Checking for an update is text rather than a button. Installing one is an action of the panel's, under Settings › Updates, and the panel is what did not load; and no way to open a web page out of Steam's UI has been measured here, so a button aimed at the releases page would be a guess on the one page whose whole job is to be true after everything else has failed.

The token

The panel reads its port and its token off the URL it was imported from (frontend/src/api/host.ts hands import.meta.url to hostSocket.ts), so the address the expression imports carries the token and there is nowhere else for it to be. The same facts object carries it once more, as a field, because the expression's redaction matches on the token itself rather than on a pattern — and both die with the expression that holds them.

What it is kept out of is everything that outlives that: the marker left on the window (which carries the process's instance instead — a value that authorises nothing), the card on screen, and what comes back to the backend — any error text has the token replaced with <token> before the injector ever logs it.

Running it

mise run dev builds the panel, restarts the running Steam, and runs the backend, which serves dist/ and loads it. The restart is the task's own — a rebuilt bundle reaches Steam only in a fresh JS context — so whatever is open in Steam when the task starts is closed. The marker above has to exist, and this backend creates it when it does not — so the task's own restart is what picks up a marker that has just been created. See the dev loop.

What the tests here can and cannot see

tests/host/inject/ drives the real client against a fake debugger on a real loopback port: real HTTP, real RFC 6455 frames through lib/websocket_frames.py in both directions. What is faked there is the page — that tier runs no JavaScript, so Runtime.evaluate is answered by a stand-in that recognises the expressions the injector sends.

So the suite holds the framing, the reconnection, the discovery rule, the watchdog's state machine, the bundle choice, what the evaluated source carries, and the branches of replacing a stranded panel — including what the fallback's kill would signal, which goes to a recorder and never to a process. It also RUNS that source: node parses it, and a harness evaluates it against a stub window with just enough of a document for the card, and with the bundle addresses as data: modules that record having been imported and that leave the installer on the window where the real bundle leaves one. So the order (import, install, import), the refusals that keep the panel out, and the sentence each refusal carries are exercised rather than read off the text.

What that harness cannot see is Steam. It stands in for the page, so nothing in it says whether @decky/ui's searches find anything, what the real installer answers against a real module registry, or whether the card is legible on a handheld. Those stay device tests: whether the panel appears, whether a forced context rebuild brings it back, whether the card draws where a broken bundle is served, and whether a backend restart with Steam open brings the panel back — in Game Mode above all, where none of it has been measured.

Neither can it see whether Steam still has what the injector's expressions name. Every name of Steam's client the backend spells — in the expressions it evaluates, the steam:// URL, the debugger target's title and the origin of Steam's interface — is listed in backend/domain/steam_internals.py, the backend's half of the list Every Steam internal Tender reaches describes, and tests/domain/test_steam_internals.py holds every string under backend/ but _vendor/ and native/ that names Steam to it.

  • Frontend bundles — what the three build outputs are, and why there are two panels.
  • ADR-0036 — the backend became its own process and serves these files.