danos/docs/display-v2-plan.md

12 KiB

Display v2 — build plan (pluggable scanout: GOP floor + virtio-gpu native)

The ordered, checkpointable build-out for display-v2.md. Each milestone lands on its own and ends in a verifiable gate — shaped for a /loop run, like display-plan.md. Read display-v2.md first for the why.

Locked decisions (do not relitigate)

  • First native backend = virtio-gpu (VM standard: mode-set + fenced present/flush).
  • Dynamic hot-attach: boot on GOP, upgrade to native when the driver announces (push, not polling); re-attach across driver restarts; GOP is the floor for "no driver ever," not a live fall-back after a reprogram.
  • v2 builds the shared-memory capability (endpoints → memory objects), shared with the future client-surface path.
  • The compositor's layers/back-buffer/damage are unchanged; only scanout is pluggable.

Conventions

Follow coding-standards.md: spell out non-acronym abbreviations, kebab-case file names, no Co-Authored-By trailers. New user binaries go through addUserBinary and get packed into the initial-ramdisk; protocols are b.addModule("…-protocol", …) imported into runtime; new syscalls extend abi.zig SystemCall + a library/runtime wrapper.

How to verify along the way

Every gate is serial-checkable — no screenshots (this plan is built to run unattended). Where "does it actually display" would otherwise need a human eyeball, the code reads its own pixels back: the scanout resource is CPU-visible RAM (shared-memory-backed) and the back buffer is cacheable, so a driver/compositor can write a known value, read it back, and log a pass/fail — and a virtio resource_flush is confirmed by the device acking it on the used ring. Those two together (pixel-readback + flush-ack) are the automated stand-in for "it's on screen."

  • zig build test — host unit tests (backend selection, virtio struct sizes/encodings, pixel-check helpers).
  • python3 test/qemu_test.py <case> — boots the kernel in QEMU; asserts on serial markers. The virtio cases boot with -device virtio-gpu (a per-case qemu_extra).
  • run-x86-64 renders to a window — for the human's own satisfaction, not a gate.

V1 — The scanout backend seam (refactor, no behaviour change)

Extract scanout from the compositor so today's path becomes one backend among future ones.

  • system/services/display/backend.zig: a Backend tagged union with info(), surface() (the cacheable compose target), present(damage), and capability flags (canModeSet/hasFencedPresent, both false for GOP).
  • The v1 GOP path is now backend.Gop (claims the display node, WC-maps the LFB, keeps the cacheable back buffer, present = the damage-rect WC copy). display.zig composes into backend.surface() and calls backend.present(damage) — no LFB or framebuffer geometry left in the compositor core.
  • The selection decision is the pure chooseKind(native_available) (gop unless a native driver announced), split from the syscall-bound select()/Gop.init().

Gate (met): display-service + display-demo pass unchanged (pure refactor; GOP is the only backend), and zig build test stays green.

V2 — The shared-memory cross-process capability (kernel)

  • abi.zig: shared_memory_create (34) / shared_memory_map (35) syscalls + a shared_memory_test service id. Handlers in process.zig: shared_memory_create(len) allocates contiguous, zeroed, cacheable frames, wraps them in a refcounted object, installs a capability handle, maps them into the caller's shared-memory arena → returns virtual_address + handle; shared_memory_map(cap) maps the same physical pages into the receiver. Reclaimed on death (see below).
  • The capability core (ipc-synchronous.zig) is now kind-tagged: scheduler.Task's handle table holds HandleObject{kind, ptr}; closeHandles and shareCapability dispatch by kind, so a SharedMemoryObject rides an ipc_call send_cap exactly like an endpoint and frees only when its last capability drops. mapUserSharedInto (paging) maps WB-cacheable + device_grant, so a sharer's teardown never frees the shared frames — the object owns them.
  • library/runtime/shared-memory.zig (+ barrel export): create(len) -> Region{ptr, handle, len}, map(handle) -> ptr.

Gate (met): python3 test/qemu_test.py shared-memoryshared-memory-client creates a region, writes a pattern, and passes its capability to shared-memory-server as an ipc_call send_cap; the server shared_memory_maps it and reads the same bytes back → shared-memory: shared 4096 bytes ok. Guardrail: ipc/ipc-call/ipc-cap, supervision, dma, usermem, display-service, and host tests all still pass — the handle-table change broke no existing IPC.

V3 — The virtio-gpu driver: bring-up + a frame on screen

  • system/drivers/virtio-gpu/: claim the virtio-gpu PCI function (device-manager match on the display/other class triple, driver self-confirms vendor 0x1AF4/device 0x1050 from config space), enable memory-space + bus-master, walk the vendor capabilities in config space to find common-config + notify, map the BAR, negotiate VERSION_1, and stand up the control virtqueue in coherent DMA. virtio-gpu-protocol.zig + virtio-pci.zig for the control/transport structs (host-tested sizes).
  • Create a 2D scanout resource backed by a coherent DMA region (V4 swaps this for the shared-memory surface), attach_backing, set_scanout to scanout 0, transfer_to_host_2d + resource_flush of a test pattern, and wait on the used ring.
  • Register a scanout service (ServiceId.scanout = 11).

Gate (met): the virtio-gpu case (QEMU -device virtio-gpu-pci) boots the device-manager stack, which discovers the function and spawns the driver; the driver writes a known test pattern into the scanout backing, transfer_to_host_2d + resource_flushes it, and waits for the device's used-ring ack, then reads the backing back and checks the pattern — logging virtio-gpu: scanout 640x480 online and virtio-gpu: flush acked, pixel check ok. That proves virtqueue + resource + attach + set_scanout + transfer + flush end to end without a screenshot (the used-ring ack is the device confirming it consumed the frame).

V4 — The native backend + hot-attach

  • backend.VirtioGpu in the compositor: surface() = the shared-memory scanout surface (the compositor composes straight into the device's resource backing; x86 DMA is coherent, so the cacheable shared pages need no flush), present(damage) = a present request over the driver's .scanout endpoint (→ transfer-to-host + resource flush).
  • The driver announces to .display after bring-up (looks it up with a bounded retry, sends attach_scanout with the geometry + the shared surface as an ipc_call send_cap). The compositor maps it, looks up .scanout itself (no need to pass the endpoint — the driver registered it), switches backend, and re-composites the current frame full-screen. The present is deferred to a one-shot timer so it runs after the reply unblocks the driver and it serves .scanout — presenting inline would deadlock.
  • Boot still starts on backend.Gop; the upgrade happens on announce. shared_memory_physical (a new syscall) gives the driver the guest-physical of the shared surface for attach_backing.

Gate (met): the display-native case (QEMU -device virtio-gpu-pci, mem bumped since it boots the whole system) starts the compositor + display-demo + device-manager; the driver announces, the compositor logs display: scanout upgraded to virtio-gpu, drives frames through the native backend, and reads a pixel back from the shared surface after a present to confirm the composited frame landed (display: native present verified), while display-demo: ok still fires — checked order-independently. Without -device virtio-gpu-pci nothing is announced and it stays on GOP: the v1 display-service/display-demo gates pass unchanged.

V5 — Mode-setting, EDID, and fenced presents

  • The driver negotiates VIRTIO_GPU_F_EDID (when offered) and reads the monitor's EDID, logging its preferred mode; it offers a small mode list over .scanout get_modes. The resource + shared surface are sized to the largest mode, so set_mode just re-points the scanout rectangle (no resource/surface churn) — a runtime resolution change. runtime.display gains modes() / setMode() (display-protocol get_modes/set_mode, forwarded to the backend).
  • Every resource_flush is issued fenced (VIRTIO_GPU_FLAG_FENCE); the device signals the fence when it has consumed the frame, which the used-ring ack the synchronous present waits on already gates — a tear-free present. (Completion feedback, not vblank: base virtio-gpu 2D has no display-refresh event, so nothing paces presents to the monitor — see the "Fenced is not vsync" note in display-v2.md.)
  • backend.VirtioGpu reports canModeSet / hasFencedPresent = true.

Gate (met): the display-modeset case (reusing the display-native boot) upgrades to virtio-gpu, queries the driver's modes, setModes to a different resolution, and confirms the change by reading the backend's geometry back (display: mode set to {w}x{h}, verified); the fenced present path is exercised and confirmed (display: fenced present ok) — both from serial, passing 3/3. The driver also logs the EDID preferred mode (virtio-gpu: EDID preferred mode …).

V6 — Resilience (restart + re-attach) + tests + docs

  • The virtio-gpu driver now hellos the device manager (role: bus) so it is properly supervised — no longer stopped at the hello deadline — and is restarted on death. On driver loss the compositor keeps the last frame (its .scanout calls now return -EPEER instead of hanging — a kernel fix: an endpoint is marked dead when its owner dies) and re-attaches when the restarted driver re-announces. A permanent give-up (crash-loop cap) leaves the frozen frame; GOP is not re-taken.
  • test/qemu_test.py: the virtio-gpu, display-native (hot-attach), display-modeset, and display-reattach (driver-kill/re-attach) cases. display-v2.md status updated.

Gate (met): the display-reattach case — device-manager (in test-scanout-restart mode) kills the virtio-gpu driver once after it hellos; the restart policy respawns it, it re-announces, and the compositor logs display: scanout re-attached after the initial display: scanout upgraded to virtio-gpu, with no CPU exception / panic (the compositor survives) — passing 3/3. All v1 + v2 cases (host tests, ipc/ipc-call/ipc-cap, supervision, shared-memory, display-service, display-demo, virtio-gpu, display-native, display-modeset) pass; default zig build is clean.


Deferred (explicitly not in this plan)

  • Client-rendered surfaces — now unblocked by the shared-memory capability (V2): an app renders its own bitmap and hands the compositor a reference. A natural follow-on.
  • Bochs DISPI backend — a simpler second native backend (mode-set only, dumb scanout); slots behind the same interface if wanted.
  • Real-GPU (NVIDIA/AMD/Intel) drivers — out of scope; those devices stay on the GOP floor by design.
  • Hardware-accelerated compositing / multiple heads — future.