display: track the mouse with a listener thread (Shape A) + cursor

The display's first use of threads (docs/threading.md, docs/display.md). The
compositor stays the single owner of the framebuffer — only the main
service.run loop touches the backend and layer stack — and a dedicated
mouse-listener thread runs beside it:

  - Listener: blocks on input.subscribeMouse(), accumulates relative dx/dy
    into an absolute cursor position clamped to the screen, and hands it to
    the compositor. It never touches the compositor, so no lock guards the
    framebuffer; a parked next() lets the core halt.
  - CursorChannel: a single-slot latest-value cell under a Thread.Mutex (the
    renderer wants where the cursor is now, not a replay of deltas), with a
    coalesced self-ipc.send poke that wakes the main loop — parked in
    replyWait — as a message-notification. At most one poke is queued while
    the last is undrained, so a fast mouse can't flood the endpoint.
  - Render: the cursor is a top-z compositor layer; on the poke the main loop
    moves it via configure + present (which damages old + new footprints).

The display binary opts into threads (addThreadedUserBinary), and
input-source gains a "mouse" mode that publishes pure motion to drive it.

Two kernel-level findings this surfaced, both fixed:

  1. IPC handles do not cross threads. The handle table lives on the Task, so
     the listener can't reuse the main loop's endpoint handle — it
     ipc.lookup(.display)s its own handle to the same endpoint to poke through.

  2. Concurrent IPC from two threads raced unlocked kernel state. The display
     is the first process issuing IPC syscalls from two threads at once, which
     exposed a data race (flaky #GP in installEntry): create_ipc_endpoint /
     ipc_register / ipc_lookup allocate from the kernel heap and mutate the
     global registry, endpoint refcounts, and handle tables without the big
     kernel lock. They were safe only while a process couldn't race itself.
     They now sync.enter() like call/reply_wait/send already did (the kernel
     heap has no lock of its own yet — heap.zig: "a lock comes with
     threads/SMP" — so the big lock keeps its callers serialized).

Test: -Dtest-case=display-cursor (smp:4) spawns the input service, the
threaded display, and input-source in mouse mode; asserts the display's
"cursor tracking mouse ok" marker once the cursor has tracked a run of motion
end to end. Verified green 6/6 under stress (the race hit ~1-in-4 before the
lock fix) and in the full 29-case QEMU guardrail suite; zig build test clean.
This commit is contained in:
2026-07-21 02:31:29 +01:00
parent cf140eb772
commit 7f415e724f
8 changed files with 326 additions and 8 deletions
+165 -4
View File
@@ -21,6 +21,8 @@ const backend_mod = @import("backend.zig");
const protocol = runtime.display_protocol;
const ipc = runtime.ipc;
const system = runtime.system;
const input = runtime.input;
const Thread = runtime.Thread;
const Rect = compositor.Rect;
const Surface = compositor.Surface;
@@ -328,6 +330,157 @@ fn fail_check(_: []const u8) void {
_ = system.write("display: compositor self-check FAILED (setup)\n");
}
// --- cursor + mouse-input thread --------------------------------------------
//
// The compositor is the single owner of the framebuffer: only the main service
// loop touches `backend` and the layer stack. A dedicated listener thread (spawned
// in `initialise`) blocks on the input service's mouse stream, accumulates relative
// motion into an absolute cursor position, and hands that position to the main loop
// through `cursor_channel` — a single-slot latest-value cell (the renderer wants
// where the cursor *is*, not a replay of every delta). The listener never touches
// the compositor; it only writes the channel and pokes the main loop awake with a
// self-directed `ipc.send`, which arrives as a message-notification in the service
// loop (docs/threading.md, docs/display.md). Shared fate: a fault in the listener
// takes the whole display down and the supervisor restarts it (docs/resilience.md).
const cursor_size = 10; // a small square sprite — enough to prove tracking
const cursor_z = 0xFFFF_FFFF; // always above client layers
const cursor_report_threshold = 5; // px of travel before the tracking marker latches
var cursor_layer: ?u32 = null;
var cursor_origin_x: i32 = 0;
var cursor_origin_y: i32 = 0;
/// Latched once the cursor has demonstrably tracked a run of motion end to end
/// (source -> input service -> listener -> channel -> render): the `display-cursor`
/// test's success marker.
var cursor_tracking_reported: bool = false;
const poke_byte = [_]u8{0}; // the poke carries no payload; the value lives in the channel
/// Shared between the listener thread (producer) and the main loop (consumer).
/// Latest-value semantics with a coalesced wake: at most one poke is queued while
/// the main loop has not drained the last one, so a fast mouse cannot flood the
/// service endpoint.
const CursorChannel = struct {
lock: Thread.Mutex = .{},
poke_endpoint: ipc.Handle = 0,
x: i32 = 0,
y: i32 = 0,
buttons: u32 = 0,
dirty: bool = false,
poke_pending: bool = false,
const Snapshot = struct { x: i32, y: i32, buttons: u32 };
/// Producer (listener thread): record the newest position and, unless a wake is
/// already queued, poke the main loop awake.
fn publish(self: *CursorChannel, x: i32, y: i32, buttons: u32) void {
self.lock.lock();
self.x = x;
self.y = y;
self.buttons = buttons;
self.dirty = true;
const need_poke = !self.poke_pending;
if (need_poke) self.poke_pending = true;
self.lock.unlock();
if (need_poke) _ = ipc.send(self.poke_endpoint, &poke_byte);
}
/// Consumer (main loop): take the latest position, or null if nothing changed
/// since the last take. Clears the wake latch so the next publish pokes again.
fn take(self: *CursorChannel) ?Snapshot {
self.lock.lock();
defer self.lock.unlock();
self.poke_pending = false;
if (!self.dirty) return null;
self.dirty = false;
return .{ .x = self.x, .y = self.y, .buttons = self.buttons };
}
};
var cursor_channel: CursorChannel = .{};
fn clampAxis(value: i32, max: i32) i32 {
if (value < 0) return 0;
if (value > max) return max;
return value;
}
/// The mouse-listener thread. Blocks on the input service's mouse stream, accumulates
/// relative motion into an absolute position clamped to the screen, and publishes each
/// update. Runs for the life of the process; a parked `next()` leaves the core free to
/// halt (docs/halting.md). It reads only its own state and the channel — never the
/// compositor — so no lock guards the framebuffer.
fn mouseListener(width: u32, height: u32) void {
var mouse = input.subscribeMouse() orelse {
_ = system.write("display: mouse subscribe failed\n");
return;
};
// Our own handle to the compositor's endpoint. IPC handles are per-thread, so we
// cannot reuse the main thread's service handle — we look the service up to install a
// handle in this thread's table. A poke posted here wakes the compositor loop parked
// in replyWait (docs/threading.md: handles do not cross threads).
cursor_channel.poke_endpoint = ipc.lookup(.display) orelse {
_ = system.write("display: mouse listener could not reach the compositor endpoint\n");
return;
};
const max_x: i32 = @as(i32, @intCast(width)) - 1;
const max_y: i32 = @as(i32, @intCast(height)) - 1;
var x: i32 = @divTrunc(max_x, 2);
var y: i32 = @divTrunc(max_y, 2);
var buttons: u32 = 0;
while (true) {
const event = mouse.next() orelse continue;
// Switch on the raw kind (not @enumFromInt, which would panic on a scroll or
// future kind): motion moves the cursor, anything else just updates buttons.
if (event.kind == @intFromEnum(input.MouseEventKind.motion)) {
x = clampAxis(x + event.dx, max_x);
y = clampAxis(y + event.dy, max_y);
} else {
buttons = event.buttons;
}
cursor_channel.publish(x, y, buttons);
}
}
/// Consume the latest cursor position from the channel and repaint the cursor layer at
/// it. Runs on the main loop (the compositor owner) in response to a listener poke.
/// `configureLayer` damages both the old and new footprints, so a plain `present`
/// repaints exactly the two rectangles that changed.
fn renderCursor() void {
const snapshot = cursor_channel.take() orelse return;
const id = cursor_layer orelse return;
_ = configureLayer(id, snapshot.x, snapshot.y, cursor_z, true);
present();
if (!cursor_tracking_reported and
@abs(snapshot.x - cursor_origin_x) >= cursor_report_threshold and
@abs(snapshot.y - cursor_origin_y) >= cursor_report_threshold)
{
cursor_tracking_reported = true;
_ = system.write("display: cursor tracking mouse ok\n");
}
}
/// Create the cursor sprite (a top-z square) at screen centre and spawn the listener
/// thread. Called from `initialise` once the backend is up. If either step fails the
/// display still serves drawing clients — it just has no cursor.
fn startCursorTracking() void {
const mode = backend.info();
cursor_origin_x = @divTrunc(@as(i32, @intCast(mode.width)), 2);
cursor_origin_y = @divTrunc(@as(i32, @intCast(mode.height)), 2);
const id = createLayer(cursor_origin_x, cursor_origin_y, cursor_size, cursor_size, cursor_z, true) orelse {
_ = system.write("display: could not create cursor layer\n");
return;
};
cursor_layer = id;
_ = fillLayer(id, Rect.init(0, 0, cursor_size, cursor_size), protocol.pack(mode.format, 0xF0, 0xF0, 0xF0));
present(); // show the cursor at its start position
_ = Thread.spawn(.{}, mouseListener, .{ mode.width, mode.height }) catch {
_ = system.write("display: could not spawn mouse listener\n");
};
}
// --- service ----------------------------------------------------------------
fn initialise(endpoint: ipc.Handle) bool {
@@ -350,6 +503,9 @@ fn initialise(endpoint: ipc.Handle) bool {
_ = system.write("display: presented frame 0\n");
selfCheck();
// Bring up the cursor and the mouse-listener thread now that the backend is live.
startCursorTracking();
return true;
}
@@ -435,11 +591,16 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Han
}
}
/// The only notification the compositor arms is the post-attach present timer: repaint the
/// screen into the freshly attached native surface, verify the frame landed, then run the
/// one-shot mode-set self-check (V5).
/// Two notification sources reach the compositor. A **message-notification** is a poke
/// from the mouse-listener thread (a buffered self-`ipc.send`, `notify_message_bit`):
/// repaint the cursor at its latest channel position. Anything else is the post-attach
/// present **timer**: repaint into the freshly attached native surface, verify the frame
/// landed, then run the one-shot mode-set self-check (V5).
fn onNotification(badge: u64) void {
_ = badge;
if (badge & ipc.notify_message_bit != 0) {
renderCursor();
return;
}
present(); // native present + verify (first timer fire after the upgrade)
if (pending_modeset_check) {
pending_modeset_check = false;