Skip to content

Architecture 13 Daemon

Alex edited this page Oct 2, 2026 · 2 revisions

13. The Background Session 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)

1. Purpose & Role

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 by shared_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-global active_installs() registry. Because installs run in the daemon, that registry lives in the daemon and is visible to every forwarded install list/install stop call.

AURELIA_NO_DAEMON (env var) opts an invocation out of all of this and forces local execution (src/main.rs:812).


2. Architecture

2.1 Client / server model

 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).

2.2 Transport (transport.rs)

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 --socket flag, see src/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 on ERROR_PIPE_BUSY (231) (src/daemon/transport.rs:98-107).

2.3 Wire protocol (proto.rs)

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 it

2.4 Auto-forwarding: which commands forward vs. run locally

async_main decides per-invocation (src/main.rs:770-826):

  1. Bare aurelia daemon (no sub-action) becomes the server, never forwarding (src/main.rs:794-805).
  2. must_run_locally(&cli) (src/main.rs:752-768) returns true for commands that must run in this process:
    • Interactive login (default credential prompt, --qr, --code/--pin, not --json): it masks the password via rpassword, which needs the real tty. Forwarding it would run rpassword inside the daemon (no tty), echoing the password in clear text. The daemon picks up the new token from session.json's mtime on the next command.
    • kill and daemon 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 --json are not interactive and still forward (they are daemon-oriented).
  3. Otherwise (and unless AURELIA_NO_DAEMON is 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).

2.5 Lifecycle: start / stop / list / upgrade

  • 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 via proc_admin::find_aurelia_processes() filtered by is_daemon.
  • Stop: aurelia daemon stop [pid] → cmd_daemon_stop (src/main.rs:2894) kills the matching daemon PID(s). aurelia kill stops all aurelia processes including the daemon.
  • Self-restart on upgrade: watch_for_upgrade polls the daemon's own binary (mtime, len) every 5 s; when it changes (an aurelia upgrade) 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).

2.6 Daemonizing / detaching stdio

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 flags DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP. Crucially, Windows spawns children with bInheritHandles=TRUE, so a detached child would inherit every inheritable handle the parent holds, including the stdout/stderr pipes Heroic handed aurelia. 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) calls windows-sys GetStdHandle + SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) on stdin, stdout, stderr before spawning, clearing the inherit flag so the daemon can't capture them.

3. server.rs: Request Handling & Shared Session

3.1 Accept loop

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.

3.2 Handling one forwarded command (handle, src/daemon/server.rs:182-218)

  1. An InflightGuard increments INFLIGHT for the request's lifetime (so the upgrade watcher never restarts mid-request) (src/daemon/server.rs:17-34, :187).
  2. read_header reads the opening C_HEADER frame and parses the argv (src/daemon/server.rs:120-130).
  3. Two pumps wire stdio over the socket:
    • spawn_output_pump: drains the command's captured stdout/stderr (OutChunk from an mpsc channel) into C_STDOUT/C_STDERR frames (src/daemon/server.rs:134-153).
    • spawn_stdin_pump: reads C_STDIN/C_STDIN_EOF frames and feeds them to the command's stdin (src/daemon/server.rs:157-178).
  4. 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, and OutputCtx routes its stdio into the socket.
  5. super::maybe_refresh_after(&argv) (src/daemon/mod.rs:364): if the command was login/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.
  6. Drain output, send the C_EXIT frame with the exit code, shut down the write half (src/daemon/server.rs:210-217).

3.3 The shared SteamClient session

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: the session.json mtime the current client/failure reflects, so a restore is re-attempted exactly when the token file changes (e.g. after login) rather than per request.
  • Slot.last_failure: gates retries to one per RESTORE_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 classified RestoreFailure (ErrorKind, retry_after, reason) of the last failed restore. last_restore_error() exposes it, and authed_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 no session.json at all, the slot is cleared and nothing is attempted.
  • Since v0.1.38, ensure_session re-stats session.json after a restore, because restore_session rewrites 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.

3.4 Liveness & roster

  • 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 then invalidate()s + ensure_session()s. steam-vent leaves a Connection API-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.

3.5 Background install tracking

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.


4. client.rs: How the CLI Talks to the Daemon

try_forward() (src/daemon/client.rs:21-27) is the whole client entry point:

  1. connect_or_spawn() (src/daemon/client.rs:30-42): try transport::connect(). If no daemon is listening, spawn_daemon() a detached aurelia daemon and poll transport::connect() up to SPAWN_ATTEMPTS (50) × SPAWN_WAIT (100 ms) ≈ 5 s. If still nothing, return None → caller runs locally.
  2. forward(stream, argv) (src/daemon/client.rs:108-169):
    • Send the Header { argv } as a C_HEADER frame.
    • Spawn a task pumping local stdin → C_STDIN/C_STDIN_EOF frames. 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_STDERR frames to local stdout/stderr (flushing each chunk for correct interleaving), until the C_EXIT frame yields the exit code (src/daemon/client.rs:151-168).

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.


5. Key Message Types & Functions (cross-module map)

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_locally at src/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 on in_daemon() to return the shared client.
  • src/main.rs:1468 / src/main.rs:1483: login --health / --reconnect call daemon::session_status() / daemon::force_reconnect().
  • src/daemon/server.rs:13 uses crate::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.

6. Launch-Relevant Notes (umu integration)

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.

Clone this wiki locally