cordial-run speak cordial.runtime/1 and it is the only channel between them. The plugin host move, the flag resolver move, assets.overlay, manifest discovery and the Flatpak extension are still proposed and nothing in them is built.
Date: 2026-10-08
Supersedes: ADR-052 decision 4 (“the built-in Android runtime is the first implementation, in-process”) and docs/runtime-spec.md section 8 as first written. The rest of ADR-052, including the listing policy, stands.
Amends: ADR-038 on where the reconciler runs (the launcher, not the client); its reasoning about polling and about never restarting a plugin for a re-grant stands. ADR-044: the live or next-launch classification becomes a per-runtime declaration, with today’s table as the built-in runtime’s.
Related: ADR-001, ADR-002, ADR-003, ADR-007, ADR-010, ADR-011, ADR-012, ADR-019, ADR-021, ADR-031, ADR-039, ADR-043, ADR-051, ADR-054
Spec: docs/runtime-spec.md. Review and plan: docs/analysis/runtime-protocol-review.md, which carries the code reading and the commit order.
Context
ADR-052 published a spec and kept the first runtime in-process: the Android runtime would speak it “over a channel, not a socket”, with the plugin host inside the client. The maintainer’s direction since is different and simpler to state: a port forks the runtime and never the launcher. For that to be true the launcher cannot be a library inside the runtime, and the plugin host, which is the largest piece of launcher behaviour, cannot live in the process a port replaces. The code is closer to this than the crate graph suggests, and further than the spec’s section 8 said. Read for this ADR:- The process boundary already exists.
cordial-shellspawnscordial-runas a sibling binary and never links it; the references tocordial_runtimein the shell are comments. What it passes is argv, about fifteenCORDIAL_*variables, an inheritedflockdescriptor and piped stdout and stderr, and afterwardslive/settings.sock(ADR-044). - The dependency runs the wrong way for a port.
cordial-runtimedepends oncordial-shell, and importshost_window,webview,nvidia,profile,stacking_gate,title_barandlive_wirefrom it. A port that forked the runtime would drag GTK, libadwaita and the launcher’s modules with it. - The plugin host is in the client.
plugin_host.rsis 3,031 lines and is started fromload.rs. It callscrate::flags,crate::profile,crate::game_logandcrate::android::asset, so the spec’s claim that the portable core “has almost no Android coupling” was wrong for the file that matters. - Two of the first draft’s events have no publisher.
client.readyandwindow.resizedare declared incore_events.rsand published by nothing.
Decision
- The launcher and the runtime are two programs speaking
cordial.runtime/1over a Unix socket. The built-in runtime,cordial-run, stays a separate process and implements the protocol in place of the in-process channel ADR-052 described. It is the conformance suite. The version-0 settings socket was removed when the transport landed (see “Update: the transport is built”). - The launcher owns the plugin host, with hot-swap. Grants, the broker, the Discord socket, the flag layer resolver and write grants, and the reconciler of ADR-038 move to the launcher. There is one host per runtime session because grants are per profile (ADR-013). The launcher holds itself alive while a client runs (ADR-031), so the host’s lifetime is the game’s in every case except a launcher crash, where the game now survives and the plugins do not. That is a change from today, where a launcher crash kills the client through the stdout pipe, and it is the better way round.
- The runtime owns the engine, its window, and the files the engine writes.
client_settings,flag_reapply, the log tail and the BloxstrapRPC parser stay with it, because they read the engine’s own output. It owns its window: the engine’s surface is awl_subsurfaceof the runtime’s own GTK toplevel (ADR-011), and a subsurface cannot cross a process boundary. The first draft’swindow.mode: embeddedis therefore dropped, and “the launcher owns the window” means the launcher owns its own window. - The protocol is events up, a closed set of requests down, and no verb that
runs code. ADR-001 and ADR-003 are untouched: there is no message for
engine memory, code, a command or a descriptor. Exactly one message carries a
path,
assets.overlay.set, and the launcher canonicalises and confines it. Plugins never speak to a runtime; the launcher maps events onto the plugin API. - The launcher synthesises exit and crash from the child’s wait status. A
crashed process cannot report its own crash, and a
lifecycle.exitwould be the stub-that-lies pattern in a message.lifecycle.readyis optional until something publishes it. The runtime survives EOF on the socket and a closed stdout, so closing the launcher mid-game stays the ordinary case. - Transport: the runtime listens on a path, the launcher connects. A handed-over descriptor was considered and rejected: the launcher is restarted while games run, and a descriptor cannot be found again. Per message limits, a bounded drop-and-count event queue on engine threads, reply timeouts that count as failure, and manifest-derived identity for the keyring are in the spec.
- Launch configuration stays env and argv for the built-in runtime in
version 1. A third-party runtime gets placeholders (
{socket},{session_dir},{profile_dir},{build_dir},{join_url}) and receives settings over the socket. The profile lock is inherited at spawn, not sent. - Cordial’s pseudo-flags become typed settings.
CordialGraphicsBackend,CordialFrameRateLimitandCordialDeviceProfileride inflags.jsontoday and would reach a foreign engine as unknown flag names. The launcher strips them from the resolved document and sends them assettings.flags.livemeans “apply and hold in force”, which is what ADR-051’s re-apply already does. - The development control surface is not in the protocol. devctl
(ADR-019) stays a runtime-private
socket behind its own variable, and
cordial-mcp.pykeeps talking to it. - A manifest says who manages builds.
builds: "cordial"means the store of ADR-054 supplies{build_dir};builds: "runtime"means the launcher shows no Version row. - Left out of version 1:
updates,doctor,session.vault,window,launch.join, a runtime-declared settings form. Each is added by a minor bump and an ADR when a second runtime needs it.
Packaging
A runtime that is packaged separately is a Flatpak extension of the launcher, and runs inside the launcher’s sandbox. The manifest gains one block andfinish-args gains nothing:
io.github.luohoa97.Cordial.Runtime.<Name> on branch 1 (the
protocol major) mounts at /app/runtimes/<Name>/, where the launcher already
looks for a manifest. The socket is an ordinary path in the profile directory,
which both sides see because they are one sandbox, so nothing new is shared and
child_watch and the inherited lock work unchanged.
The alternatives need a grant this project has refused. Two separate apps
sharing a directory would need --filesystem=xdg-run/cordial-runtime:create in
both, and still could not start each other without
--talk-name=org.freedesktop.Flatpak, which is arbitrary host command
execution (ADR-002 section 2,
ADR-007). Passing a descriptor over a
portal does not apply: the Flatpak portal’s Spawn starts a sandbox of the
caller’s own application, and no portal brokers a connection between two apps.
All of this is INFERRED from the Flatpak documentation; no extension was
built.
The cost is stated plainly: an extension runs with Cordial’s permissions and
no others, so a runtime that needs more (--device=all, a FUSE device) cannot be
one. That is the property ADR-007 asks for, the set of host resources fixed when
the launcher’s manifest is written, and a runtime that cannot live with it is
native-only in version 1. A runtime built against another base than
org.gnome.Platform 50 must bundle what it needs under its own mount.
The protocol crate and its licence
A new crate,cordial-protocol, depends on serde and serde_json and
nothing else, links no native code, and holds the message types, the framing
codec, the manifest type, the version negotiation and a conformance harness that
both sides run against shared line vectors. live_wire.rs moves into it with its
tests. Both the launcher and cordial-run depend on it.
It is licensed MIT OR Apache-2.0, and it is the only part of Cordial that
is. The maintainer decided this on 2026-10-08, after this ADR was first written
with the crate under the workspace’s GPL-3.0-or-later. The reasoning that
decided it is the one the first draft left as a cost: ADR-052 rejected “a trait
with no wire form” partly because it forces a runtime into Cordial’s licence,
and a GPL crate does the same to a Rust runtime or launcher that links it. The
crate is the interface other programs implement, so it has to be something they
can depend on whatever their own licence is. What it contains is the message
types, the codec, the manifest type, the version negotiation and the conformance
cases, which are an interface and its tests and not the client. The wire itself
stays plain JSON lines specified in docs/runtime-spec.md, so a runtime in any
language implements it without the crate.
Why this is not the relicensing CONTRIBUTING.md declines. The rule in “The
licence is settled” refuses requests to relicense Cordial, and it still does. It
now names this crate as the single exception, with the reason, and says
contributions to crates/cordial-protocol are under the crate’s licence. The
sole author of the code that moved in (live_wire.rs, which the crate was built
from) is the maintainer, who made the decision, so nobody else’s grant is
overridden. A pull request to that directory is accepted under MIT OR Apache-2.0 and a contributor is told so before they write it.
What the permissive crate may not become. It stays small and pure:
serde and serde_json only, no native code, nothing that links the engine,
the AOSP linker or GTK. Anything that needs those belongs in a GPL crate that
depends on it, never the other way round; a permissive crate that depended on a
GPL one could not be published. The two settings types it took from the
launcher (TitleBar, FrameRateLimit) came with the methods the Settings rows
call, which are pure data.
Publishing. crates.io publication is the maintainer’s call and is not done
here; the crate is publish = true at version 0.1.0, independent of Cordial’s
version, and cargo publish --dry-run passes. It is meant to be published once
the split has landed, so the first public version is not immediately wrong.
Every other crate stays publish = false, because each links the AOSP linker or
GTK. NOTICE and THIRD-PARTY-NOTICES.md say the exception exists and where
its licence texts are (crates/cordial-protocol/LICENSE-MIT and
LICENSE-APACHE). (NOTICE said “See COPYING”, a file that does not exist; the
repository’s text is LICENSE, corrected in the same change as this ADR.)
Order of work
Docs, then a pure refactor, then additive wiring, then the move. Each commit keeps the single-binary path working; the numbered plan is in the review note. This section originally held the transport back until after the 1.0 gates (the signed-in startup freeze and the text boxes): nothing before them was to touchwindow.rs, the start or publish sites in load.rs, the native shims or
the startup path. The maintainer decided on 2026-10-08 that the shell and the
runtime speak the protocol fully and that this is how they talk, which
supersedes that ordering for the transport and for the transport only. What
survives of the restriction is the part the startup-freeze work depends on: the
startup sequence in load.rs is unchanged, and the only edit there is one call
beside the one it replaces. The plugin host move, the flag resolver move and
everything after them still wait, and the plugin host moving into the launcher is
the next step.
Update: the transport is built
Written the same day, from the code that landed. Everything here was read from the tree or measured in the runs listed in the commit messages; where it was not, it says so. The runtime serves.cordial_runtime::control listens on
<profile>/runtime/<session>/ctl.sock, a 0700 directory the launcher makes
before the spawn and names with eight hex characters, and tells the runtime in
CORDIAL_SESSION_DIR. A runtime started by hand makes its own. A path that does
not fit sun_path’s 108 bytes is bound and reached through
/proc/self/fd/<dirfd>/ctl.sock on both sides (cordial_protocol::socket). It
offers lifecycle, events.core, events.presence, state and settings, and
nothing else: flags, assets.overlay and diagnostics have no code behind them
and a request for one is unsupported. The server is started where
live_settings::start was, and lifecycle.stop goes to looper::request_quit,
the same door the window’s close button uses.
The launcher connects and stays connected. One Link per running client,
opened with retry after the spawn, held until the client exits, reconnected with
reattach:true if it drops, and not reconnected if the runtime says it was
superseded. Settings go as settings.set; what the runtime reports in
settings.get replaces the launcher’s assumption about what the launch
environment gave it. A key the runtime declares next-launch is reported as
applying at the next launch and not sent. Exit and crash are still synthesised
from child_watch; freeze recovery still reads the engine’s log; stdout is still
the log and the crash page’s source.
The launcher adopts a runtime it finds. On startup it looks for
runtime/*/ctl.sock under every profile and takes control of any that answers.
That is the only case in which “reopening the launcher reattaches” does work:
closing the launcher’s window leaves the same process holding the same
connection (ADR-031), so nothing needs finding. A launcher process that dies
still takes a client it started with it, through the piped stdout, exactly as
ADR-031 recorded; the runtime survives the socket closing and not its stdout
closing. A client whose output goes elsewhere, which is every client started by
hand, survives it. Fixing the pipe means the log-file shape ADR-031 sketched, and
is not done.
Removed. live/settings.sock, the version-0 codec (cordial_protocol::v0)
and cordial_shell::live_wire. Nothing in tools/, the MCP, the docs or any test
outside those used them; tools/cordial-mcp.py talks to devctl, which is
untouched.
Events. game.joined, game.left and session.state come from the
log watcher’s updates; engine.version and game.presence are mirrored from
plugin_host::publish_core, so the launcher is told the same thing a plugin is.
They go through the bounded drop-and-count queue and a pump thread of their
own; none blocks an engine thread. session.state is {signed_in:true} on a
join that names a user, and nothing ever says signed out, because nothing in the
runtime knows. lifecycle.ready and health are not sent. The launcher records
the events, folds them into a snapshot and logs them; nothing in the launcher
uses them yet, because the consumer is the plugin host, which has not moved.
What was measured
Signed out, in a nested headless sway, with the shell’s ownXDG_* directories
redirected to scratch, release build 1e774ac89-dirty plus the commit after it:
- Conformance, against a real
cordial-runwith no launcher attached: 16 passed, 0 failed, 1 skipped (diagnostics.get, not offered). With a launcher attached and--stop: 17 passed, 0 failed, 1 skipped, then the client exited 0 and removed its session directory. The first run of the second kind failed 5 cases, because the launcher reconnected to a runtime that had just superseded it and took control back mid-run; the launcher no longer does. - Every live key, changed by editing
shell.jsonthe way Settings saves it, reached the client in the same second, once each, and the client’s own line (live: throttle -> off) followed the shell’s (live settings -> pid N: throttle). Apresent_modechange producedapplies at next launch (... declares it)and nosettings.set; a shell-only key and an unchanged re-save produced nothing. - Closing the launcher window left the shell process and the client running
and
cordial_infopresents rising (1322, then 1326). A secondcordial-shellhanded over to the running one and a later setting change went down the same connection: onecontroller attachedline in the client’s log, no reattach. - A launcher started after a client it did not spawn adopted it (
found ... running as pid N ... reattached), applied a settings change, was killed, and the client carried on (presents 562, then 1138); a second launcher reattached and applied another. - Killing a launcher that had spawned its client killed the client, through the piped stdout, as ADR-031 said it would. That is the control for the paragraph above about what is not fixed.
- Not measured: the session path over
sun_path’s limit with a real client (the runtime’s was 101 bytes; the fallback is covered bycordial_protocol::socket’s tests only), and any signed-in run, sogame.joined,session.stateandgame.presencewere exercised by unit tests and not by a real join.
What is not decided
- Where the session cookie lives for a third-party runtime. The built-in
runtime reads the secret store itself through
cordial_shell::secrets(re-exported incordial-runtime/src/secrets.rs);session.vaultis deferred until a runtime needs it. - Whether
mangohudandvkbasalt, which are Vulkan loader environment variables, are settings or a manifest-declared environment allowlist. - The sandbox variants of discovery for a runtime that is not an extension.
Consequences
cordial-runtimeis eventually renamed (cordial-android) and loses its dependency oncordial-shell’s non-window modules; the window code it imports moves with it or into a small crate of its own. That is the last step, not the first.- The launcher gains a session object: a child, a socket, a plugin host, a
state snapshot. It already has most of it in
live.rsandlaunch.rs. - A plugin’s behaviour on a launcher crash changes (above), and a release note for the step that moves the host should say so.
- Hot-swap latency is unchanged: the launcher polls the same files once a second, from a process that wrote them.
Alternatives considered
- Keep the plugin host in the runtime and forward grants over the socket. Rejected: every port would then carry the plugin host, which is the thing the split exists to avoid, and ADR-003’s isolation would depend on each port getting the broker right.
- The launcher binds, the runtime connects. Removes the connect retry and stale sockets, but a restarted launcher cannot find a running game.
- A descriptor handed at spawn. Same objection, and ADR-031 already makes closing the launcher’s window ordinary.
- Embedding the runtime’s surface in the launcher’s window. Not possible
across processes with a
wl_subsurface; a cross-process embed would need a different presentation path, which is a rewrite of the engine’s windowing and is not proposed. - Per-line
vfields and a minor in the manifest. Rejected: three places for one number let them disagree.