diff --git a/build.zig b/build.zig index ae18855..372bf5c 100644 --- a/build.zig +++ b/build.zig @@ -535,7 +535,9 @@ pub fn build(b: *std.Build) void { // The FAT filesystem server: mounts the block device and serves it into the VFS // at /mnt/usb. Its engine (engine.zig / on-disk.zig) is imported relatively. const fat_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "fat", "system/services/fat/fat.zig"); - const display_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "display", "system/services/display/display.zig"); + // Threaded: the display runs a mouse-listener thread alongside its compositor loop + // (docs/threading.md, docs/display.md), so it opts into real atomics/TLS. + const display_exe = addThreadedUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "display", "system/services/display/display.zig"); const display_demo_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "display-demo", "system/services/display-demo/display-demo.zig"); const virtio_gpu_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "virtio-gpu", "system/drivers/virtio-gpu/virtio-gpu.zig"); const shm_server_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "shm-server", "system/services/shm-server/shm-server.zig"); diff --git a/docs/display.md b/docs/display.md index b4c6dad..392ee04 100644 --- a/docs/display.md +++ b/docs/display.md @@ -211,6 +211,38 @@ with a boot-race retry): `display.info()`, a `Layer` handle with `fill` / `blitT `damage`, and `present()`. Application code never issues the raw syscalls — it calls the runtime, as with every other danos service. +## The cursor: a mouse-listener thread feeding the compositor + +The compositor is the single owner of the framebuffer — only the main `service.run` loop +touches the backend and the layer stack. Tracking the mouse without breaking that +ownership is the display's first use of [threads](threading.md): the service is built +multi-threaded (`addThreadedUserBinary`) and, at startup, spawns a **mouse-listener +thread** beside the compositor loop. + +- **Listener thread.** Blocks on the input service's mouse stream + (`input.subscribeMouse()`), accumulates the relative `dx`/`dy` motion 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()` leaves its core + free to halt ([halting.md](halting.md)). +- **The channel.** A single-slot *latest-value* cell (`CursorChannel`) guarded by a + `runtime.Thread.Mutex`: the renderer wants where the cursor *is now*, not a replay of + every delta, so a new position overwrites the old. The listener also **pokes** the + compositor awake — the main loop is parked in `replyWait`, so the listener posts a + zero-payload `ipc.send` to the compositor's endpoint, which arrives as a + message-notification ([ipc.md](ipc.md)). The poke is *coalesced*: at most one is queued + while the main loop has not drained the last, so a fast mouse cannot flood the endpoint. +- **Render.** On the poke, the main loop takes the latest position and moves the cursor — + which is just a top-z compositor layer — with the existing `configure` + `present` path + (it damages the old and new footprints, so only those two rectangles repaint). + +Two threading facts shape this (both in [threading.md](threading.md)). IPC **handles do +not cross threads**, 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. And a +multi-threaded service doing concurrent IPC is why the kernel's endpoint-create / register +/ lookup syscalls now serialize under the big kernel lock. Shared fate applies: a fault in +the listener takes the whole display down, and the supervisor restarts the process +([resilience.md](resilience.md)). + ## What v1 does not do (and why that's fine) Two capabilities are deliberately out of the first cut. Neither reshapes anything above; @@ -232,7 +264,7 @@ both are clean additions behind the interfaces v1 establishes. ## Verifying it -Three QEMU test cases ([tests.zig](../system/kernel/tests.zig), `python3 +Four QEMU test cases ([tests.zig](../system/kernel/tests.zig), `python3 test/qemu_test.py `), each layering on the last: - **`display`** — the kernel handoff: the seeded `display` device is shaped correctly and @@ -250,6 +282,12 @@ test/qemu_test.py `), each layering on the last: `display-demo: ok`, proving a frame travelled client → compositor → screen, exactly as the [input test](input.md) proves an event travels source → service → subscriber. The visible motion itself is a screenshot away via `zig build run-x86-64`. +- **`display-cursor`** — the mouse-listener thread end to end: with the `input` service up, + `input-source mouse` publishes pure motion, and the display's listener thread accumulates + it into a cursor position handed to the render loop over the `CursorChannel`. Once the + cursor has tracked a run of that motion, the service logs + `display: cursor tracking mouse ok`. Runs `smp: 4` — the compositor and listener threads + execute on different cores, which is what surfaced the IPC-under-lock requirement above. The compositor's pixel math (rectangle clipping, fill, composite, tile blit) and colour packing are additionally covered by pure host unit tests under `zig build test`. diff --git a/docs/threading.md b/docs/threading.md index 35ba304..e87080d 100644 --- a/docs/threading.md +++ b/docs/threading.md @@ -249,6 +249,21 @@ it may call `runtime.Thread.spawn`. Everyone else stays single-threaded and lean - **Resilience** ([resilience.md](resilience.md)): a faulting thread kills its whole process (shared fate). The supervisor restarts the **process**, which respawns its threads from a known-good state — restart granularity stays the process. +- **IPC — two consequences threads forced ([ipc.md](ipc.md)):** + - *Handles do not cross threads.* The handle table lives on the `Task` + ([scheduler.zig](../system/kernel/scheduler.zig)), so a handle number is meaningful + only to the thread that created it — thread A's endpoint handle `3` is not thread B's. + A thread that needs to reach an endpoint another thread owns looks it up + (`ipc.lookup(service)`) to install its **own** handle to the same underlying endpoint. + This is how the display's mouse-listener thread reaches the compositor loop's endpoint + to poke it awake (docs/display.md). + - *IPC syscalls that touch shared kernel state now serialize under the big kernel lock.* + `create_ipc_endpoint`/`ipc_register`/`ipc_lookup` allocate from the kernel heap and + mutate the global service registry, endpoint refcounts, and handle tables. Those paths + were unlocked because a single-threaded process could not race itself; a multi-threaded + one can, from two cores at once. They now take `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 is what keeps its callers serialized. ## Build-out plan (staged, each gate serial-checkable) diff --git a/system/kernel/process.zig b/system/kernel/process.zig index 266bdfe..6b17970 100644 --- a/system/kernel/process.zig +++ b/system/kernel/process.zig @@ -256,6 +256,13 @@ fn failErr(state: *architecture.CpuState, errno: i64) void { /// create_ipc_endpoint() -> handle: allocate an endpoint and install it in the /// caller's handle table. fn systemCreateIpcEndpoint(state: *architecture.CpuState) void { + // Under the big kernel lock: this allocates from the kernel heap and mutates the + // caller's handle table. A multi-threaded process (e.g. the display's compositor + + // mouse-listener threads) can drive this concurrently from two cores, so the endpoint + // allocation and every other lock holder must serialize (heap.zig: "a lock comes with + // threads/SMP"). + const flags = sync.enter(); + defer sync.leave(flags); const endpoint = ipc.createIpcEndpoint() orelse return failErr(state, ipc.ENOMEM); const h = ipc.installHandle(scheduler.current(), endpoint); if (h < 0) { @@ -268,6 +275,10 @@ fn systemCreateIpcEndpoint(state: *architecture.CpuState) void { /// ipc_register(service_id, handle): publish the caller's endpoint under a /// well-known id so other processes can find it. fn systemIpcRegister(state: *architecture.CpuState) void { + // Under the big kernel lock: mutates the global service registry and endpoint + // refcounts, which threads of the same (or another) process can race. + const flags = sync.enter(); + defer sync.leave(flags); const id: u32 = @truncate(architecture.systemCallArg(state, 0)); const endpoint = ipc.resolveHandle(scheduler.current(), architecture.systemCallArg(state, 1)) orelse return failErr(state, ipc.EBADF); architecture.setSystemCallResult(state, @bitCast(ipc.register(id, endpoint))); @@ -276,6 +287,11 @@ fn systemIpcRegister(state: *architecture.CpuState) void { /// ipc_lookup(service_id) -> handle: find a published endpoint and install a /// handle to it in the caller. fn systemIpcLookup(state: *architecture.CpuState) void { + // Under the big kernel lock: reads the global registry, takes an endpoint reference, + // and installs a handle — all racy against concurrent threads (this is the path the + // display's mouse-listener thread takes to reach the compositor endpoint). + const flags = sync.enter(); + defer sync.leave(flags); const id: u32 = @truncate(architecture.systemCallArg(state, 0)); const endpoint = ipc.lookup(id) orelse return failErr(state, ipc.ENOENT); const h = ipc.installHandle(scheduler.current(), endpoint); diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index f6126c5..1b56b74 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -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; diff --git a/system/services/display/display.zig b/system/services/display/display.zig index 6dd7c5a..88e4556 100644 --- a/system/services/display/display.zig +++ b/system/services/display/display.zig @@ -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; diff --git a/system/services/input-source/input-source.zig b/system/services/input-source/input-source.zig index da7c39f..8925d2b 100644 --- a/system/services/input-source/input-source.zig +++ b/system/services/input-source/input-source.zig @@ -10,17 +10,38 @@ //! keyboard and mouse drivers publish their own synthetic streams today; swapping in //! decoded hardware is a follow-up (see docs/input.md). +const std = @import("std"); const runtime = @import("runtime"); const input = runtime.input; const system = runtime.system; -pub fn main() void { +pub fn main(init: runtime.process.Init) void { var source = input.connectSource() orelse { _ = system.write("input-source: input service unavailable\n"); return; }; - _ = system.write("input-source: publishing synthetic input events\n"); + // "mouse" mode publishes a steady stream of pure motion (dx=dy=+1), for driving a + // cursor (the `display-cursor` test). The default "rotate" mode cycles all device + // classes to exercise the service's per-device routing (the `input` test). + const mode = init.arguments.get(1) orelse "rotate"; + if (std.mem.eql(u8, mode, "mouse")) { + _ = system.write("input-source: publishing synthetic mouse motion\n"); + while (true) { + _ = source.publishMouseEvent(.{ + .kind = @intFromEnum(input.MouseEventKind.motion), + .button = 0, + .dx = 1, + .dy = 1, + .scroll_x = 0, + .scroll_y = 0, + .buttons = 0, + }); + system.sleep(20); // ~50 events/sec: moves the cursor briskly + } + } + + _ = system.write("input-source: publishing synthetic input events\n"); var step: usize = 0; while (true) : (step +%= 1) { // Rotate across the device classes so every publish path (and the service's diff --git a/test/qemu_test.py b/test/qemu_test.py index ecca6b2..1f83f23 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -185,6 +185,16 @@ CASES = [ {"name": "display-demo", "expect": r"display-demo: scene up[\s\S]*display-demo: ok", "fail": r"display-demo: (no display|create failed)|display: could not|CPU EXCEPTION|KERNEL PANIC"}, + # Threaded compositor tracks a mouse (docs/threading.md, docs/display.md): the display + # runs a mouse-listener thread alongside its compositor loop. `input-source mouse` + # publishes pure motion -> the input service fans it to the display's listener -> the + # listener accumulates it into a cursor position handed to the render loop over a + # single-slot channel. `display: cursor tracking mouse ok` latches once the cursor has + # tracked a run of that motion end to end. + {"name": "display-cursor", + "smp": 4, + "expect": r"display: online \d+x\d+[\s\S]*display: cursor tracking mouse ok", + "fail": r"display: (could not|mouse subscribe failed)|CPU EXCEPTION|KERNEL PANIC"}, # Shared memory (v2 V2): shm-client creates a region, writes a pattern, and passes its # capability to shm-server, which maps it and confirms the same bytes — proving # cross-process shared pages over the extended capability passing.