apex · updated October 11, 2026 · 159904621b04

The Daemon (apexd)

Relevant source files

The daemon is the long-lived process on a host that owns apex sessions. It keeps each session's authoritative Log, the Server that runs commands, terminals and file I/O for it, and a follower replica of the whole session. It speaks the attach protocol to every client over a Unix socket. UIs, tools and the apex command all reach a session through it. When no UI is attached, the daemon leads the session itself, so tools and scripts can work on a headless session. The first UI that attaches takes over.

This page covers crates/apex-server/src/daemon.rs, the apexd binary and the apex-bench measurement program. The replication model it implements (shards, leases, fencing) is described in Sessions, Shards and Leadership. The message formats are on The Attach Protocol. What the Server does with commands, files and processes is on The Server, and how proposals become entries is on Proposals.

Starting a daemon

There are three ways a daemon process starts. All of them end in Daemon::run(socket, session), which binds the socket, makes one first session, and loops until told to stop.

Entry point Where Notes
apexd [--socket PATH] [--session NAME] crates/apex-server/src/bin/apexd.rs Standalone binary. The session defaults to default and the socket to default_socket(). Unknown arguments exit with status 2.
apex server crates/apex-cli/src/main.rs:922-928 The same call from the CLI, using the CLI's -socket / -session (see The apex Command). It creates the socket's parent directory and prints apex server: session … on … to stderr first.
spawn_server(exe, socket, session) daemon.rs:29-62 Used by the CLI's ensure_server and by the UI. It runs exe -socket=… -session=… server detached and waits up to 5 s for the socket to answer.

spawn_server makes the child independent of whoever asked for it. In pre_exec it calls setsid() and then default_signals(), so the end of an ssh session or a bridge's process group does not take the daemon with it, and it does not inherit an ignored SIGTERM. Stdin and stdout are null, the working directory is $HOME, and stderr is appended to apexd.log beside the socket (daemon_log, daemon.rs:1601). Everything the daemon eprintln!s, such as "session X made" or a connection's framing error, ends up there.

default_socket() returns $APEX_SOCKET if that is set. Otherwise it returns $TMPDIR/apex-$USER/main.sock. When neither USER nor LOGNAME is set, as in many container execs, the numeric uid takes the user's place (daemon.rs:67-79).

Daemon::run_with does some process-wide setup before it binds:

run gives ~/.apex/profile as the host profile. run_with takes the profile as a parameter so tests can pass None and keep the profile out of $HOME. The doc comments on run_with and on the host_profile field still say "init file" (~/.apex/init), but the code uses ~/.apex/profile. The socket file is removed again when the loop ends.

Sources: crates/apex-server/src/bin/apexd.rs:1-29, crates/apex-server/src/daemon.rs:25-99, crates/apex-server/src/daemon.rs:237-311, crates/apex-server/src/daemon.rs:1600-1604, crates/apex-cli/src/main.rs:922-928

Threads and the event loop

One thread owns all daemon state. Every other thread talks to it through a single std::sync::mpsc channel of Events. Each connection has its own writer thread draining a private outbound queue, so a slow client never stalls the core (daemon.rs:1-11).

Diagram

The Event enum (daemon.rs:101-115) lists every input to the main loop:

Event Sent by Main loop action
Accept(UnixStream) listener thread accept: gives the connection the next id and starts its reader and writer
Msg(conn, ClientMsg) connection reader handle, except ClientMsg::Stop, which clears all connections and breaks the loop
Gone(conn) reader, at EOF or on a bad frame gone: detach, return leases, drop streams
Server(session id, ServerEvent) per-session forwarding thread Server::pump, then after, file-watch fan-out, and OSC 52 clipboard to UIs
PlumbTimeout(id) sleep thread from ask_tool tool_failed: the plumb walk moves on
AskTimeout(id) sleep thread from Ask routing ask_over(id, None)
StartTimeout(session, tool) sleep thread from hold start_failed
Io(conn, stream, IoUp) tunnel and fetch threads io_up: forward the response head, body, end or failure

accept (daemon.rs:456-510) first queues ServerMsg::Build { protocol, id }, so the first frame on every connection says which apex this is. It then spawns two threads. The reader loops on read_frame::<ClientMsg> and posts Event::Msg. On EOF or a decode error (for example, a client built against another wire format) it logs the error and posts Event::Gone. The writer blocks on its queue, writes a frame, coalesces whatever else is already queued with try_recv, and flushes once. When its sender is dropped, it flushes, then calls shutdown(Both) on the socket so the peer sees EOF instead of hanging. A new Conn starts with no session or attachment and with kind Tool until Hello arrives.

Daemon::send(id, msg) only pushes onto that connection's channel; it never blocks on I/O. To cut a connection off, the daemon removes its Conn and so drops the sender. That is how end_session disconnects members and how Stop ends every connection.

The per-session Server produces its own events (pty output, finished shell commands, file-watch hits, periodic rescans) on a futures unbounded channel returned by Server::new (lib.rs:165). new_session_in spawns a thread that blocks on that stream and re-posts each item as Event::Server(sid, ev). Terminal events reach the main thread the same way.

Sources: crates/apex-server/src/daemon.rs:1-11, crates/apex-server/src/daemon.rs:101-126, crates/apex-server/src/daemon.rs:267-308, crates/apex-server/src/daemon.rs:372-384, crates/apex-server/src/daemon.rs:456-516, crates/apex-server/src/lib.rs:165

Key types

struct Session {
    id: u64,              // internal, stable across renames
    label: String,        // for people
    log: Log,             // authoritative
    server: Server,       // effects on the world; leads the term shards
    view: Node,           // follower of every shard; leader when no UI is attached
    leader: Option<u64>,  // the UI connection that leads (single-player)
}
Type Role
Daemon All state: sessions: BTreeMap<String, Session> keyed by the session's identity string, conns: HashMap<u64, Conn>, and the in-flight tables pending, tool_plumbs, asks, held and finds, plus the channel ends (daemon.rs:211-235).
Session One session, as above. The map key is the log's identity (log.id()). id is a small internal number that events and connections use because it does not change on a rename.
Conn One client connection: the session and AttachmentId it attached as, its AttachmentKind, its outbound Sender<ServerMsg>, sent (how far each shard has been forwarded to it), its open I/O-plane streams, and the pids of programs it adopted through Named (daemon.rs:128-141).
Pending Something waiting for the leader's Applied: either a tool's proposal (connection and the tool's own id) or a step of a plumb walk.
Held Work waiting for a tool that a rule's start command is launching: a plumb, or a tool:// page request.
IoStream An open stream on the I/O plane: Watch, Put, Tunnel, Fetch or Relay (daemon.rs:144-158). See The I/O Plane and Pages.

Each session has two replicas. Session::view is a Node running as the SERVER attachment that follows every shard, and it is what the daemon reads and leads with. Server::node is the server's own replica, which leads only the pinned terminal shards (lib.rs:97-107). Log compaction takes both into account (see below).

Sources: crates/apex-server/src/daemon.rs:128-235, crates/apex-server/src/lib.rs:97-107

Sessions: the registry

A daemon holds many sessions. The sessions map is the registry, and resolve(s) finds a session by any of three names (daemon.rs:315-329):

  1. its full identity (the map key),
  2. its label,
  3. a unique prefix of the identity, four characters or longer.

infos() lists the sessions as SessionInfo { id, label }, sorted by label. That is the reply to ListSessions, NewSession, RenameSession and EndSession. A failure gets ServerMsg::Error { text } instead.

Creating a session

new_session_in(label, dir) (daemon.rs:346-401) does nothing and returns Ok(false) if the label already resolves. A NewSession for an existing session therefore succeeds and simply replies with the list. Otherwise it:

  1. Makes a fresh Log, takes its identity as the key, and sets the label.
  2. Makes the Server. If dir is given, it cds there first; a path that is not a directory is an error and nothing is made.
  3. Sets the session environment: apexsession (the identity), apexsessionlabel, APEX_SOCKET, EDITOR, and BROWSER when the xdg-open link exists. EDITOR is apex-editor, a symlink to the binary made beside it so that $EDITOR file works as a single word under zsh and rc. If the link cannot be made, it falls back to PATH/apex editor (daemon.rs:1606-1634). link_beside only makes links next to a binary named apex, so test binaries leave their directory alone.
  4. Spawns the server-event forwarding thread.
  5. Calls server.install_default_rules(&mut log) (lib.rs:1329) to install the session's low-priority plumbing rules (see Plumbing).
  6. Makes the follower Node::new(SERVER) (node.rs:268), catches it up, and calls view.init_session(&mut log) (node.rs:425) to lay out the top row and two columns, so a tool can work before any UI attaches.
  7. Records where the session is (MetaOp::Cwd from server.place(): host and directory) and catches up again.
  8. Inserts the session and calls server.run_profile(&view, host_profile) to start the host profile.
  9. Calls after to forward and settle.

Renaming and ending

rename_session checks the new label with providers::valid_label and refuses a label already in use. It changes Session::label, appends a label entry to the metalog (which is how attached clients learn the new name), and updates apexsessionlabel for commands started from then on.

end_session(name, force) (daemon.rs:430-454) refuses while any absolute-path, non-scratch File buffer is dirty, unless force is set. The error text points at -f. Otherwise every member connection receives ServerMsg::Ended { id, label } and then goes through gone. The session is removed from the map, every running process is killed, and dropping the Server ends its terminals.

ClientMsg::Stop (apex stop) is handled in the run loop itself, not in handle: it clears all connections and leaves the loop, so every session ends together with the daemon (daemon.rs:277-281).

Sources: crates/apex-server/src/daemon.rs:313-454, crates/apex-server/src/daemon.rs:555-586, crates/apex-server/src/daemon.rs:1606-1634, crates/apex-server/src/lib.rs:1885, crates/apex-server/src/lib.rs:1329, crates/apex-core/src/node.rs:268, crates/apex-core/src/node.rs:425

Profile and attach scripts

There is no configuration file. Two shell scripts run as commands of the session, and their output goes to +Errors like any command's (see Configuration):

A rule's start command is launched by Server::start_tool, which runs it as a process named after the tool; a leading apex is replaced by this daemon's own apex command (lib.rs:1251-1261).

ClientMsg::Env { set } sets variables, and both Env and EnvImport reply with the full ServerMsg::Env { vars }. ClientMsg::Cd changes the session's directory and appends the new Cwd to the metalog.

Sources: crates/apex-server/src/daemon.rs:396-399, crates/apex-server/src/daemon.rs:635-639, crates/apex-server/src/daemon.rs:716-727, crates/apex-server/src/daemon.rs:903-912, crates/apex-server/src/lib.rs:1229-1300

Attaching, leading and handing back

hello(id, session, name, kind, attach) (daemon.rs:588-642) attaches a connection to a session:

  1. Resolve the session, or reply Error "no session …".
  2. log.attach(kind, name) (log.rs:276) registers the attachment in the metalog and returns its AttachmentId.
  3. If the kind is Ui, take leadership in single-player mode. For every shard that is not pinned: if a holder other than SERVER still holds the lease and has not released it, reclaim it (log.rs:369), which fences the previous UI. Then grant the lease (log.rs:349) to the new attachment and set Session::leader = Some(conn). The previous UI stays connected but its appends are now refused.
  4. Catch up view, take a postcard snapshot of its state, and set the connection's sent marks to the log's last sequence numbers, because the snapshot already covers those entries.
  5. Reply Welcome { attachment, snapshot }.
  6. If work is held for a tool of this name (it was just started for that work), deliver it now, in order.
  7. Run the attach script, then call after so other connections learn from the metalog that the leases moved.

Tools and CLI commands attach with kind Tool. They never take leases; they change state by proposing (see Proposals).

gone(id) (daemon.rs:518-553) undoes all of this. It stops the connection's ⌘O find jobs and drops its I/O streams. It makes the server forget the processes the connection adopted, removes the plumbing rules the attachment owned, and appends a detach. If the connection was the leader, leader becomes None and every lease the attachment held is reclaimed to SERVER, so the daemon leads again until the next UI attaches. Pending tool proposals from the connection are dropped.

Diagram

Proposals and who applies them

ClientMsg::Propose { id, proposal } is assigned a daemon-side pid and recorded in pending as Pending::Tool { conn, id }. Then propose(name, pid, p) routes it (daemon.rs:967-979):

There is one special case. A non-UI connection's Proposal::Snarf is also sent as ServerMsg::Clipboard to every UI on the session, as a terminal's OSC 52 is (daemon.rs:932-944).

Appends from the leader

ClientMsg::Append { shard, entries } appends each entry with log.append_entry (log.rs:130), which checks fences and sequence numbers, and stops at the first failure, replying with an Error that names the shard and sequence. The connection's sent mark for the shard is moved to the log's end, because the appender already has its own entries. The highest accepted sequence is acknowledged with Ack. The daemon then polls the server for execs addressed to it (poll_execs, lib.rs:1029) and starts any verb walks those produced (daemon.rs:650-678). CreateShard and DeleteShard are applied directly; a created shard's sent mark starts at 0.

Sources: crates/apex-server/src/daemon.rs:518-553, crates/apex-server/src/daemon.rs:588-694, crates/apex-server/src/daemon.rs:932-979, crates/apex-server/src/lib.rs:1029, crates/apex-core/src/log.rs:130-381, crates/apex-server/tests/socket.rs:131-177

after: settle, forward, compact

Nearly every handler ends with after(name, props) (daemon.rs:1513-1597). It is the one place where the session's state is brought to rest and shipped out:

  1. Server bookkeeping. flush_procs writes process starts, renames and exits into the metalog. close_orphan_terms starts shells whose windows have appeared and closes terminals whose windows are gone. sync_watches keeps the directory watches in step with the open files. Then view.catch_up.
  2. Leading while headless. If leader is None, the server's proposals are applied here. The loop also opens and lands on any take_gotos() locations whose windows are not open: a URL becomes a web window, anything else is opened as a file in the first column. It then polls execs again, because what the daemon just did may have handed the server more work, and adds the proposals from status_flags (below). The loop repeats until there are no proposals left.
  3. Notifications follow statuses. Server::status_flags (lib.rs:584-608) looks at each window's program status (see Program Status Protocol (OSC 7501)). When a window's status comes to be Done, Blocked or Error, it raises a notification in the name of whoever reported the status and returns a Proposal::Notice so the leader shows the window. Another of the three states counts as news again; the same state with other words does not. When the status no longer asks for the user, a notification still up is lowered. The first call after the daemon starts only records the statuses that already stood, so none of them is raised. This runs in both branches: with a UI leading, the resulting proposals are sent to it with the others.
  4. Forwarding. For each connection on the session and each shard, the entries after the connection's sent mark are sent as ServerMsg::Entries. The metalog goes first, because it announces shards before their entries.
  5. Compaction. For each shard, the daemon computes the lowest of: the log's last sequence, what view has applied, what server.node has applied, and every member connection's sent mark. It then calls log.compact(shard, low). A log grows with what is outstanding, not with how long the session has existed.
  6. Proposals to a UI leader. If a UI leads, the server's proposals are sent to it with id: 0, so no answer is expected.
  7. Verbs. If the server has queued plumb starts for verbs (peek_plumb_starts), start_verbs begins those walks.

Event::Server runs Server::pump (lib.rs:635) and then after with the returned proposals. It also streams changed files to watch subscribers (file_changed) and sends each OSC 52 clipboard text to every UI connection on the session (daemon.rs:285-303).

Sources: crates/apex-server/src/daemon.rs:285-303, crates/apex-server/src/daemon.rs:1510-1597, crates/apex-server/src/lib.rs:576-608, crates/apex-server/src/lib.rs:635

Other in-session messages

Every message other than the session-level ones is handled by in_session. If the connection has not attached yet, it gets Error "not attached" (daemon.rs:578-584). The table groups what in_session does.

Messages What the daemon does
TermKey, TermPaste, TermType, TermResize, TermClear, TermFocus, TermScroll Passed to the Server's terminal hosts. Their output comes back as entries on the term shard (see Terminals).
TermText, TermFind, TermRead Selection text becomes a Snarf proposal. Find replies TermFound, and read replies TermLines.
OpenFile, EditOver Server::open_file becomes an OpenWindow proposal (with cover set for EditOver), or an Errors proposal on failure.
Plumb plumb_start begins a walk, then drive carries it out.
Ask, Answer A question about a window is routed to the window's owner with a 2 s ASK_ANSWER timeout. Answers are matched to a tool_plumbs or asks entry.
PostToPage, WindowEvent Relayed: a page owner's post goes to the leading UI, and a window event goes to the window's owner.
ClientConfig Accepted from UIs only; sets the colours terminals report to programs.
RuleAdd, RuleRm Validated. A rule is owned by its attachment (mine) or by SERVER, and only the owner may remove it; a session-owned rule is removed when the request says it is on the session's behalf.
Set A setting for the session or for a named attachment.
Notify, Unnotify Raise a notification (plus a Notice proposal so the leader shows the window). Only the raiser, or a UI, may lower it.
Named Server::name_process. An adopted pid is remembered on the connection.
FindStart, FindQuery, FindStop ⌘O listing jobs, keyed by (connection, request), whose results go straight onto the connection's channel.
Kill Signals the targets. The processes' exits are recorded in the metalog when they happen.
Candidates Path completions, sent only to the asker.
Io The I/O plane (file GET/PUT/watch, CONNECT, http(s) fetch, tool:// relay).

Plumb walks and tool deadlines

A plumb walk is a Server-side state machine. The daemon's drive (daemon.rs:1448-1488) carries out each PlumbStep:

Timeouts are handled by spawning a thread that sleeps for the deadline and then posts an event. The main loop discards the event if the entry has already been removed.

Sources: crates/apex-server/src/daemon.rs:160-209, crates/apex-server/src/daemon.rs:644-963, crates/apex-server/src/daemon.rs:1355-1508, crates/apex-server/src/lib.rs:1251-1261

Error handling and edge cases

Sources: crates/apex-server/src/daemon.rs:466-483, crates/apex-server/src/daemon.rs:650-678, crates/apex-server/src/daemon.rs:952-956, crates/apex-server/src/daemon.rs:1429-1497

Tests

crates/apex-server/tests/socket.rs runs a real daemon on a thread (Daemon::run_with(path, "main", None), with a socket path made unique by pid and a counter) and drives it with Remote clients. The tests cover:

Test What it checks
attach_edit_ack_and_reattach Appends are acknowledged, and a second UI sees the same state, takes the leases and can edit.
execs_and_terminals_over_the_socket OpenFile, Put, a |sort pipe, and Newterm with keys whose output appears on the term shard.
a_tool_works_on_a_headless_session_and_a_ui_takes_over Daemon leadership, handover to a UI, routing of proposals through the UI, and leases returning to SERVER when the UI drops.
sessions_are_listed_and_made The registry, and that sessions are isolated from each other.
the_watcher_reloads_clean_buffers_and_flags_dirty_ones File watching, stale buffers, and a Put that refuses once.
the_daemon_says_its_build_first_and_stops_when_told The first frame is Build, and Stop removes the socket.

Further tests cover B3 expansion, directory windows, links to places in other sessions, and page owners being asked about links and posting to their scripts.

Sources: crates/apex-server/tests/socket.rs:12-313

apex-bench measurements

apex-bench [--socket PATH] [--via CMD] measures the attach protocol. Without --socket it starts Daemon::run on a thread of its own process (apex_bench.rs:33). With --via CMD it attaches through a command's stdin and stdout, for example apex attach --stdio, which stands in for the ssh path without the network. It measures, in order (apex_bench.rs:52-170):

  1. 2000 Ping / Pong round trips.
  2. 5000 single-keystroke round trips: a local insert, flush, then waiting for the server's Ack.
  3. Throughput for 10 000 and 100 000 inserts in one flush.
  4. Attach time (connect, snapshot, decode) as the text grows by 0, 100, 1000 and 10 000 KB. Each probe takes the leases, so the bench reconnects afterwards to take them back.
  5. Terminal latency: 200 alternating a and backspace TermKeys, each timed until the term shard's next Entries arrives.

It reports p50, p90, p99 and max for each. The README records a run on an M-series laptop:

what p50 p99
ping round trip 21 µs 42 µs
keystroke → ack 14 µs 23 µs
100 000 keystrokes in one flush 1.8 M entries/s
attach, ~120 KB text (770 KB snapshot) 4.2 ms
attach, 11.5 M runes (11.9 MB snapshot) 34 ms
terminal key → rows on the client 180 µs 570 µs

The README draws two conclusions. The transport is not where time goes. The snapshot is several times larger than the text because it carries the undo history, so compacting or excluding undo would make a large session's attach cost about what its text costs. Through the stdio bridge, the numbers rise only slightly (ping 26 µs, attach +4.5 ms). The README's remark that terminal latency is dominated by "alacritty's parser" and that "the whole grid is published as Rows entries per wakeup" predates the current code. Terminals now use libghostty-vt (see Terminals), and Server::publish_term publishes only the rows that changed (lib.rs:423-439). macOS limits socket paths to 104 bytes, which matters for --socket.

Sources: crates/apex-server/src/bin/apex_bench.rs:1-194, crates/apex-server/README.md:26-57, crates/apex-server/src/lib.rs:423-439

Previous: The Server: Commands, Files and ProcessesNext: The Attach Protocol