Operations and the wire

July 16, 2026 ยท View on GitHub

This page covers the two wire formats the router speaks and the four operations it serves. It mirrors src/protocol/ (the frame layouts and the opcodes) and src/server/ (the loop's IPC side: the drain, the handlers, the reply). For the routing that follows a drained event see the routing engine; for the tables the handlers mutate see the state page; for the overview and the capability identity see the README.

The router holds no InputSource capability. Nothing on this page changes that: the operations here let a consumer register interest or claim a grab, and the reply path acknowledges the request. None of them can inject an event into the kernel ring.

Two wire formats

Inbound requests and outbound deliveries use deliberately different magics so a subscriber can never mistake a delivery for a reply on its own inbox.

FrameMagicDirectionLayoutSource
NIRS request0x4E49_5253consumer to router, and router to reply20-byte header, then payloadsrc/protocol/header.rs:17
NINP delivery0x4E49_4E50router to subscriber8-byte header, then the 32-byte event, 40 bytes totalsrc/protocol/delivery.rs:23

The NIRS request frame

The request header is magic, version 1, op, flags, a reserved word, a request id, and a declared payload length, in that order (src/protocol/header.rs:17, src/protocol/decode.rs:19). parse checks the magic and version, reads the op / flags / request id, and requires the declared payload length to match the received body exactly, so a truncated or padded body is rejected before dispatch (src/protocol/decode.rs:32). A reply reuses the same header with a 4-byte status appended (src/protocol/encode.rs:19, src/protocol/encode.rs:29).

The status values the router returns are 0 for success, E_INVAL (-22), E_ACCES (-13), and E_BAD_OP (-38) (src/protocol/errno.rs:17), plus E_NOMEM (-12) from subscribe and E_BUSY (-16) from grab request, both defined locally in their handlers (src/server/handlers/subscribe.rs:21, src/server/handlers/grab_request.rs:22).

The NINP delivery frame

encode_delivery writes the 4-byte magic, a 2-byte version (1), two zero bytes, then the 32-byte InputEvent field by field, little-endian, for 40 bytes total (src/protocol/delivery.rs:29). The frame size is HDR_LEN + size_of::<InputEvent>(), computed from the libc struct so the two stay in lockstep (src/protocol/delivery.rs:25). This is the envelope every consumer decodes; the desktop shell's inverse is documented in the event path. The delivery path itself lives with the routing engine (src/route/deliver.rs), documented on the routing page.

The four operations

The router exposes four opcodes on its service inbox (src/protocol/ops.rs:17).

OpOpcodeWhat it doesHandler
OP_HEALTHCHECK0x0001reply 0 to a liveness probe (empty body required)ops.rs:17, handlers/health.rs:20
OP_SUBSCRIBE0x0002record a pid and a kind mask so it receives those event kindsops.rs:18, handlers/subscribe.rs:23
OP_GRAB_REQUEST0x0003claim exclusive keyboard or pointer events (trusted callers only)ops.rs:19, handlers/grab_request.rs:31
OP_GRAB_RELEASE0x0004drop the caller's grabs (empty body required)ops.rs:20, handlers/grab_release.rs:21

The IPC drain

drain_ipc reads each pending request without blocking and dispatches it (src/server/drain_ipc.rs:28). It calls mk_ipc_recv_from on the service inbox with RECV_NOWAIT in a loop; a return of zero or less, or a zero sender pid, ends the loop and hands control back to the main loop (src/server/drain_ipc.rs:38). A request that fails parse is skipped and the loop continues (src/server/drain_ipc.rs:41). The dispatch then matches on the opcode (src/server/drain_ipc.rs:44):

  match req.op:
      OP_HEALTHCHECK  if body empty  -> health::handle
      OP_SUBSCRIBE                   -> subscribe::handle
      OP_GRAB_REQUEST                -> grab_request::handle
      OP_GRAB_RELEASE if body empty  -> grab_release::handle
      _ if body empty                -> E_BAD_OP
      _                              -> E_INVAL

The two empty-body guards on the match arms are load bearing. OP_HEALTHCHECK and OP_GRAB_RELEASE require an empty body, so a probe or a release carrying stray bytes falls through to the wildcard arms: an unknown opcode with an empty body gets E_BAD_OP, and any request with a non-empty body it does not recognise gets E_INVAL (src/server/drain_ipc.rs:51).

The four handlers

Each handler is one file under src/server/handlers/ and replies through respond::status.

Health

health::handle replies 0 and does nothing else; it is the liveness probe (src/server/handlers/health.rs:20). The empty-body requirement is enforced by the caller's match arm, not the handler.

Subscribe

The body is exactly 8 bytes: a u32 kind mask and a u32 pad (src/protocol/limits.rs:23). The mask is a bitset over the INPUT_KIND_* values, so bit n set means the subscriber wants events whose kind == n (src/protocol/limits.rs:20). The handler validates the length, rejecting anything but 8 bytes with E_INVAL, reads the mask, and upserts it into the subscription table (src/server/handlers/subscribe.rs:24). upsert updates an existing entry for that pid or claims a free slot; a mask of 0 removes the entry, and a full table returns false, which the handler maps to E_NOMEM (src/server/handlers/subscribe.rs:32). The table and its 16-slot cap are on the state page.

Grab request

The body is 8 bytes carrying a u32 kind mask (src/server/handlers/grab_request.rs:23). The handler is gated first: is_trusted_grabber resolves each name in GRABBERS = [app.boot_splash, app.setup_wizard, app.input_probe] to a pid and compares it to the sender, and a non-match is E_ACCES (src/server/handlers/grab_request.rs:25, :32). Only then does it check the body length (E_INVAL on a mismatch) and read the mask; a zero mask is E_INVAL (src/server/handlers/grab_request.rs:44). The grab table splits the mask into keyboard and pointer classes and stores each separately; a class already held by a different pid makes request return false, which the handler maps to E_BUSY (src/server/handlers/grab_request.rs:48). The split and the busy check are on the state page.

Grab release

The body must be empty, enforced by the match arm (src/server/drain_ipc.rs:48). The handler drops whichever keyboard and pointer grabs the caller held and replies 0 (src/server/handlers/grab_release.rs:22). It is unconditional and does not check that the caller was ever trusted, because releasing a grab you do not hold is a no-op: GrabTable::release only clears a class whose holder pid matches the caller.

The reply

Every handler replies through one helper. respond::status writes the response header with a 4-byte payload length, writes the status word after the header, and calls mk_ipc_reply to the sender pid (src/server/respond.rs:21). The reply travels on the reply endpoint, port 4321 (Capsule.mk:13). The reply frame is HDR_LEN + STATUS_LEN bytes, a 20-byte NIRS header plus the 4-byte status (src/server/respond.rs:24, src/protocol/limits.rs:18).

Source map

  src/protocol/header.rs      NIRS magic, version, 20-byte header, Request
  src/protocol/decode.rs      parse: magic/version check, exact payload-length match
  src/protocol/encode.rs      response_header + write_status
  src/protocol/errno.rs       E_INVAL, E_ACCES, E_BAD_OP
  src/protocol/limits.rs      SUBSCRIBE_REQ_LEN, STATUS_LEN, the kind-mask contract
  src/protocol/ops.rs         the four opcodes
  src/protocol/delivery.rs    NINP magic and encode_delivery, the 40-byte frame
  src/protocol/read_u32.rs    the little-endian body reader the handlers use
  src/server/drain_ipc.rs     RECV_NOWAIT loop, parse, opcode dispatch, empty-body guards
  src/server/handlers/health.rs        liveness reply
  src/server/handlers/subscribe.rs     length + mask validation, upsert, E_NOMEM
  src/server/handlers/grab_request.rs  trusted-grabber gate, mask checks, E_BUSY
  src/server/handlers/grab_release.rs  unconditional release
  src/server/respond.rs       status reply on the reply endpoint
  Capsule.mk                  the service and reply endpoints (ports 4320 and 4321)

Every reference above is verified against those trees.