Architecture¶
The central idea¶
Clients write desired state. A reconciler drives the live session toward it. Observed state is always re-derived and never persisted.
Everything else follows from that. Boot-restore, hotplug recovery, crash recovery, and API writes are not four features — they are four triggers for one pass.
Module map¶
src/
├── main.rs CLI (`run`, `openapi`), daemon wiring, signal handling
├── config.rs Bootstrap configuration: file + SUEDE_* overrides
├── presentation.rs Experimental direct-presentation session lifecycle: resolving requested vs. effective mode against runtime state, the fallback marker and crash budget, and running `suede display-reset` as a bounded child process during fallback
├── drm_inventory.rs Experimental: live physical-output inventory for direct mode — EDID identity and exact mode timings read straight from sysfs and DRM_IOCTL_MODE_GETCONNECTOR, with no prior Wayland boot
├── alignment.rs Automatic output phase alignment: judges the slicer's `phaseMs` after a Wayland session starts and runs the output-phase fix itself, within a per-session budget
├── nvidia_driver.rs NVIDIA kernel module and GSP firmware state, read straight from /proc/driver/nvidia, for GET /system and the gsp-firmware and nvidia-driver-version health checks
├── model/
│ ├── observed.rs What sway and PipeWire report
│ ├── desired.rs The document clients write, and its validation
│ ├── geometry.rs Canvas/output geometry: source rects, corner pins, center remap
│ └── black_lift.rs Fixed and adaptive black-lift configuration
├── sway/
│ ├── protocol.rs IPC framing
│ ├── raw.rs Sway's JSON shapes, and the mapping into the model
│ ├── client.rs Live client over a Unix socket
│ ├── mock.rs In-memory client that simulates output commands
│ └── direct.rs Experimental: `DirectOutputs`, a `SwayClient` wrapper that intercepts commands for owned physical outputs and simulates their effect, so the reconciler and planner need no direct-mode branch of their own
├── audio/
│ ├── pw.rs PipeWire via pw-dump / pw-cli
│ └── mock.rs In-memory monitor
├── state.rs Atomic persistence with a .bak fallback; repairs an old or invalid document instead of refusing to boot
├── snapshot.rs Shared live view of outputs, windows, and status
├── projection_policy.rs Warp capability gating: when Warp is allowed to be effective
├── projection/ Canvas slicing, edge blending, and Warp geometry (feature `projection`)
│ ├── mod.rs Module overview and the capture→blend→present pipeline
│ ├── manager.rs `BlendManager`: owns the slicer child process (spawn, control, restart) and per-output blend-overlay processes
│ ├── slicer.rs The `suede slice` child binary: capture, blend, present loop
│ ├── control.rs Versioned stdin/stdout control protocol to the slicer child
│ ├── layout.rs `Evaluator`: seam distance and blend-weight computation
│ ├── warp.rs Per-output homography and center remap math
│ ├── warp_update.rs Output-local transfer/geometry table builds
│ ├── blend.rs Canvas planning and blend-weight activation
│ ├── seam_oracle.rs Independent test-only cross-check for blend weights
│ ├── gpu.rs Vulkan capture/blend path, queue priority negotiation; builds the overlap-highlight shader variant only while it is on
│ ├── shaders/ `blend.frag` and its overlap-highlight variant `blend_markers.frag` (the plain shader plus one block, test-guarded), compiled to checked-in `.spv` with naga
│ ├── gpu_readback.rs `#[cfg(test)]` GPU readback harness, not production
│ ├── adaptive.rs Adaptive black-lift measurement and control
│ ├── pattern.rs Built-in test patterns (grid, warp-alignment, sync, …)
│ ├── overlay.rs The `suede blend` child binary: a per-output layer-shell overlay for test-pattern-only bench alignment when no canvas is running — actual seam blending lives in the slicer now
│ └── dmabuf.rs dmabuf plumbing shared between the Wayland side (`slicer.rs`) and the Vulkan side (`gpu.rs`)
├── reconciler/
│ ├── plan.rs Pure diff: (observed, desired) → commands
│ └── mod.rs The pass, the task loop, event forwarding
├── supervisor/
│ ├── launcher.rs Launch specification → process invocation
│ └── mod.rs Process lifecycle, placement, watchdog
├── checks/ Environment health checks and remediations
├── events.rs SSE fan-out
└── api/ axum routers, handlers, OpenAPI, embedded web UI
└── projection.rs `/projection/recommendation` handlers and the layout-recommendation engine
Boundaries that matter¶
Everything that touches the compositor goes through SwayClient. The trait has five methods; the live implementation speaks the IPC protocol over a Unix socket, and the mock records commands and simulates their effect. Nothing else in the codebase opens a socket. This is what lets CI — which has no compositor, no displays, and no audio server — run the full test suite.
The planner is pure. plan_outputs(observed, desired, applied, capabilities) returns a list of command strings and divergences. It performs no IO, so the reconciliation rules are table-testable:
let plan = plan_outputs(&observed, &desired, &applied, capabilities);
assert_eq!(plan.commands, vec![
"output HDMI-A-1 enable",
"output HDMI-A-1 mode 1920x1080@60Hz",
"output HDMI-A-1 pos 0 0",
...
]);
The executor in reconciler/mod.rs runs the plan. Splitting them is what makes "does a satisfied configuration issue any commands?" a one-line assertion.
Applications are child processes, not exec calls. Sway's exec would leave Suede with no PID — the prior implementation this generalizes had to terminate browsers with pkill -f chrom|firefox. Owning the process gives clean SIGTERM-then-SIGKILL termination of the whole process group, exit-code observation, restart policies, and per-app audio routing through the environment.
The reconciliation pass¶
- Re-query outputs.
- Plan the canvas: decide Warp capability (
projection_policy::status), validate warp geometry, and compute the canvas/slicing plan. The configured layout lives in canvas space and may overlap; sway must only ever be handed a plain tiling, so this happens before the output plan. - Diff the resulting (non-overlapping) output layout against desired state; issue only the commands for fields that differ, in one batched Sway IPC message.
- If any output was enabled or disabled, wait for the layout to settle, then re-query. A plain move is also re-observed before anything downstream derives geometry from it.
- Run the projection pass: size the headless canvas and start, update, or stop the slicer child now that observed output geometry is settled. This decides which output (if any) the active app renders into.
- Resolve exactly one app —
desired.activeApp— to its target output and workspace; it always covers the whole canvas. Per-app placement fields no longer exist; there is nothing to resolve per display. - Start, stop, or restart that application accordingly.
- Ensure the null audio sink exists, if the app routes to silence.
- Hide and park the cursor.
- Re-query windows, place any that are newly mapped, run the watchdog.
- Publish status.
A failed command is logged, recorded as a divergence, and retried next pass. A pass never aborts the daemon and never discards desired state.
Diffing against two sources¶
Most settings are diffed against observed state, which is authoritative and survives a daemon restart. But Sway does not report tearing or max_render_time back through get_outputs, so those are diffed against what Suede last applied, held in memory.
There is one subtlety worth knowing: enabling an output resets everything Sway knows about it. A plan that issues enable therefore re-applies every other setting unconditionally, rather than trusting a record that says they already match.
Concurrency¶
| Task | Responsibility |
|---|---|
| HTTP server | Serves requests; writes go to the store and trigger a pass |
| Reconciler | Owns "make live match desired"; one pass at a time, under a mutex |
| Sway event pump | Reconnects with backoff, forwards events to the trigger |
| PipeWire monitor | pw-dump --monitor as a change trigger, debounced |
| Health checks | Re-evaluated every 60 seconds |
| Boot capability probe | api::capabilities::boot_measure, spawned once at startup to establish initial GPU/warp capability before the first pass needs it |
| systemd liveness watchdog | watchdog::feed, feeds sd_notify(WATCHDOG=1) for as long as the daemon's async runtime is still turning — separate from the per-app content watchdog, which lives in the supervisor |
Slicer control writer (projection/manager.rs) |
A dedicated blocking thread owning the slicer child's stdin pipe, so a slow write never blocks reconciliation |
Slicer stats reader (projection/manager.rs) |
A dedicated thread reading the slicer child's stdout for capability events and stats, independent of the control writer |
Triggers are coalesced: a burst of events produces one pass. The trigger channel has capacity one, and a full channel means a pass is already pending, so requesting is never blocking.
The state store's two locks¶
StateStore (state.rs) splits "what is live" from "what is on disk" into
two locks taken in a fixed order, never nested. documents, a RwLock,
holds the current and (if one is live) preview document; a prepare closure
runs under it to read anything a validator needs — capability status, the
bootstrap overlap policy — before the transition, so nothing that touches
disk, a capability probe, or another lock is ever reached while documents
is held. writer, a separate Mutex, owns everything about the file on
disk and is taken only after documents is released: flush copies the
newest document out under a brief read guard, drops it, and only then
persists. Saves are serialized by writer and ordered by revision, so a
flush that finds a newer revision already on disk does nothing — an earlier
revision can never overwrite a later one no matter how the executor
schedules the flushes. Handlers run persistence through spawn_blocking
(the fsync of even a 4 kB file is still a blocking syscall), so a slow disk
never stalls the async executor or an effective() reader. If a save fails,
memory wins: the write stays live and the reconciler keeps driving toward
it, and StateStore::persist_failure reports a state_not_persisted
divergence until a later save reaches disk.
Testing¶
CI has no compositor, so the architecture is built around that constraint rather than fighting it.
| Layer | How it is tested |
|---|---|
| Sway mapping | Recorded get_outputs / get_tree fixtures in src/sway/fixtures/ |
| Planner | Table-driven; asserts exact command sequences |
| Supervisor | Real child processes (sleep, true), mock compositor |
| API | tower::ServiceExt::oneshot against the router, with mock backends |
| Persistence | Temp directories; corruption and migration paths included |
| OpenAPI | Snapshot test, so every endpoint change is a reviewable diff |
| Documentation links | tests/docs_links.rs resolves every "page/#anchor" string literal named in src/checks/mod.rs and src/model/observed.rs against the actual docs tree |
| GPU-gated | #[ignore]d by default; SUEDE_GPU_TEST=1 cargo test -- --ignored runs them against whatever Vulkan device the machine actually has |
| Web UI (Warp editor) | tests/ui/*.cjs, driven by Playwright against a real browser — not run by CI; a manual step documented in tests/ui/README.md |
| End to end | scripts/smoke-test.sh drives a running daemon over HTTP |
| Spelling | scripts/check-en-us.sh fails on en-GB spelling in a tracked text file; runs in CI and in scripts/dev-check.sh |
The smoke test is the one that catches what unit tests structurally cannot: it starts the real binary, kills a supervised process to prove it relaunches, restarts the daemon to prove configuration survives, and diffs suede openapi against the served document.
scripts/dev-check.sh # fmt, clippy, test, smoke
scripts/dev-check.sh snapshot # refresh the OpenAPI snapshot
Deliberate deviations from the specification¶
Two dependencies named in the original specification were replaced during implementation. Both preserve a higher-level guarantee the specification also makes — a single self-contained binary with no native library dependencies, cross-compilable to aarch64 without a custom sysroot.
swayipc-async → a direct implementation. The crate is built on the smol/futures-lite ecosystem rather than tokio, so using it inside a tokio/axum service would mean two async reactors in one binary. The IPC protocol is a six-byte magic string, a little-endian length, and a little-endian type; the implementation in sway/protocol.rs is about 60 lines and gives exact control over reconnection.
The pipewire crate → pw-dump and pw-cli. The crate links against libpipewire-0.3, which would add a build-time native dependency, break the "only libc" property, and require a PipeWire-equipped arm64 sysroot for cross-compilation. Driving PipeWire's own command-line tools gives the same capabilities — enumeration, change notification, null sink creation — with no build dependency at all. pw-dump --monitor is used purely as a change trigger, mirroring how Sway's detail-free output event is handled, with a one-shot pw-dump providing the authoritative list.
The web UI is build-step free. The specification called for TypeScript compiled to static assets. It is instead one self-contained HTML file embedded with include_str!. A reference client's job is to be readable and to exercise every endpoint; requiring an npm toolchain in CI to ship it would be a poor trade.
Design records and future architecture plans¶
See the Engineering Plans section:
- Black lift: a design record of the shipped
projection.blackLiftspatial-distribution and adaptive-control math, plus one related idea that was drafted but never built. - Direct-to-Display Presentation via VK_KHR_display: a proposed plan, not yet implemented, for bypassing the Wayland compositor to present directly to display hardware via Vulkan display extensions.