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
+55
View File
@@ -101,6 +101,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
displayServiceTest(boot_information);
} else if (eql(case, "display-demo")) {
displayDemoTest(boot_information);
} else if (eql(case, "display-cursor")) {
displayCursorTest(boot_information);
} else if (eql(case, "shm")) {
shmTest(boot_information);
} else if (eql(case, "virtio-gpu")) {
@@ -2782,6 +2784,46 @@ fn displayServiceTest(boot_information: *const BootInformation) void {
while (true) scheduler.yield();
}
/// The threaded compositor tracks a mouse (docs/threading.md, docs/display.md). Spawn the
/// `input` fan-out service, the display (which runs a mouse-listener thread alongside its
/// compositor loop and draws a top-z cursor), and `input-source` in `mouse` mode — a
/// synthetic source publishing pure motion. The display's own marker,
/// `display: cursor tracking mouse ok`, is printed once the cursor has tracked a run of
/// motion end to end (source -> input service -> listener thread -> channel -> render), so
/// like the other display cases we match on serial rather than poll in-kernel.
fn displayCursorTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: display-cursor\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
if (!spawnNamed(rd, "input")) {
log("display-cursor: could not spawn the input service\n", .{});
result();
return;
}
if (!spawnNamed(rd, "display")) {
log("display-cursor: could not spawn the display service\n", .{});
result();
return;
}
if (!spawnNamedWithArg(rd, "input-source", "mouse")) {
log("display-cursor: could not spawn the mouse source\n", .{});
result();
return;
}
scheduler.setPriority(1); // below the services, so they run
while (true) scheduler.yield();
}
/// D4 — a separate process drives the compositor. Spawn the display service and the
/// hardware-free `display-demo` client, which creates a wallpaper, a moving rectangle,
/// and a cursor and presents a run of frames. Its `display-demo: ok` heartbeat — printed
@@ -3029,6 +3071,19 @@ fn spawnNamed(rd: initial_ramdisk.Reader, name: []const u8) bool {
return false;
}
/// As `spawnNamed`, but passes one extra argv entry (argv[1]) — e.g. a mode selector like
/// `input-source mouse`.
fn spawnNamedWithArg(rd: initial_ramdisk.Reader, name: []const u8, arg: []const u8) bool {
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (eql(item.name, name)) {
return if (process.spawnProcess(item.blob, 4, &.{ item.name, arg })) true else |_| false;
}
}
return false;
}
/// The GSI discovery recorded for the HPET, from the same device table drivers see.
fn hpetGsi() ?u32 {
var buffer: [16]device_abi.DeviceDescriptor = undefined;