Architecture

Audience: contributors modifying veiland-core internals. Plugin authors and users do not need this document — see plugin-api.md and config.md respectively.

Module map

veiland-core  (binary)
  main.rs           AppData definition, main(), calloop event loop
  app/mod.rs        AppData inherent methods: spawn, hotplug, repaint,
                    Configure tick, plugin-socket driver
  app/compositor.rs CompositorHandler (frame callback)
  app/input.rs      KeyboardHandler (password input, PAM trigger)
  app/lock.rs       SessionLockHandler (lock acquired/denied,
                    per-surface configure, unlock)
  app/output.rs     OutputHandler (new output, output destroyed)
  auth/mod.rs       PAM session, mlock'd password buffer
  config.rs         Config load + validation (TOML → typed structs)
  region.rs         Pixel region → clip-space rect conversion
  renderer.rs       Renderer struct: EGL/GL handles, compositor program,
                    indicator program, box program, placeholder FBO
  plugin/
    mod.rs          Re-exports + shared helpers
    slot.rs         PluginSlot: static config metadata + PluginState
    state.rs        PluginState: per-frame state (texture, connection)
                    + handle_message dispatch
    connection.rs   HostConnection: SCM_RIGHTS send/recv, handshake,
                    protocol framing
    dmabuf.rs       import_dmabuf / release_texture (EGLImage ↔ GL texture)
    spawn.rs        spawn_plugin: socketpair + Command (fork + exec)
    host_spawn.rs   try_spawn_one: config → spawn_plugin + HostConnection
    sync.rs         EGL fence import / wait / release

veiland-plugin  (library, linked by plugins)
  socket.rs         Connection: plugin-side socket framing, send/recv
  lifecycle.rs      Connection::connect, wait_for_configure, FramePacer
  buffer.rs         DmaBuffer: GBM bo + EGLImage + GL FBO
  gl.rs             GbmEgl: render node open, EGL ctx, GBM device
  render.rs         GbmEgl impl (construction, resize helpers)
  sync.rs           Plugin-side fence export

veiland-protocol  (library, linked by both)
  codec.rs          Byte-level read/write primitives, version handshake
  client.rs         ClientMessage encode/decode (Hello, Buffer, BufferDestroy)
  server.rs         ServerMessage encode/decode (Configure, FrameDone,
                    BufferReleased, Shutdown)
  types.rs          Fourcc, Modifier opaque wrappers
  error.rs          ProtocolError

The index-correspondence invariant

This is the most load-bearing non-obvious invariant in the codebase. Getting it wrong silently composites the wrong plugin onto the wrong monitor.

AppData holds two parallel Vecs:

lock_surfaces: Vec<Option<LockSurface>>   // one entry per connected output
plugins:       Vec<Vec<Option<PluginSlot>>> // outer index = same output

The contract: lock_surfaces[i] and plugins[i] always refer to the same output. The renderer paints plugins[i] onto lock_surfaces[i].

How it is maintained:

What breaks it: inserting into or removing from either Vec without doing the same to the other at the same index. The renderer loop assumes plugins.len() <= lock_surfaces.len() and iterates without bounds checks; a length mismatch causes out-of-bounds access.

The calloop event loop

main() owns one calloop::EventLoop<AppData>. All Wayland events and plugin socket events run on the same thread. The one worker thread is the auth worker: the blocking PAM call runs there so a wrong password (which pam_unix delays ~2s) doesn't stall the loop. KeyboardHandler::key copies the password out of the mlock'd buffer, sends it to the worker, and sets AuthState::Checking; the verdict comes back over a calloop::channel source and the unlock decision is committed on this thread (see below).

EventLoop::dispatch(16ms timeout, &mut AppData)
  └─ WaylandSource          → dispatches wl_display events
       └─ SCTK delegate handlers on AppData
            SessionLockHandler::configure  → EGL surface setup, repaint
            CompositorHandler::frame       → repaint (frame callback)
            KeyboardHandler::key           → password buffer; on Enter,
                                             send attempt to auth worker
            OutputHandler::new_output      → push to pending_outputs_arrived
            OutputHandler::output_destroyed→ hotplug-out teardown
  └─ Generic(plugin_fd)     → drive_plugin(output_idx, plugin_idx)
       └─ HostConnection::recv_message
            → PluginState::handle_message
                 → import_dmabuf (on Buffer)
                 → repaint_lock_surfaces (after new texture)
  └─ Channel(auth verdict)  → handle_auth_verdict(ok)
       → on Ok: take session lock, unlock, UnlockedCleanly
       → on Err: AuthState::Failed + 1500ms reset timer

After dispatch returns:
  process_pending_hotplug()  → drain pending_outputs_arrived,
                               create surfaces + spawn plugins
  process_periodic_tick()    → re-send Configure with updated time
                               to every live plugin every 30s

Plugin sockets are registered as calloop::Generic sources via register_plugin_source(output_idx, plugin_idx). On EPOLLIN the handler calls drive_plugin, which reads one message and calls handle_message. On EOF or protocol violation it returns PostAction::Remove — calloop removes the source, and the plugin is marked dead. The next process_pending_hotplug does not revive it; the region falls back to the lock-surface clear color.

The 16 ms dispatch timeout is a worst-case wake interval. In practice the loop is driven by frame callbacks (CompositorHandler::frame) and plugin socket events; the timer only matters when both are quiet (a static lockscreen with no input and no plugins).

The trust boundary in code

                         process boundary
                              |
  veiland-core (trusted)      |   plugin process (untrusted)
  ─────────────────────────   |   ──────────────────────────
  auth/mod.rs                 |   veiland-plugin/
    PAM, password buffer,     |     socket.rs
    unlock decision           |     lifecycle.rs
                              |     buffer.rs
  plugin/connection.rs        |     gl.rs
    HostConnection::recv_      |
    message                   |
    validates every field     |
    before passing to EGL     |   SCM_RIGHTS (dmabuf fd)
                              |──────────────────────────>
  plugin/dmabuf.rs            |
    import_dmabuf             |
    eglCreateImage is the     |
    final modifier/format     |
    validation gate           |

The password buffer (auth::Session) is mlock'd and lives entirely in trusted process memory. No plugin message maps to "unlock" — the unlock decision path is keyboard event → password buffer → PAM → state change. Plugins receive no keyboard events; the API surface is absent by protocol design, not runtime filter.

The PAM step is pam_authenticate only — deliberately no pam_acct_mgmt. A locker re-verifies an existing session ("did the person at the keyboard prove they own this session?"); account admission (expiry, pam_time, pam_nologin) is a login manager's decision. Running it here also let a transient pam_unix error during logind session teardown reject a correct password and lock the user out of their own live session.

The process boundary is a protocol boundary, not a same-UID security boundary: plugins run as the same user as the core, so mlock (which stops swapping, not reading) does not stop a hostile same-UID plugin from PTRACE_ATTACH-ing the core or reading /proc/<pid>/mem when ptrace_scope=0. The core mitigates this with prctl(PR_SET_DUMPABLE, 0) at startup (main.rs §0; opt out with VEILAND_ALLOW_DUMP=1 — see the environment table in config.md §8), which denies same-UID ptrace/proc-mem and suppresses core dumps of the buffer. That is defense-in-depth, not an absolute wall — there is no seccomp or landlock sandbox yet, so a determined hostile third-party plugin is a residual risk. First-party plugins are reviewed in-tree; third-party plugins are same-user code the user chose to trust.

Diagrams