-
-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture 13 Daemon
The daemon subsystem (src/daemon/) is a shared-session server: one long-lived
aurelia daemon process holds a single authenticated Steam connection, and every other
aurelia <cmd> invocation transparently forwards itself to that daemon and runs against
the shared session. This document covers why it exists, the client/server architecture,
the wire protocol, the lifecycle, and how it interacts with launching games.
| File | Role |
|---|---|
src/daemon/mod.rs |
Process-global DaemonState, shared-session restore/liveness, roster cache, status/reconnect helpers |
src/daemon/server.rs |
The server: accepts forwarded commands, routes stdio, runs them against the shared session |
src/daemon/client.rs |
The thin client: forwards this invocation to a running daemon (auto-spawning one), relays stdio + exit code |
src/daemon/proto.rs |
The framed wire protocol multiplexing stdin/stdout/stderr/exit over one socket |
src/daemon/transport.rs |
Cross-platform local IPC endpoint (Unix domain socket / Windows named pipe) |
The per-invocation CLI re-authenticates with Steam on every command. Steam throttles
repeated logons (the invalid credentials / RateLimitExceeded churn), so a naive
"log on per command" model quickly gets the whole machine rate-limited
(src/daemon/mod.rs:3-7).
The daemon solves this by holding one authenticated SteamClient for its whole
lifetime. Every other aurelia command forwards itself to the daemon and executes on its
behalf, so the machine logs on once per daemon lifetime rather than once per command.
Concretely, the daemon provides:
-
Session reuse: a single live Steam connection shared by all commands
(
shared_restored_client,src/daemon/mod.rs:277). -
Background friends roster: one watcher task fills a shared roster cache from Steam
broadcasts (
spawn_watcher,src/daemon/mod.rs:196, served byshared_roster,src/daemon/mod.rs:295). -
Long-running work that survives the client: installs and game launches run inside
the daemon process, so a quick CLI invocation can kick them off and a later, separate
invocation can list/stop them.
install list/install stop(src/main.rs:2350,src/main.rs:2388) read a process-globalactive_installs()registry. Because installs run in the daemon, that registry lives in the daemon and is visible to every forwardedinstall list/install stopcall.
AURELIA_NO_DAEMON (env var) opts an invocation out of all of this and forces local
execution (src/main.rs:812).
aurelia play 730 aurelia daemon (long-lived server)
┌───────────────────────┐ ┌──────────────────────────────────┐
│ async_main │ socket/pipe │ run_server (accept loop) │
│ must_run_locally? no │ ────────────► │ handle(stream): │
│ client::try_forward │ framed proto│ read_header → run_argv(argv) │
│ send HEADER(argv) │ ◄──────────── │ stdout/stderr → C_STDOUT/STDERR │
│ relay STDOUT/STDERR │ │ shared SteamClient (1 logon) │
│ read C_EXIT → exit() │ │ send C_EXIT(code) │
└───────────────────────┘ └──────────────────────────────────┘
A normal invocation is a thin client. It connects to (or spawns) the daemon, sends its
own argv, streams stdin in, relays the daemon's stdout/stderr back to the user's
terminal, and exits with the code the daemon reports. The daemon parses that argv with
Cli::try_parse_from and runs the same command code path it would run locally. The only
difference is that restored_client() hands it the shared connection instead of a fresh
logon.
The split between "client" and "server" is just whether DAEMON (a process-global
OnceLock<DaemonState>) is initialized: in_daemon() returns true only in the daemon
process (src/daemon/mod.rs:43, src/daemon/mod.rs:117).
The transport is a local IPC endpoint, platform-specific but presented through a uniform
Listener / connect() API. Both stream types implement AsyncRead + AsyncWrite, so
server and client code stay generic over the concrete type.
| Platform | Endpoint type | Default path | Code |
|---|---|---|---|
| Unix | Unix domain socket |
$XDG_RUNTIME_DIR/aurelia-<uid>.sock (falls back to temp dir) |
src/daemon/transport.rs:13-55 |
| Windows | Named pipe | \\.\pipe\aurelia-<USERNAME> |
src/daemon/transport.rs:57-109 |
- The endpoint can be overridden with
AURELIA_DAEMON_SOCKET(set by the--socketflag, seesrc/main.rs:795-799), letting a driver isolate its own daemon (src/daemon/transport.rs:18-21,src/daemon/transport.rs:65-70). - On Unix,
bind()removes a stale socket file from a crashed previous run before binding (src/daemon/transport.rs:38-43). - On Windows, the listener uses
first_pipe_instance(true)and, on each accept, swaps in a fresh pipe instance for the next client (src/daemon/transport.rs:79-93).connect()retries onERROR_PIPE_BUSY(231) (src/daemon/transport.rs:98-107).
A tiny framed protocol multiplexes stdin/stdout/stderr/exit over the single socket. Each
frame is [u8 channel][u32 BE length][payload] (src/daemon/proto.rs:1-6). read_frame
returns Ok(None) on clean EOF and rejects any length above MAX_FRAME_LEN (64 MiB) to
stop a malformed prefix from triggering a huge allocation
(src/daemon/proto.rs:13, src/daemon/proto.rs:42-63).
| Constant | Value | Direction | Meaning |
|---|---|---|---|
C_HEADER |
0x01 |
client → daemon | First frame: JSON { argv } (Header) |
C_STDIN |
0x02 |
client → daemon | A chunk of the client's stdin |
C_STDIN_EOF |
0x03 |
client → daemon | Client's stdin reached EOF |
C_STDOUT |
0x11 |
daemon → client | A chunk of the command's stdout |
C_STDERR |
0x12 |
daemon → client | A chunk of the command's stderr |
C_EXIT |
0x13 |
daemon → client | Final frame: 4-byte BE process exit code |
The protocol carries no request/response message types beyond stdio multiplexing:
the "request" is the command's argv, and the daemon runs the real CLI command behind it.
Defined in proto.rs, where write_frame/read_frame are the only two functions
(src/daemon/proto.rs:25, src/daemon/proto.rs:43).
// src/daemon/mod.rs:35
struct Header { argv: Vec<String> } // argv[0] is the program name, so the daemon can Cli::try_parse_from itasync_main decides per-invocation (src/main.rs:770-826):
- Bare
aurelia daemon(no sub-action) becomes the server, never forwarding (src/main.rs:794-805). -
must_run_locally(&cli)(src/main.rs:752-768) returnstruefor commands that must run in this process:-
Interactive
login(default credential prompt,--qr,--code/--pin, not--json): it masks the password viarpassword, which needs the real tty. Forwarding it would runrpasswordinside the daemon (no tty), echoing the password in clear text. The daemon picks up the new token fromsession.json's mtime on the next command. -
killanddaemon stop/daemon list: these manage local OS processes and must not forward to (or auto-spawn) the very daemon they may be about to terminate. -
login --health/login --reconnect/login --jsonare not interactive and still forward (they are daemon-oriented).
-
Interactive
- Otherwise (and unless
AURELIA_NO_DAEMONis set),daemon::client::try_forward()runs.Ok(Some(code))→ forwarded,std::process::exit(code).Ok(None)→ no daemon could be reached or spawned, fall through to local execution (src/main.rs:812-822).
-
Start (explicit):
aurelia daemon→run_server()(src/daemon/server.rs:68). It first checks whether a daemon is already listening and exits cleanly if so (so the auto-spawn race is safe). It binds before the initial session restore, so it is reachable immediately. The (possibly slow/failing) logon runs in the background and only blocks the first command that needs auth (src/daemon/server.rs:62-98). - Start (implicit/auto-spawn): the client spawns one on demand (see §4).
-
List:
aurelia daemon list→cmd_daemon_list(src/main.rs:2922) enumerates processes viaproc_admin::find_aurelia_processes()filtered byis_daemon. -
Stop:
aurelia daemon stop [pid]→cmd_daemon_stop(src/main.rs:2894) kills the matching daemon PID(s).aurelia killstops all aurelia processes including the daemon. -
Self-restart on upgrade:
watch_for_upgradepolls the daemon's own binary(mtime, len)every 5 s; when it changes (anaureliaupgrade) the daemon exits once idle (INFLIGHT == 0) so the next forwarded command auto-spawns a daemon running the new code, rather than serving stale code that might reject a newly added subcommand (src/daemon/server.rs:36-60,src/daemon/server.rs:17-34).
The client spawns the daemon detached so it outlives the spawning shell (or the Heroic
process that invoked aurelia), with all three std streams sent to null
(spawn_daemon, src/daemon/client.rs:44-55):
-
Unix (
detach,src/daemon/client.rs:57-62):cmd.process_group(0)puts the daemon in a new process group so it survives the parent's exit. -
Windows (
detach,src/daemon/client.rs:64-76): creation flagsDETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP. Crucially, Windows spawns children withbInheritHandles=TRUE, so a detached child would inherit every inheritable handle the parent holds, including the stdout/stderr pipes Heroic handedaurelia. The long-lived daemon would then keep those pipes open forever and Heroic would never see EOF. To prevent this,clear_std_handle_inheritance(src/daemon/client.rs:78-97) callswindows-sysGetStdHandle+SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0)on stdin, stdout, stderr before spawning, clearing the inherit flag so the daemon can't capture them.
run_server() (src/daemon/server.rs:68-112) binds the endpoint, spawns three background
tasks, then loops accepting connections and spawning a handle(stream) task per connection:
-
tokio::spawn(state.ensure_session()): establish the shared session in the background. -
tokio::spawn(state.liveness_loop()): keep it alive (probe + reconnect, see §3.4). -
tokio::spawn(watch_for_upgrade(...)): self-restart on binary change.
- An
InflightGuardincrementsINFLIGHTfor the request's lifetime (so the upgrade watcher never restarts mid-request) (src/daemon/server.rs:17-34,:187). -
read_headerreads the openingC_HEADERframe and parses the argv (src/daemon/server.rs:120-130). - Two pumps wire stdio over the socket:
-
spawn_output_pump: drains the command's captured stdout/stderr (OutChunkfrom anmpscchannel) intoC_STDOUT/C_STDERRframes (src/daemon/server.rs:134-153). -
spawn_stdin_pump: readsC_STDIN/C_STDIN_EOFframes and feeds them to the command's stdin (src/daemon/server.rs:157-178).
-
- The command runs via
crate::output::scoped(ctx, crate::run_argv(argv))(src/daemon/server.rs:204-205).run_argv(src/main.rs:831) is the same entry point local execution uses, andOutputCtxroutes its stdio into the socket. -
super::maybe_refresh_after(&argv)(src/daemon/mod.rs:364): if the command waslogin/logout(and not a read-only--health/--reconnect), invalidate and re-establish the shared session immediately so the next request sees the new/cleared token without waiting on a file-mtime tick. - Drain output, send the
C_EXITframe with the exit code, shut down the write half (src/daemon/server.rs:210-217).
The canonical session lives in DaemonState/Slot (src/daemon/mod.rs:62-90):
-
Slot.client: Option<SteamClient>: the one authenticated client holding the live connection. -
Slot.session_mtime: thesession.jsonmtime the current client/failure reflects, so a restore is re-attempted exactly when the token file changes (e.g. afterlogin) rather than per request. -
Slot.last_failure: gates retries to one perRESTORE_RETRY_BACKOFF(30 s). A transient failure self-heals on a later command, but a persistently bad/throttled token is not retried fast enough to re-create the logon storm (src/daemon/mod.rs:96-114). -
Slot.last_error(v0.1.37-2): the classifiedRestoreFailure(ErrorKind,retry_after, reason) of the last failed restore.last_restore_error()exposes it, andauthed_client()turns it into a typed error, so a forwarded command reports why the session is unavailable (e.g.rate_limited) instead of "not logged in". With nosession.jsonat all, the slot is cleared and nothing is attempted. - Since v0.1.38,
ensure_sessionre-statssession.jsonafter a restore, becauserestore_sessionrewrites the file. Recording the pre-restore mtime made every following command look like a token change and log on again.
ensure_session (src/daemon/mod.rs:144-186) restores the session from the stored token
only when needed (Slot::is_current), double-checked under the write lock. On success it
also (idempotently) starts the friends watcher. shared_restored_client
(src/daemon/mod.rs:277) is the daemon-side replacement for the CLI's restored_client():
it returns a SteamClient::from_shared(connection) (a cheap Arc-backed clone over the one
live connection, no logon), or an unauthenticated client if no session exists.
-
liveness_loop(src/daemon/mod.rs:240-259) probes the shared connection every 60 s (probe_alive), re-probes after 2 s on failure (to avoid burning a logon on a transient hiccup), and only theninvalidate()s +ensure_session()s. steam-vent leaves aConnectionAPI-usable after the socket dies, so this is the only way a dropped session heals without a command failing first. - The friends roster is an
Arc<RwLock<Roster>>populated asynchronously by the single watcher task (run_friends_watcher).shared_roster(src/daemon/mod.rs:295) reads and sorts it.invalidate(src/daemon/mod.rs:219-233) tears down the session, aborts the watcher, and clears the roster so a reconnect starts fresh.
Installs run inside the daemon as ordinary forwarded commands (aurelia install <id> is
not in must_run_locally). The in-flight state is held in a process-global
active_installs() registry in the daemon. install list/install stop
(src/main.rs:2350-2419), themselves forwarded into the same daemon process, read that
registry, so they observe (and can abort, via an Arc<AtomicBool> abort signal) installs
started by other CLI invocations.
try_forward() (src/daemon/client.rs:21-27) is the whole client entry point:
-
connect_or_spawn()(src/daemon/client.rs:30-42): trytransport::connect(). If no daemon is listening,spawn_daemon()a detachedaurelia daemonand polltransport::connect()up toSPAWN_ATTEMPTS(50) ×SPAWN_WAIT(100 ms) ≈ 5 s. If still nothing, returnNone→ caller runs locally. -
forward(stream, argv)(src/daemon/client.rs:108-169):- Send the
Header { argv }as aC_HEADERframe. - Spawn a task pumping local stdin →
C_STDIN/C_STDIN_EOFframes. It is aborted once the command exits, so a command that never reads stdin doesn't block on a no-EOF interactive stdin (src/daemon/client.rs:126-149,:167). - Relay incoming
C_STDOUT/C_STDERRframes to local stdout/stderr (flushing each chunk for correct interleaving), until theC_EXITframe yields the exit code (src/daemon/client.rs:151-168).
- Send the
The connection is split into read/write halves with the writer behind an
Arc<Mutex<_>> so the header write, the stdin pump, and the relay loop can share it.
| Item | Location | Notes |
|---|---|---|
Header { argv } |
src/daemon/mod.rs:35 |
The request: the command's full argv |
DAEMON: OnceLock<DaemonState> |
src/daemon/mod.rs:43 |
Some only in the daemon process |
in_daemon() |
src/daemon/mod.rs:117 |
Distinguishes daemon vs. thin client |
shared_restored_client() |
src/daemon/mod.rs:277 |
Shared-connection client (no logon) |
shared_roster() / session_status() / force_reconnect()
|
src/daemon/mod.rs:295,343,352 |
Used by friends, login --health, login --reconnect
|
run_server() |
src/daemon/server.rs:68 (re-exported src/daemon/mod.rs:31) |
Server entry |
handle() |
src/daemon/server.rs:182 |
One forwarded command |
client::try_forward() |
src/daemon/client.rs:21 |
Thin-client entry |
write_frame / read_frame
|
src/daemon/proto.rs:25,43 |
Protocol I/O |
transport::{connect, Listener, endpoint} |
src/daemon/transport.rs:111 |
IPC endpoint |
C_HEADER…C_EXIT channel constants |
src/daemon/proto.rs:16-22 |
Frame channels |
Cross-module interactions:
-
src/main.rs:794-822(async_main): the dispatch gate that decides whether to become server, forward, or run local.must_run_locallyatsrc/main.rs:752. -
src/main.rs:831(run_argv): the daemon's entry into the normal command code path. -
src/main.rs:1113(restored_client): branches onin_daemon()to return the shared client. -
src/main.rs:1468/src/main.rs:1483:login --health/--reconnectcalldaemon::session_status()/daemon::force_reconnect(). -
src/daemon/server.rs:13usescrate::output::{OutChunk, OutputCtx, Stream}to capture and route the command's stdio over the socket. -
proc_admin::find_aurelia_processes()(src/main.rs:2895,:2923):daemon list/stop.
Does play go through the daemon? Yes. aurelia play <app_id> is an ordinary command
and is not in must_run_locally, so under normal operation it is forwarded to (and runs
inside) the daemon process (src/main.rs:752-768, src/main.rs:812-822). Only
AURELIA_NO_DAEMON, an interactive login, or kill/daemon stop|list run locally.
Where does the umu/game process get spawned? cmd_play (src/main.rs:2725) calls
client.play_game(...), which spawns the runner child (umu / Proton / native) via the
launch pipeline (SpawnProcessStage, src/launch/stages/spawn_process.rs) and then
blocks on child.wait() until the game exits (src/steam_client/launch.rs:133-162).
Because play runs inside the daemon, the umu/game process is a child of the daemon
process, and the daemon task blocks on wait() for the duration of the play session. The
thin client just relays "Launching … / Finished playing …" output and the exit code. The
actual game process is parented to the long-lived daemon, not the short-lived CLI process.
Interaction with the shared session: the pre-launch update check/download
(check_for_updates / update_game, src/main.rs:2759-2790) and any Steam Cloud
sync-down/up around the session (src/steam_client/launch.rs:164-181) run on the shared
authenticated SteamClient (restored_client() → shared_restored_client()), so a launch
reuses the daemon's single logon rather than authenticating again. The launch is also
recorded in the running registry (record_launch, src/steam_client/launch.rs:150-158)
so a separate aurelia stop <app_id> invocation, itself forwarded into the same daemon,
can find and terminate the game while the daemon's play task is blocked on wait().
Implication: a play session occupies one daemon request task (and one detached child process) for its full duration. This is the same mechanism background installs use: the work lives in the daemon, while quick client invocations start, observe, and stop it.
Users
-
Usage
- Global behavior
- Authentication
- Library
- Store & discovery
- Collections
- Install & maintenance
- Launching
- Depots & branches
- Downgrade & pinning
- Steam Cloud
- Steam Workshop
- Friends & chat
- Inventory & market
- Configuration
- Proton & Wine
- Windows Steam runtime
- Luxtorpeda plugin
- umu-launcher plugin
- Launch scripts
- Session daemon
- Files & locations
- Exit codes & logging
- Windows Steam Runtime
Maintainers
Architecture