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.
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 on every retry (refused GET /ws: wrong token in the log), and the new
injector finds the marker and loads nothing over it. The game page's Tender section stays at "Loading...", 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.
What it does about it (backend/host/inject/recovery.py):
- 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 empty lists in a row let it act: a freshly rebuilt context can list none for a few seconds while a game is still up. 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. - Asks Steam to rebuild its JS context with
SteamClient.Browser.RestartJSContext(), evaluated inSharedJSContext. Evaluated directly, the call answers "Cannot find default execution context" — the answer that led ADR-0024 to rule the call out. Scheduled withsetTimeout, 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 firesPage.domContentEventFired, so the panel is loaded again through the ordinary sequence — nothing here loads it. - 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. - 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
steamwebhelperprocess 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. - 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.
- 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.
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:
- 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.
- 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.
- 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.
- 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.
- 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 CONTEXT.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.addBindingis 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, because nothing in this program updates itself yet and no way to open a web page out of Steam's UI has been measured here. It becomes a button in the cut that gives it something to do (#1903).
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.
Related¶
- 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.