Services as processes
Keeps 2 promises
A service is a process, so services written in any language port, and a
crash takes down one service, not the node. The terminal keeps a real pty
per shell, owned by the node, not by the session, so a dropped pipe leaves
the shell running to reattach to. The files service confines a caller's
path to its root. MCP is a service too, off until turned on
(mcp.md).
Where it breaks: a crashed service fails its calls, and state worth keeping
is the service's job. A local socket must be reachable only by the node. A
service that calls another is a caller with a key of its own
(access/service-to-service).
The desktop
Keeps 8 promises
The desktop is a program of its own, youros-desktop, in Rust beside the
node, which starts it when it finds it there. It links the machine's
GStreamer and GLib, so it can't be in the node's static build, and an
update's trial never waits on it. It drives mutter's RemoteDesktop and
ScreenCast over D-Bus (zbus), and runs GStreamer inside the process
(gstreamer-rs): PipeWire in, x264 (or VP8 where x264 isn't installed) out of
an appsink. Errors and frames come to the service as they happen, rather
than being guessed from a child process's silence. Input goes back through
the same mutter session. One desktop per user on a node.
| The machine | The pane gets | Root |
|---|---|---|
| logged in, unlocked | own; mirror when built |
no |
| logged in, locked | own |
no |
| nobody logged in | own, under the user's lingering systemd |
no |
| no GNOME | nothing yet; a wlroots compositor run the same way would fit | — |
own is gnome-shell --headless --wayland --no-x11 started as the
user, on a D-Bus of its own, with XDG_SESSION_ID naming no session.
Measured on GNOME 46, Ubuntu 24.04 (2026-10-19), while the machine's own
session was locked:
- it came up in about a second, using 140 MB;
- it was unlocked;
- it streamed a
RecordVirtualmonitor; - it took remote pointer input.
youros-desktop did the same there, the physical session's LockedHint
unchanged throughout; e2e/desktop proves the logged-out case.
The private bus lets it sit beside the user's own session, where a second
logind session for the same account could not: both would hold
org.gnome.Shell on one bus. The session id matters too. With none,
gnome-shell takes the user's graphical session from logind (or the
greeter's, when nobody is logged in), locks when that session is told to,
and writes its own lock state onto it. With an id logind doesn't know, it
takes nothing and carries on. Nobody logged in needs lingering
(loginctl enable-linger), which keeps the user's systemd, PipeWire and
/run/user up; polkit lets any user turn that on for themselves.
mirror (not built) is RecordMonitor on the user's own session.
GNOME ends every cast when the screen locks and refuses new ones until it is
unlocked, and we leave that alone. Keeping the cast going through the lock
would put a remote unlock in front of a screen somebody locked and walked
away from.
Frames go as session datagrams (session/datagram-window), each split
into pieces under the relay's 64 KiB frame. The socket between node and
service carries a datagram as a frame whose length has its top bit set. The
browser says got for each frame that arrives whole. A viewer three frames
behind its last got holds back the encoder, whose input drops frames
until it catches up; what is encoded is then always a chain the viewer can
decode. A viewer silent for two seconds stops holding the others back. A
frame missing a piece is dropped, and the browser asks for a keyframe. It
decodes with WebCodecs onto a canvas.
Captures. The capture is the pane's size: the caps right after
pipewiresrc are what mutter builds the monitor from, and a resize is a
new capture, because a PipeWire stream keeps its first format for life. A
new capture is made before the old one stops, so the desktop always has a
monitor; with none for a moment, mutter takes windows out of full screen.
Each capture has a PipeWire connection of its own. Sharing one, as
pipewiresrc does by default, hands a new capture the dead connection
after PipeWire restarts, and it waits on it for ever. A second, idle
connection ends when PipeWire does, which is how its death is heard at
once; a pipewiresrc that has nothing to say posts no error. An idle screen
sends nothing, so pipewiresrc sends its last frame again every second,
and three silent seconds mean a dead stream. A capture is stopped on a
thread of its own: a pipeline whose PipeWire died can hang going to NULL.
mutter makes its virtual pointer on the first motion, and a window binds it
only after, so the service moves the pointer once as each capture starts;
otherwise a viewer's first click reaches nothing. Input moves the seat to
the pane it came from, and each viewer hears who holds it. No call into
GStreamer or mutter is made with the service's lock held.
Proofs run in e2e/desktop: a GNOME machine under systemd that nobody
logs in to, in Docker, installs the node with the line the page shows, and
Chromium reads a test app's frame counter and colours off the pane's
canvas. Measured there (2026-10-19, llvmpipe, no GPU):
- first frame 0.85 s after the pane opens;
- 28 frames a second, directly;
- a click on the canvas changes it after 110 ms (median), 165 ms (worst of ten);
- a key after 45 to 80 ms;
- a resize settles in 1.9 s;
- frames are back 1 s after PipeWire is killed, and 8 to 9 s after the shell is (a new shell starts);
- 12 frames a second over the relay, where the e2e relay writes every message to disk.
Where it breaks:
ownis the user's account, not a sandbox. It shares the same files, keyring and PipeWire. An app that locks its profile, like a browser, runs in one desktop or the other.- Windows open at the machine aren't in
own. Seeing those ismirror. - Locking is off in
ownalone. It gets a dconf database of its own (youros_desktop, seeded from the user's settings), with locking off: the node's grant is the gate, and an unlock dialog with no logind session behind it may never unlock. - A PipeWire restart takes the monitor with it. mutter drops it before anything can be made in its place, so full-screen and maximised windows come back out of those states.
- A killed shell takes its windows with it. A new shell starts empty.
- The direct pipe is still one ordered, reliable channel. A lost packet
holds the frames behind it until SCTP resends it; the second, unordered
channel the design wants (
desktop/latest-frame) isn't built. - Pacing is by round trip. Three frames per round trip caps the frame rate on a slow relay.
- It renders in software (llvmpipe), and GTK apps fall back from EGL.
- The node depends on mutter's private D-Bus API, and doesn't check mutter's version yet.
Updates
Keeps 8 promises
- A node updates itself
- Only a release we signed is installed
- Never an older version
- A node restarts into an update only when nobody is using it
- A release that doesn't come up is rolled back, and never tried again
- Updating a service restarts only that service
- Services outlive a restart of the node
- An extension updates on its own, signed by its publisher
A release is a numbered manifest at /dl/manifest on the host the node was
installed from, with each build's SHA-256, and its signature beside it at
/dl/manifest.sig. It names its component, node for now; a service or
an extension would be a component of its own with its own rules
(trust.md), and a node takes only node. Our signature is the floor:
the account approving each release in its log (browser/approved-releases)
is a second check on top, once built. Today a node takes every release of
its own once idle; the tiers (trust/tiers) will make a change to the
trusted base also wait for the account (trust/base-approved), which this
updater doesn't do yet. The number is the count of commits on the line releases
come from, which only grows because that line is never rewritten; a build
knows its own number, and a build with none (made by hand, in tests) never
updates itself. The release key is hybrid like an account's (D-2); its
secret stays on the machine that makes releases, outside the repo, and the
node carries the public half compiled in. The installer can only check a
hash, since a shell can't verify ML-DSA; from the first run on, the node
checks signatures itself.
The node checks on start, every fifteen minutes (a static file, free to
serve), and at once when its relay hints at a later release; the hint only
says when to look, so a relay that withholds it delays a node by a quarter
of an hour at most (relay.md, Release hints). A newer, correctly signed
release is downloaded, checked against its hash and kept beside the
running file as youros-node.next. The node goes on serving until no
session has carried a message for three minutes and no file is open in a
room, or a day has passed; then
it tells each session it is restarting, stops its services and starts the
new build as a trial, while the old one waits. The trial has a minute to
attach to the relay, have its services answer and load the account's log,
and says so in a file. If it does, the old build is kept as
youros-node.prev, the new one renamed into place, and the node replaces
itself with it in the same process, so systemd and the installer's
background start both carry on. If the trial exits or stays silent, it is
stopped, its release is recorded as bad and never fetched again, and the
old build starts afresh. A build that passed its trial but then fails to
come up three times in ten minutes swaps the kept one back itself. Going
back to the build kept on this machine is not a downgrade in the sense of
node/no-downgrade, which is about what the server offers: it returns to
what this machine was already running. Browsers open a new session as soon
as the node is back, and the page shows each machine's release beside it.
Today the services are the node's own binary, so they update with it, and
a restart ends every shell: the two future promises above are what lifts
that, a service released and restarted on its own, and services that the
new node adopts through their sockets instead of starting afresh. An
extension (sandbox.md) is never in our release: it has a publisher, and
its updates are signed by them (D-32).
Where it breaks: one release key, so losing it means a new key shipped by
hand to every machine, and a stolen one signs code every machine runs;
splitting it (several keys, a threshold) is open (trust/urgent-fix
asks for a second key). Idle is what the node sees: no session message
for three minutes and no file open in a room (files/open-is-live); a
shell left open with nothing running counts as idle, and ends with the
restart.