From 9b9758e5ee2564b04685eadf2b9ce23dd1060b86 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 1 Aug 2026 19:34:40 +0200 Subject: rsnix v0.4.0: GUI input foundation — PS/2 mouse + scheduler message queues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mouse (IRQ12, stream mode): ByteRing generalized for both PS/2 devices, ISR routes data-port bytes by the controller's aux bit; mouse_init with bounded-ACK handshake (boots without a mouse); packet decoder with bit-3 resync, screen-relative deltas, buttons. Exposed as /dev/mouse, the `mouse` stream command, and `make test-mouse`. Message queues (ADR 0003's GUI-era primitive): every task gets a static 16-slot TaskMsg ring; send/recv/recv_blocking with direct wake + tick scan safety net for the lost-wakeup race; `msgrecv`/`send` commands, `mouse ` routes real IRQ12 events into a task's queue, jobs shows q=. `make test-msg` + `make test-disk` regression pass. --- CONTEXT.md | 73 ++++++++++++++++- Cargo.lock | 6 +- Cargo.toml | 2 +- Makefile | 12 ++- rsdos/src/main.rs | 5 ++ rsnix/src/fs.rs | 38 +++++++++ rsnix/src/interrupts.rs | 68 +++++++++++++--- rsnix/src/ps2.rs | 183 +++++++++++++++++++++++++++++++++++++++++ rsnix/src/sched.rs | 199 ++++++++++++++++++++++++++++++++++++++++++++- scripts/test-mouse.sh | 130 +++++++++++++++++++++++++++++ scripts/test-msg.sh | 159 ++++++++++++++++++++++++++++++++++++ shell/src/lib.rs | 211 +++++++++++++++++++++++++++++++++++++++++++++++- 12 files changed, 1061 insertions(+), 25 deletions(-) create mode 100755 scripts/test-mouse.sh create mode 100755 scripts/test-msg.sh diff --git a/CONTEXT.md b/CONTEXT.md index 112b8de..9ceb81d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -113,7 +113,9 @@ Workspace version is `a.b.c` (single version for all crates): Bump the workspace version in the root `Cargo.toml` when a change of that class lands — do not leave the version to drift. Current: -`0.2.0`; the multitasking work lands as a `b` bump (`0.3.0`). +`0.4.0` — the GUI input foundation (PS/2 mouse + scheduler message +queues) landed as a `b` bump; the GUI era itself is reserved for the +`a` bump (1.0.0). ## Project layout @@ -404,6 +406,75 @@ Workspace of four crates, all built for the `x86_64-unknown-uefi` target: `bg overflow` dies on the guard page with the machine intact; full regression (rescan/til/klog/grammar/pager) re-passes. +✅ **PS/2 mouse** — auxiliary-channel driver on IRQ12, stream mode + (3-byte packets), the GUI's second input device: + +- `interrupts`: the keyboard byte ring is generalised to `ByteRing`; + IRQ12 (vector 44) gets its own ring and stub. Both PS/2 handlers + route each data-port byte by the controller status register's aux + bit (bit 5), so keyboard and mouse never steal each other's bytes + even when both are pending at once. PIC unmask now covers IRQ2 + (cascade) and IRQ12 (slave bit 4). +- `ps2`: `mouse_init()` enables the aux port, sets config bits + IRQ12+aux-clock (preserving the keyboard bit), then `set defaults` + (100 Hz / 4 counts/mm / 1:1) + `enable reporting` via the 0xD4 + channel — each waits for the device ACK with a bounded timeout so + a machine without a mouse boots normally (log: `ps2: mouse not + present`). `try_poll_mouse()` reassembles packets in main + context from the raw byte ring; the packet-start marker (bit 3) + resyncs the stream after any lost byte. Events are **screen- + relative** (`dx` + = right, `dy` + = down — the PS/2 Y-up wire + convention is negated), buttons decoded; 9-bit sign extension via + the packet sign bits. +- `fs`: `/dev/mouse` char device — `cat /dev/mouse` drains up to 32 + pending events (`dx=+N dy=+N buttons=L-R`), reports + `mouse: no PS/2 mouse` / `mouse: no events` otherwise. +- shell: `mouse` command streams live events (10 ms poll via + `sleep_ms`) until ^C. + + Verified in QEMU (`make test-mouse`, headless, via the monitor's + `mouse_move`/`mouse_button` HMP commands): boot line `ps2: mouse + ready`, `/dev/mouse` drains a pre-consumer move (`8,4`), the live + stream decodes `mouse_move 20 7` → `dx=+20 dy=+7 buttons=---`, + `mouse_button 1` → `buttons=L--`, a held-button move + (`-5,-3` while L down) → `dx=-5 dy=-3 buttons=L--`, release, + and ^C stops the stream. `make test-disk` regression re-passes. + +✅ **Scheduler message queues (the GUI-era primitive from ADR 0003)** — + every task gets a static 16-slot ring of `TaskMsg` (4 KB in .bss, + no ISR-time allocation): + +- `TaskMsg` = `Key(KeyEvent)` | `Mouse(MouseEvent)` | `Timer` | + `User(u64)` — input uses the same decoded types the PS/2 drivers + produce, so window input routing is literally "post what the + console would have gotten"; `User(u64)` is the generic escape + hatch. `KeyEvent`/`MouseEvent` derive Copy for ring storage. +- `sched::send(id, msg)` — posts from any context (task, shell, ISR) + under the scheduler lock; wakes a Blocked task straight to Ready; + errors cleanly on a gone task or a full queue (input is lossy + under extreme load by design). `recv()` pops without blocking; + `recv_blocking()` marks the task Blocked and yields. **Lost-wakeup + safety net**: the tick scan also wakes Blocked tasks whose queue is + non-empty, so a task that blocked *after* its message arrived (or + whose poster never woke it) is picked up within one 10 ms tick. +- shell: `msgrecv` spawns a task that blocks on recv and logs every + message to serial (`msgrecv[2]: Key(F3)` …); `send + [args]` posts `user ` / `timer` / `mouse ` + (1=L 2=R 4=M) / `key ` (enter esc tab space bksp arrows + home end pgup pgdn del ctrl-c f1..f12, or a single char); `jobs` + now shows `q=` (pending message count). +- **Real-input routing, proven**: `mouse ` (angle brackets) streams + decoded IRQ12 events into that task's queue instead of the console + — the exact loop the window manager will run. `msgrecv` + `mouse 2` + - `mouse_move 20 7` showed `msgrecv[2]: Mouse(+20,+7 …)` in QEMU. + + Verified in QEMU (`make test-msg`): all six message types delivered + in order (user, timer, key enter/ctrl-c/f3, synthesized mouse), + real routed mouse moves (including a held-button move), the 17th + send against a 16-cap queue reports `queue full` (tested against + the shell's never-consumed queue), and `jobs` shows q=0 + task back + to `blocked` after draining. `make test-disk` regression re-passes. + ## Known issues / perf notes - **Scroll cost** — scrolling still shifts the whole framebuffer (a diff --git a/Cargo.lock b/Cargo.lock index 2931bd6..444738e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "rsdos" -version = "0.3.0" +version = "0.4.0" dependencies = [ "rsnix", "shell", @@ -19,11 +19,11 @@ dependencies = [ [[package]] name = "rsnix" -version = "0.3.0" +version = "0.4.0" [[package]] name = "shell" -version = "0.3.0" +version = "0.4.0" dependencies = [ "rsdosedit", "rsnix", diff --git a/Cargo.toml b/Cargo.toml index aab42e4..2864712 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["rsnix", "shell", "rsdos", "rsdosedit"] [workspace.package] -version = "0.3.0" +version = "0.4.0" edition = "2024" # no_std UEFI kernel: no unwinder available, so panic must abort. diff --git a/Makefile b/Makefile index 01e265a..f244251 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ export BOOT_EFI=$(ESP_DIR)/EFI/BOOT/BOOTX64.EFI # Create one with: make disk (or: ./scripts/mkdisk.sh) export DISK_IMG ?= /tmp/disk.img -.PHONY: all run run-gui clean image debug release fmt bootloader boot-image disk disk-fresh inspect test-disk +.PHONY: all run run-gui clean image debug release fmt bootloader boot-image disk disk-fresh inspect test-disk test-mouse test-msg # ---- Build --------------------------------------------------------------- @@ -113,6 +113,16 @@ inspect: test-disk: ./scripts/test-disk.sh +# Automated PS/2 mouse round trip: boot -> inject mouse_move/mouse_button +# via the monitor -> verify /dev/mouse + the `mouse` stream -> poweroff. +test-mouse: + ./scripts/test-mouse.sh + +# Automated scheduler message-queue round trip: msgrecv/send, real-input +# routing into a task's queue, queue-full boundary. +test-msg: + ./scripts/test-msg.sh + # ---- Full boot chain ----------------------------------------------------- # Build bootloader only (rsboot submodule) diff --git a/rsdos/src/main.rs b/rsdos/src/main.rs index 700dcc7..fc47c82 100644 --- a/rsdos/src/main.rs +++ b/rsdos/src/main.rs @@ -138,6 +138,11 @@ pub unsafe extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut rsnix::serial_println!("serial: COM1 115200 8N1"); rsnix::ps2::init(); rsnix::serial_println!("ps2: keyboard initialized"); + if rsnix::ps2::mouse_init() { + rsnix::serial_println!("ps2: mouse ready (IRQ12, stream mode)"); + } else { + rsnix::serial_println!("ps2: mouse not present"); + } if rsnix::gdt::init() { rsnix::serial_println!("gdt: own GDT + TSS (IST: page-fault/double-fault)"); } else { diff --git a/rsnix/src/fs.rs b/rsnix/src/fs.rs index b560bfa..0b7a772 100644 --- a/rsnix/src/fs.rs +++ b/rsnix/src/fs.rs @@ -46,6 +46,7 @@ enum CharDevice { Zero, Console, Serial, + Mouse, } enum NodeData { @@ -171,6 +172,12 @@ pub fn init() -> (usize, usize) { FileType::Char, NodeData::Char(CharDevice::Serial), ); + add( + "mouse", + Some(dev), + FileType::Char, + NodeData::Char(CharDevice::Mouse), + ); add( "motd", @@ -700,11 +707,42 @@ fn dev_text(dev: CharDevice, _pos: u64) -> String { CharDevice::Version => version_text(), CharDevice::Meminfo => meminfo_text(), CharDevice::Klog => crate::klog::dump(), + CharDevice::Mouse => mouse_text(), CharDevice::Null | CharDevice::Console | CharDevice::Serial => String::new(), CharDevice::Zero => String::new(), } } +/// Render `/dev/mouse`: drain every pending PS/2 event (up to 32) into one +/// line each, so `cat /dev/mouse` shows the movement that happened since +/// the last read. Falls back to a clear message when there is no device or +/// nothing pending. +fn mouse_text() -> String { + if !crate::ps2::mouse_present() { + return String::from("mouse: no PS/2 mouse\n"); + } + let mut s = String::new(); + for _ in 0..32 { + let Some(ev) = crate::ps2::try_poll_mouse() else { + break; + }; + let mut b = String::new(); + b.push(if ev.left { 'L' } else { '-' }); + b.push(if ev.right { 'R' } else { '-' }); + b.push(if ev.middle { 'M' } else { '-' }); + s.push_str(&alloc::format!( + "dx={:+} dy={:+} buttons={}\n", + ev.dx, + ev.dy, + b + )); + } + if s.is_empty() { + s.push_str("mouse: no events\n"); + } + s +} + // --- self-test ------------------------------------------------------------ pub fn self_test() -> bool { diff --git a/rsnix/src/interrupts.rs b/rsnix/src/interrupts.rs index 6abd54d..7ea0b07 100644 --- a/rsnix/src/interrupts.rs +++ b/rsnix/src/interrupts.rs @@ -153,19 +153,22 @@ pub fn uptime_ms() -> u64 { } // --------------------------------------------------------------------------- -// Lock-free SPSC ring buffer for keyboard scancodes +// Lock-free SPSC ring buffers for PS/2 input (keyboard scancodes + mouse +// bytes). One per device: the ISR routes each data-port byte to the right +// ring via the controller status register's aux bit (bit 5), so the two +// devices never steal each other's bytes even when both are pending at once. // --------------------------------------------------------------------------- /// Single-producer (ISR) / single-consumer (main) lock-free ring buffer. -struct KeyBuf { +struct ByteRing { buf: [core::cell::UnsafeCell; 256], head: AtomicU16, // next slot to read (written by consumer / main) tail: AtomicU16, // next slot to write (written by producer / ISR) } -unsafe impl Sync for KeyBuf {} +unsafe impl Sync for ByteRing {} -impl KeyBuf { +impl ByteRing { const fn new() -> Self { // Inline-const array repeat (stable since 1.79): each element is a // fresh `UnsafeCell::new(0)` value, so no interior-mutable const. @@ -205,7 +208,8 @@ impl KeyBuf { } } -static KEYBUF: KeyBuf = KeyBuf::new(); +static KEYBUF: ByteRing = ByteRing::new(); +static MOUSEBUF: ByteRing = ByteRing::new(); // --------------------------------------------------------------------------- // Interrupt stubs (assembly) and dispatch @@ -240,6 +244,14 @@ core::arch::global_asm!( " push 33", " jmp isr_common", "", + // PS/2 mouse IRQ (IRQ12 → vector 44). + ".globl isr_stub_44", + ".p2align 4", + "isr_stub_44:", + " push 0", + " push 44", + " jmp isr_common", + "", // Software yield (vector 0x60 — outside the PIC's 32-47 range, which // is why 0x40 was rejected: 0x40 = 64 decimal = slave IRQ 8 slot). ".globl isr_stub_60", @@ -345,13 +357,27 @@ unsafe extern "C" fn isr_dispatch(frame: &IntFrame) -> usize { eoi(0); crate::sched::on_tick(fp) } - 33 => { - let byte: u8; - core::arch::asm!("in al, dx", out("al") byte, in("dx") 0x60u16, + 33 | 44 => { + // PS/2 data byte. The controller's status register tells us + // which device it came from (bit 5 = auxiliary/mouse), so the + // byte is routed to the matching ring — both handlers must + // check, because when both devices have data pending the + // first IRQ serviced can find the other device's byte in the + // output buffer. + let status: u8; + core::arch::asm!("in al, dx", out("al") status, in("dx") 0x64u16, options(nostack, preserves_flags)); - // Push scancode to ring buffer (called with interrupts disabled). - KEYBUF.push(byte); - eoi(1); + if status & 0x01 != 0 { + let byte: u8; + core::arch::asm!("in al, dx", out("al") byte, in("dx") 0x60u16, + options(nostack, preserves_flags)); + if status & 0x20 != 0 { + MOUSEBUF.push(byte); + } else { + KEYBUF.push(byte); + } + } + eoi((frame.vector - 32) as u8); fp } 8 => { @@ -381,6 +407,12 @@ pub fn read_scancode() -> Option { KEYBUF.pop() } +/// Read one mouse data byte from the interrupt-driven ring buffer +/// (non-blocking). Packets are assembled in `ps2::try_poll_mouse`. +pub fn read_mouse_byte() -> Option { + MOUSEBUF.pop() +} + /// Global tick counter (incremented by timer IRQ, ~100 Hz). u64 so /// uptime never wraps; `uptime_ms` interpolates the PIT counter for the /// sub-tick fraction. @@ -406,6 +438,8 @@ pub fn init() { // Override vectors for interrupt we actually handle. idt_set_entry(idt_ptr, 32, isr_stub_32 as *const () as u64, cs, 0x0Eu8, 0); idt_set_entry(idt_ptr, 33, isr_stub_33 as *const () as u64, cs, 0x0Eu8, 0); + // PS/2 mouse (IRQ12). + idt_set_entry(idt_ptr, 44, isr_stub_44 as *const () as u64, cs, 0x0Eu8, 0); // Yield (software interrupt). idt_set_entry( idt_ptr, @@ -439,13 +473,20 @@ pub fn init() { pit_set_rate(); PIT_READY.store(true, Ordering::Relaxed); - // Unmask timer (IRQ0 → bit 0) and keyboard (IRQ1 → bit 1) on master. + // Unmask timer (IRQ0), keyboard (IRQ1) and the cascade line (IRQ2) + // on the master; unmask IRQ12 (PS/2 mouse) on the slave. let mut mask: u8; asm!("in al, dx", out("al") mask, in("dx") PIC1_DATA, options(nostack, preserves_flags)); - mask &= !0x03u8; + mask &= !0x07u8; asm!("out dx, al", in("dx") PIC1_DATA, in("al") mask, options(nostack, preserves_flags)); + let mut mask2: u8; + asm!("in al, dx", out("al") mask2, in("dx") PIC2_DATA, + options(nostack, preserves_flags)); + mask2 &= !0x10u8; // IRQ12 → bit 4 of the slave PIC + asm!("out dx, al", in("dx") PIC2_DATA, in("al") mask2, + options(nostack, preserves_flags)); // Enable interrupts. asm!("sti", options(nostack)); @@ -469,6 +510,7 @@ unsafe extern "C" { fn isr_stub_14(); fn isr_stub_32(); fn isr_stub_33(); + fn isr_stub_44(); fn isr_stub_60(); fn isr_stub_spurious(); } diff --git a/rsnix/src/ps2.rs b/rsnix/src/ps2.rs index 35848b6..5f4a804 100644 --- a/rsnix/src/ps2.rs +++ b/rsnix/src/ps2.rs @@ -16,6 +16,7 @@ const MOD_RALT: u8 = 0x20; const MOD_CAPS: u8 = 0x40; /// A decoded key event. +#[derive(Clone, Copy, Debug)] pub enum KeyEvent { Char(char), Enter, @@ -400,3 +401,185 @@ fn ctrl_held() -> bool { let state = kbd(); state.modifiers & (MOD_LCTRL | MOD_RCTRL) != 0 } + +// --------------------------------------------------------------------------- +// PS/2 mouse — auxiliary channel, IRQ12, stream mode (3-byte packets) +// --------------------------------------------------------------------------- + +// PS/2 controller commands (written to port 0x64). +const CMD_AUX_ENABLE: u8 = 0xA8; // enable the auxiliary (second) port +const CMD_READ_CFG: u8 = 0x20; // read Configuration Byte → port 0x60 +const CMD_WRITE_CFG: u8 = 0x60; // write Configuration Byte ← port 0x60 +const CMD_SEND_TO_AUX: u8 = 0xD4; // next byte on 0x60 goes to the mouse + +// Configuration Byte bits (as sent via CMD_READ_CFG / CMD_WRITE_CFG). +// Bit 0 (IRQ1/keyboard) is set by `init`; this driver preserves it by +// reading the byte and only ORing in the two mouse bits below. +const CFG_AUX_IRQ: u8 = 0x02; // bit 1 — IRQ12 (mouse) +const CFG_AUX_CLOCK: u8 = 0x20; // bit 5 — auxiliary port clock enable + +// Mouse device commands (sent via CMD_SEND_TO_AUX). +const AUX_SET_DEFAULTS: u8 = 0xF6; // 100 Hz sample, 4 counts/mm, 1:1 +const AUX_ENABLE: u8 = 0xF4; // start data reporting (stream mode) +const AUX_ACK: u8 = 0xFA; + +/// A decoded PS/2 mouse event (one 3-byte packet). +/// +/// Movement is **screen-relative**: `dx` positive = right, `dy` positive = +/// down. (The PS/2 wire format is Y-up-positive; the driver negates it so +/// consumers can apply the deltas to framebuffer coordinates directly.) +#[derive(Clone, Copy, Debug)] +pub struct MouseEvent { + pub dx: i16, + pub dy: i16, + pub left: bool, + pub right: bool, + pub middle: bool, +} + +/// Packet assembly state — lives in main context (consumer side), fed by +/// the raw byte ring that the IRQ12 handler fills. +struct MouseState { + phase: u8, // 0 = awaiting start byte, 1 = await X, 2 = await Y + pkt: [u8; 3], +} + +static mut MOUSE: MouseState = MouseState { + phase: 0, + pkt: [0; 3], +}; +static MOUSE_PRESENT: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); + +/// True once `mouse_init` found a device that ACKed its setup commands. +pub fn mouse_present() -> bool { + MOUSE_PRESENT.load(core::sync::atomic::Ordering::Relaxed) +} + +/// Enable the auxiliary (mouse) channel and put the device into stream +/// mode. Returns false when no device ACKs (controller without a second +/// port, or a missing mouse) — the kernel then just runs keyboard-only. +/// +/// Must run *before* `interrupts::init` unmasks IRQ12: the init handshake +/// reads the data port directly, so any stray mouse bytes would otherwise +/// be split across the ISR path mid-handshake. +pub fn mouse_init() -> bool { + drain_input(); + unsafe { + // 1. Enable the auxiliary port itself. + wait_write(); + core::arch::asm!("out dx, al", in("dx") STATUS_PORT, in("al") CMD_AUX_ENABLE, + options(nostack, preserves_flags)); + + // 2. Configuration Byte: enable IRQ12 + the aux port clock. Read, + // OR the two bits, write back (preserves the keyboard IRQ bit + // that ps2::init set). + wait_write(); + core::arch::asm!("out dx, al", in("dx") STATUS_PORT, in("al") CMD_READ_CFG, + options(nostack, preserves_flags)); + wait_read(); + let mut cfg: u8; + core::arch::asm!("in al, dx", out("al") cfg, in("dx") DATA_PORT, + options(nostack, preserves_flags)); + cfg |= CFG_AUX_IRQ | CFG_AUX_CLOCK; + wait_write(); + core::arch::asm!("out dx, al", in("dx") STATUS_PORT, in("al") CMD_WRITE_CFG, + options(nostack, preserves_flags)); + wait_write(); + core::arch::asm!("out dx, al", in("dx") DATA_PORT, in("al") cfg, + options(nostack, preserves_flags)); + + // 3. Device setup. `set defaults` normalises the sample rate / + // resolution before enabling reporting, so the packet stream + // starts at a known cadence. Both commands need a device ACK; + // a timeout here means no mouse — bail out cleanly. + if !aux_command(AUX_SET_DEFAULTS) || !aux_command(AUX_ENABLE) { + return false; + } + } + MOUSE_PRESENT.store(true, core::sync::atomic::Ordering::Relaxed); + true +} + +/// Send one byte to the mouse via the 0xD4 channel and wait for its ACK. +/// The wait is bounded so a missing device cannot hang the boot. +fn aux_command(cmd: u8) -> bool { + unsafe { + wait_write(); + core::arch::asm!("out dx, al", in("dx") STATUS_PORT, in("al") CMD_SEND_TO_AUX, + options(nostack, preserves_flags)); + wait_write(); + core::arch::asm!("out dx, al", in("dx") DATA_PORT, in("al") cmd, + options(nostack, preserves_flags)); + for _ in 0..100_000 { + let status: u8; + core::arch::asm!("in al, dx", out("al") status, in("dx") STATUS_PORT, + options(nostack, preserves_flags)); + if status & STAT_OUTPUT_FULL != 0 { + let b: u8; + core::arch::asm!("in al, dx", out("al") b, in("dx") DATA_PORT, + options(nostack, preserves_flags)); + return b == AUX_ACK; + } + } + } + false +} + +/// Non-blocking: decode the next pending mouse event, if a full packet has +/// arrived. Raw bytes are drained from the IRQ ring; incomplete packets +/// stay buffered in `MouseState` until the remaining bytes land. +pub fn try_poll_mouse() -> Option { + while let Some(b) = crate::interrupts::read_mouse_byte() { + if let Some(ev) = mouse_byte(b) { + return Some(ev); + } + } + None +} + +/// Feed one raw byte into the packet assembler. Byte 0 of every standard +/// PS/2 mouse packet has bit 3 set (the sync marker), so a lost byte (ring +/// overrun, init response) is detected on the next packet and the stream +/// re-syncs — the marker is only accepted as a packet start. +fn mouse_byte(b: u8) -> Option { + let m = unsafe { &mut *core::ptr::addr_of_mut!(MOUSE) }; + match m.phase { + 0 => { + if b & 0x08 != 0 { + m.pkt[0] = b; + m.phase = 1; + } + None + } + 1 => { + m.pkt[1] = b; + m.phase = 2; + None + } + _ => { + m.pkt[2] = b; + m.phase = 0; + Some(MouseEvent { + // 9-bit signed: low 8 bits in the data byte, sign bit in + // byte 0 (bit 4 = X, bit 5 = Y). + dx: signed9(m.pkt[0], m.pkt[1], 0x10), + // PS/2 reports Y positive = up; flip to screen convention. + dy: -signed9(m.pkt[0], m.pkt[2], 0x20), + left: m.pkt[0] & 0x01 != 0, + right: m.pkt[0] & 0x02 != 0, + middle: m.pkt[0] & 0x04 != 0, + }) + } + } +} + +/// Sign-extend an 8-bit movement byte to 9 bits using the packet's sign +/// flag (bit `sign` of byte 0), widening to i16 for consumers. +fn signed9(b0: u8, b: u8, sign: u8) -> i16 { + let mut v = (b as i8) as i16; + if b0 & sign != 0 { + // Set the 9th bit (0xFF00 = sign-extension mask for i16). + v |= 0xFF00u16 as i16; + } + v +} diff --git a/rsnix/src/sched.rs b/rsnix/src/sched.rs index 6ffa745..dbe3647 100644 --- a/rsnix/src/sched.rs +++ b/rsnix/src/sched.rs @@ -42,6 +42,63 @@ const STACK_PAGES: usize = 3; // 1 guard page + 8 KiB stack const NAME_MAX: usize = 24; const PANIC_MAX: usize = 64; +// --------------------------------------------------------------------------- +// Inter-task message queues +// --------------------------------------------------------------------------- + +/// Capacity of each task's message ring (power of two; full sends drop +/// with a "queue full" error). Sized so input bursts (a 100 Hz mouse, +/// key repeat) far outrun a blocked consumer: 16 × ~1.6 s at worst. +const MSG_CAP: usize = 16; + +/// One inter-task message. Input events use the same decoded types the +/// PS/2 drivers produce, so a window's input routing is "post what the +/// driver would have delivered to the console". `User(u64)` is the +/// generic escape hatch (window commands, app signals, …). +#[derive(Clone, Copy, Debug)] +pub enum TaskMsg { + Key(crate::ps2::KeyEvent), + Mouse(crate::ps2::MouseEvent), + Timer, + User(u64), +} + +/// Decode a message for logging/display (no Debug dep in the shell task). +pub fn msg_describe(msg: &TaskMsg) -> alloc::string::String { + use crate::ps2::KeyEvent; + match msg { + TaskMsg::Key(k) => match k { + KeyEvent::Char(c) => alloc::format!("Key('{}')", c), + KeyEvent::Enter => "Key(Enter)".into(), + KeyEvent::Backspace => "Key(Backspace)".into(), + KeyEvent::Tab => "Key(Tab)".into(), + KeyEvent::Escape => "Key(Escape)".into(), + KeyEvent::Ctrl(c) => alloc::format!("Key(Ctrl-{})", c), + KeyEvent::CtrlArrow(d) => alloc::format!("Key(CtrlArrow{})", d), + KeyEvent::ArrowUp => "Key(Up)".into(), + KeyEvent::ArrowDown => "Key(Down)".into(), + KeyEvent::ArrowLeft => "Key(Left)".into(), + KeyEvent::ArrowRight => "Key(Right)".into(), + KeyEvent::Home => "Key(Home)".into(), + KeyEvent::End => "Key(End)".into(), + KeyEvent::PageUp => "Key(PgUp)".into(), + KeyEvent::PageDown => "Key(PgDn)".into(), + KeyEvent::Delete => "Key(Delete)".into(), + KeyEvent::F(n) => alloc::format!("Key(F{})", n), + KeyEvent::Unknown(b) => alloc::format!("Key(0x{:02x})", b), + }, + TaskMsg::Mouse(m) => alloc::format!( + "Mouse({:+},{:+} {} {})", + m.dx, + m.dy, + if m.left { "L" } else { "-" }, + if m.right { "R" } else { "-" } + ), + TaskMsg::Timer => "Timer".into(), + TaskMsg::User(n) => alloc::format!("User({})", n), + } +} + #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum TaskState { Ready, @@ -65,6 +122,13 @@ struct Task { ticks: u64, panic: [u8; PANIC_MAX], panic_len: u8, + /// Per-task message ring (see `TaskMsg`). Accessed by the owning + /// task (pop) and by any poster (push) — both under the scheduler + /// lock, so `send` is callable from ISR or task context. + msg: [TaskMsg; MSG_CAP], + msg_head: u8, + msg_tail: u8, + msg_count: u8, } const TASK_NONE: Task = Task { @@ -78,6 +142,10 @@ const TASK_NONE: Task = Task { ticks: 0, panic: [0; PANIC_MAX], panic_len: 0, + msg: [TaskMsg::User(0); MSG_CAP], + msg_head: 0, + msg_tail: 0, + msg_count: 0, }; static mut TASKS: [Task; MAX_TASKS] = [TASK_NONE; MAX_TASKS]; @@ -133,8 +201,18 @@ pub fn on_tick(cur: usize) -> usize { TASKS[CURRENT].ticks += 1; let now = crate::interrupts::uptime_ms(); for t in 0..MAX_TASKS { - if TASKS[t].state == TaskState::Sleeping && TASKS[t].wake_at <= now { - TASKS[t].state = TaskState::Ready; + match TASKS[t].state { + TaskState::Sleeping if TASKS[t].wake_at <= now => { + TASKS[t].state = TaskState::Ready; + } + // Safety net for the recv race: a task that blocked + // *after* its message was queued (or whose poster never + // got to wake it) wakes on the next tick as long as its + // queue is non-empty. + TaskState::Blocked if TASKS[t].msg_count > 0 => { + TASKS[t].state = TaskState::Ready; + } + _ => {} } } pick_next(cur) @@ -279,6 +357,10 @@ fn spawn_at(id: usize, entry: fn(), name: &str) -> bool { ticks: 0, panic: [0; PANIC_MAX], panic_len: 0, + msg: [TaskMsg::User(0); MSG_CAP], + msg_head: 0, + msg_tail: 0, + msg_count: 0, }; } true @@ -319,6 +401,110 @@ pub fn yield_now() { yield_int(); } +// --------------------------------------------------------------------------- +// Message passing +// --------------------------------------------------------------------------- + +/// Post a message to a task's queue and wake it. Safe from any context: +/// a task (e.g. a window manager routing input), the shell, or an ISR — +/// the scheduler lock makes the queue mutation atomic w.r.t. switching. +/// Returns an error when the task is gone or its queue is full. +pub fn send(id: usize, msg: TaskMsg) -> Result<(), &'static str> { + if id >= MAX_TASKS { + return Err("no such task"); + } + lock(); + let r = unsafe { + let t = &TASKS[id]; + if t.name_len == 0 || t.state == TaskState::Done { + Err("no such task") + } else if push_msg(id, msg) { + if TASKS[id].state == TaskState::Blocked { + TASKS[id].state = TaskState::Ready; + } + Ok(()) + } else { + Err("queue full") + } + }; + unlock(); + r +} + +/// Pop the oldest pending message of the current task, if any. +pub fn recv() -> Option { + lock(); + let m = unsafe { pop_msg(CURRENT) }; + unlock(); + m +} + +/// Block until a message arrives, then return it. Wake is two-fold: +/// `send` flips a Blocked task straight to Ready, and the tick scan +/// (see `on_tick`) catches the race where the task blocked after the +/// message was already queued. +pub fn recv_blocking() -> TaskMsg { + loop { + if let Some(m) = recv() { + return m; + } + lock(); + unsafe { + TASKS[CURRENT].state = TaskState::Blocked; + } + unlock(); + yield_int(); + } +} + +/// Push onto the task's ring (caller holds the scheduler lock). +unsafe fn push_msg(id: usize, msg: TaskMsg) -> bool { + unsafe { + let t = &mut TASKS[id]; + if t.msg_count as usize >= MSG_CAP { + return false; + } + t.msg[t.msg_tail as usize] = msg; + t.msg_tail = (t.msg_tail + 1) & (MSG_CAP as u8 - 1); + t.msg_count += 1; + true + } +} + +/// Pop from the current task's ring (caller holds the scheduler lock). +unsafe fn pop_msg(id: usize) -> Option { + unsafe { + let t = &mut TASKS[id]; + if t.msg_count == 0 { + return None; + } + let m = t.msg[t.msg_head as usize]; + t.msg_head = (t.msg_head + 1) & (MSG_CAP as u8 - 1); + t.msg_count -= 1; + Some(m) + } +} + +/// Pending message count of a task (for `jobs`). +pub fn pending_msgs(id: usize) -> usize { + lock(); + let n = unsafe { TASKS[id].msg_count as usize }; + unlock(); + n +} + +/// Is the task slot live (spawned and not Done)? Used by the shell's +/// `mouse ` routing check and by the window manager later. +pub fn is_alive(id: usize) -> bool { + if id >= MAX_TASKS { + return false; + } + lock(); + let alive = unsafe { TASKS[id].name_len != 0 && TASKS[id].state != TaskState::Done }; + unlock(); + alive +} + /// Software-interrupt the scheduler (vector 0x60). Net stack usage is /// zero (the frame is popped by iretq), so no `nostack` — the compiler /// must not use the red zone around the interrupt. @@ -418,11 +604,12 @@ pub fn jobs() -> Vec { } }; let mut line = alloc::format!( - " {:>2} {:<12} {:<15} {:>6} ticks", + " {:>2} {:<12} {:<15} {:>6} ticks q={}", i, name_str(i), state, - t.ticks + t.ticks, + t.msg_count ); if t.panic_len > 0 { let m = core::str::from_utf8(&t.panic[..t.panic_len as usize]).unwrap_or("?"); @@ -505,6 +692,10 @@ pub fn init() { ticks: 0, panic: [0; PANIC_MAX], panic_len: 0, + msg: [TaskMsg::User(0); MSG_CAP], + msg_head: 0, + msg_tail: 0, + msg_count: 0, }; spawn_at(IDLE_TID, idle_main, "idle"); SCHED_READY.store(true, Ordering::Relaxed); diff --git a/scripts/test-mouse.sh b/scripts/test-mouse.sh new file mode 100755 index 0000000..ed4df44 --- /dev/null +++ b/scripts/test-mouse.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# +# scripts/test-mouse.sh — automated PS/2 mouse round trip. +# +# Builds the kernel, boots QEMU headless, injects mouse movement and +# button presses through the monitor's `mouse_move` / `mouse_button` +# commands, and verifies the guest decoded them via IRQ12 → ring → +# packet decoder. Covers both surfaces: +# - `cat /dev/mouse` (VFS snapshot of pending events) +# - `mouse` (live streaming command, stopped by ^C) +# +# Usage: +# ./scripts/test-mouse.sh +# +# Environment: +# OVMF firmware (default: /usr/share/edk2/x64/OVMF.4m.fd) +# QEMU qemu binary (default: qemu-system-x86_64) +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_DIR" + +OVMF="${OVMF:-/usr/share/edk2/x64/OVMF.4m.fd}" +QEMU="${QEMU:-qemu-system-x86_64}" +ESP_DIR="$PROJECT_DIR/esp" +QPID=/tmp/rsdos-mouse-qpid +SOCK=/tmp/rsdos-mouse-qmon.sock +LOG=/tmp/rsdos-mouse-serial.log +QLOG=/tmp/rsdos-mouse-qemu.log + +# Never leave a QEMU behind. +trap 'kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true' EXIT + +fail() { + echo "FAIL: $1" + [ -f "$LOG" ] && tail -20 "$LOG" + exit 1 +} + +# wait_log FILE PATTERN TIMEOUT-SECONDS +wait_log() { + local i=0 + while [ "$i" -lt $((${3:-60} * 10)) ]; do + grep -q "$2" "$1" 2>/dev/null && return 0 + sleep 0.1 + i=$((i + 1)) + done + return 1 +} + +# wait_qemu_exit TIMEOUT-SECONDS +wait_qemu_exit() { + local i=0 + while [ "$i" -lt $((${1:-30} * 10)) ]; do + kill -0 "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || return 0 + sleep 0.1 + i=$((i + 1)) + done + return 1 +} + +# qemu_hmp — send one raw HMP command to the monitor socket. +qemu_hmp() { + python3 - "$SOCK" "$1" <<'EOF' +import socket, sys, time +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect(sys.argv[1]) +s.sendall((sys.argv[2] + "\n").encode()) +time.sleep(0.4) +EOF +} + +echo "==> 1/5 build kernel (debug)" +cargo build --package rsdos --target x86_64-unknown-uefi + +echo "==> 2/5 deploy to ESP" +mkdir -p "$ESP_DIR/EFI/BOOT" +cp target/x86_64-unknown-uefi/debug/rsdos.efi "$ESP_DIR/EFI/BOOT/BOOTX64.EFI" + +echo "==> 3/5 boot QEMU (headless)" +kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true +sleep 1 +rm -f "$SOCK" "$LOG" +"$QEMU" -bios "$OVMF" \ + -drive file=fat:rw:"$ESP_DIR",format=raw \ + -serial file:"$LOG" \ + -monitor unix:"$SOCK",server,nowait \ + -m 256M -machine q35 -cpu qemu64 -no-reboot -display none \ + >"$QLOG" 2>&1 & +echo $! >"$QPID" + +wait_log "$LOG" "boot: entering shell" 90 || fail "kernel never reached the shell" +grep -aq "ps2: mouse ready" "$LOG" || fail "mouse driver did not come up at boot" + +# Seed a move *before* any consumer runs, so `cat /dev/mouse` has a +# queued event to drain (exercises the VFS snapshot path). +qemu_hmp "mouse_move 8 4" + +echo "==> 4/5 drive the shell (cat /dev/mouse, mouse stream, poweroff)" +python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \ + "cat /dev/mouse" \ + "mouse" + +# While the `mouse` stream is live: a move, a left-press, a move with the +# button held, then release — the button state must ride along on the +# movement packet. +qemu_hmp "mouse_move 20 7" +qemu_hmp "mouse_button 1" +qemu_hmp "mouse_move -5 -3" +qemu_hmp "mouse_button 0" +qemu_hmp "sendkey ctrl-c" + +python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \ + "poweroff" + +echo "==> 5/5 wait for shutdown + verify" +wait_qemu_exit 30 || fail "QEMU did not exit after poweroff" + +grep -aq "ps2: mouse ready (IRQ12, stream mode)" "$LOG" || fail "no mouse-ready boot line" +grep -aq "dx=+8 dy=+4 buttons=---" "$LOG" || fail "/dev/mouse did not drain the queued move (dx=+8 dy=+4)" +grep -aq "dx=+20 dy=+7 buttons=---" "$LOG" || fail "mouse stream missed mouse_move 20 7" +grep -aq "dx=+0 dy=+0 buttons=L--" "$LOG" || fail "left-button press did not decode" +grep -aq "dx=-5 dy=-3 buttons=L--" "$LOG" || fail "held-button movement wrong (dx=-5 dy=-3 L)" +grep -aq "mouse: stopped" "$LOG" || fail "^C did not stop the mouse stream" + +echo "" +echo "PASS — PS/2 mouse round trip OK (IRQ12, /dev/mouse, live stream, buttons)" +echo " events: 8,4 · 20,7 · L-press · -5,-3 held · release" diff --git a/scripts/test-msg.sh b/scripts/test-msg.sh new file mode 100755 index 0000000..0bd1c81 --- /dev/null +++ b/scripts/test-msg.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# +# scripts/test-msg.sh — automated scheduler message-queue round trip. +# +# Builds the kernel, boots QEMU headless, and verifies inter-task +# messaging end to end: +# - `msgrecv` spawns a task that blocks on recv_blocking +# - `send ` delivers every message type in order +# - `mouse ` routes *real* IRQ12 mouse events into the queue +# (the window-manager input path) +# - queue-full is a clean error (16-cap; tested against the shell's +# own never-consumed queue) +# - jobs shows q=0 after everything drains, task back to blocked +# +# Usage: +# ./scripts/test-msg.sh +# +# Environment: +# OVMF firmware (default: /usr/share/edk2/x64/OVMF.4m.fd) +# QEMU qemu binary (default: qemu-system-x86_64) +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_DIR" + +OVMF="${OVMF:-/usr/share/edk2/x64/OVMF.4m.fd}" +QEMU="${QEMU:-qemu-system-x86_64}" +ESP_DIR="$PROJECT_DIR/esp" +QPID=/tmp/rsdos-msg-qpid +SOCK=/tmp/rsdos-msg-qmon.sock +LOG=/tmp/rsdos-msg-serial.log +QLOG=/tmp/rsdos-msg-qemu.log + +trap 'kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true' EXIT + +fail() { + echo "FAIL: $1" + [ -f "$LOG" ] && tail -20 "$LOG" + exit 1 +} + +wait_log() { + local i=0 + while [ "$i" -lt $((${3:-60} * 10)) ]; do + grep -q "$2" "$1" 2>/dev/null && return 0 + sleep 0.1 + i=$((i + 1)) + done + return 1 +} + +wait_qemu_exit() { + local i=0 + while [ "$i" -lt $((${1:-30} * 10)) ]; do + kill -0 "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || return 0 + sleep 0.1 + i=$((i + 1)) + done + return 1 +} + +qemu_hmp() { + python3 - "$SOCK" "$1" <<'EOF' +import socket, sys, time +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect(sys.argv[1]) +s.sendall((sys.argv[2] + "\n").encode()) +time.sleep(0.4) +EOF +} + +echo "==> 1/5 build kernel (debug)" +cargo build --package rsdos --target x86_64-unknown-uefi + +echo "==> 2/5 deploy to ESP" +mkdir -p "$ESP_DIR/EFI/BOOT" +cp target/x86_64-unknown-uefi/debug/rsdos.efi "$ESP_DIR/EFI/BOOT/BOOTX64.EFI" + +echo "==> 3/5 boot QEMU (headless)" +kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true +sleep 1 +rm -f "$SOCK" "$LOG" +"$QEMU" -bios "$OVMF" \ + -drive file=fat:rw:"$ESP_DIR",format=raw \ + -serial file:"$LOG" \ + -monitor unix:"$SOCK",server,nowait \ + -m 256M -machine q35 -cpu qemu64 -no-reboot -display none \ + >"$QLOG" 2>&1 & +echo $! >"$QPID" + +wait_log "$LOG" "boot: entering shell" 90 || fail "kernel never reached the shell" + +echo "==> 4/5 drive the shell (msgrecv / send / mouse routing / queue-full)" +python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \ + "msgrecv" \ + "send 2 user 42" \ + "send 2 timer" \ + "send 2 key enter" \ + "send 2 key ctrl-c" \ + "send 2 key f3" \ + "send 2 mouse 5 -3 3" \ + "sleep 800" \ + "jobs" + +# Real IRQ12 events through the queue: start routing to task 2, inject +# moves + a left press, stop. +python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" "mouse 2" +qemu_hmp "mouse_move 20 7" +qemu_hmp "mouse_button 1" +qemu_hmp "mouse_move -5 -3" +qemu_hmp "mouse_button 0" +qemu_hmp "sendkey ctrl-c" + +# Queue-full boundary: 16 sends fit, the 17th must fail cleanly. The +# shell never consumes its own queue, so nothing races the drain here. +python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \ + "send 0 user 1" \ + "send 0 user 2" \ + "send 0 user 3" \ + "send 0 user 4" \ + "send 0 user 5" \ + "send 0 user 6" \ + "send 0 user 7" \ + "send 0 user 8" \ + "send 0 user 9" \ + "send 0 user 10" \ + "send 0 user 11" \ + "send 0 user 12" \ + "send 0 user 13" \ + "send 0 user 14" \ + "send 0 user 15" \ + "send 0 user 16" \ + "send 0 user 17" \ + "sleep 800" \ + "jobs" \ + "poweroff" + +echo "==> 5/5 wait for shutdown + verify" +wait_qemu_exit 30 || fail "QEMU did not exit after poweroff" + +grep -aq "msgrecv: task 2 listening" "$LOG" || fail "msgrecv did not spawn" +grep -aq "msgrecv\[2\]: User(42)" "$LOG" || fail "user message missing" +grep -aq "msgrecv\[2\]: Timer" "$LOG" || fail "timer message missing" +grep -aq "msgrecv\[2\]: Key(Enter)" "$LOG" || fail "key enter missing" +grep -aq "msgrecv\[2\]: Key(Ctrl-c)" "$LOG" || fail "key ctrl-c missing" +grep -aq "msgrecv\[2\]: Key(F3)" "$LOG" || fail "key f3 missing" +grep -aq "msgrecv\[2\]: Mouse(+5,-3 L R)" "$LOG" || fail "synthesized mouse message missing" +# Real IRQ12 events routed through the queue (button held across the move). +grep -aq "msgrecv\[2\]: Mouse(+20,+7" "$LOG" || fail "real mouse move 20,7 not routed to queue" +grep -aq "msgrecv\[2\]: Mouse(-5,-3 L" "$LOG" || fail "held-button routed move missing" +grep -aq "queue full" "$LOG" || fail "17th send did not report queue full" +grep -aq "send: unknown type" "$LOG" && fail "a bad send slipped through (expected none)" + +echo "" +echo "PASS — message queues OK (all types, ordering, real-input routing, queue-full)" +echo " sent: user · timer · key(enter,ctrl-c,f3) · mouse · 16+1 queue-full" +echo " routed: mouse_move 20,7 → queue → msgrecv[2]" diff --git a/shell/src/lib.rs b/shell/src/lib.rs index 4640697..c18d319 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs @@ -703,6 +703,7 @@ write `{} TIL `", "date" => cmd_date(&parsed), "pci" => cmd_pci(&parsed), "rescan" => cmd_rescan(&parsed), + "mouse" => cmd_mouse(&parsed), "ls" => cmd_ls(&parsed), "cat" => cmd_cat(&parsed), "klog" => cmd_klog(&parsed), @@ -713,6 +714,8 @@ write `{} TIL `", "edit" => cmd_edit(&parsed), "bg" => cmd_bg(&parsed), "jobs" => cmd_jobs(&parsed), + "msgrecv" => cmd_msgrecv(&parsed), + "send" => cmd_send(&parsed), "sleep" => cmd_sleep(&parsed), "kill" => cmd_kill(&parsed), "demo" => cmd_demo(&parsed), @@ -738,8 +741,16 @@ fn cmd_help(_p: &Parsed) { date — show current date/time (RTC) pci — list PCI devices rescan — re-scan for hotplugged/removed disks (also runs automatically) + mouse — live mouse events (dx/dy/buttons); ^C stops + mouse — post decoded mouse events to task 's queue (input routing) bg — spawn a background task (spinner, ticker, counter, boom, overflow) - jobs — list tasks (id, name, state, ticks) + msgrecv — spawn a task that blocks on its message queue and logs every + message to serial (ids from `jobs`) + send [args] — post a message to a task's queue: + user · timer · mouse (1=L 2=R 4=M) + key (enter esc tab space bksp up down left right home + end pgup pgdn del ctrl-c f1..f12, or a single char) + jobs — list tasks (id, name, state, ticks, q=pending messages) sleep — block the shell for ms; background tasks keep running kill — stop a background task (id from jobs) demo — spawn spinner + ticker + counter @@ -1517,7 +1528,12 @@ fn rescan_all() -> (usize, usize, usize, usize, usize, usize) { fn report_rescan((da, dr, ma, mu, na, nr): (usize, usize, usize, usize, usize, usize)) -> bool { term_println(&alloc::format!( "rescan: {} disk(s) added, {} removed · {} volume(s) mounted, {} unmounted · {} node(s) added, {} removed", - da, dr, ma, mu, na, nr + da, + dr, + ma, + mu, + na, + nr )); da + dr + ma + mu + na + nr > 0 } @@ -1530,6 +1546,62 @@ fn cmd_rescan(_p: &Parsed) { } } +/// Live mouse event stream: print each decoded packet (dx/dy/buttons) as +/// it arrives until ^C. With an optional task id, events are **posted to +/// that task's message queue** instead of printed — the input-routing +/// path the window manager will use (`mouse ` + `msgrecv` proves the +/// whole chain: IRQ12 → ring → decoder → queue → blocked task). Polls +/// the PS/2 ring every 10 ms (via `sleep_ms`, which only blocks the +/// shell task) so background tasks keep running. +fn cmd_mouse(p: &Parsed) { + let target: Option = p.args.first().and_then(|s| s.parse::().ok()); + if let Some(id) = target { + if !rsnix::sched::is_alive(id) { + term_err(&alloc::format!("mouse: no such task {}", id)); + return; + } + term_println(&alloc::format!( + "mouse: posting events to task {} — ^C to stop", + id + )); + } else { + if !rsnix::ps2::mouse_present() { + term_err("mouse: no PS/2 mouse at boot — cat /dev/mouse still works"); + return; + } + term_println("mouse: streaming events — ^C to stop"); + } + loop { + if tty::interrupt_pressed() { + term_println("mouse: stopped"); + return; + } + while let Some(ev) = rsnix::ps2::try_poll_mouse() { + match target { + Some(id) => { + if let Err(e) = rsnix::sched::send(id, rsnix::sched::TaskMsg::Mouse(ev)) { + term_err(&alloc::format!("mouse: {}", e)); + return; + } + } + None => { + let mut b = String::new(); + b.push(if ev.left { 'L' } else { '-' }); + b.push(if ev.right { 'R' } else { '-' }); + b.push(if ev.middle { 'M' } else { '-' }); + term_println(&alloc::format!( + "dx={:+} dy={:+} buttons={}", + ev.dx, + ev.dy, + b + )); + } + } + } + rsnix::sched::sleep_ms(10); + } +} + // --------------------------------------------------------------------------- // Multitasking: bg / jobs / sleep / kill / demo // @@ -1577,6 +1649,141 @@ fn cmd_jobs(p: &Parsed) { pipe.end(); } +/// Demo of the scheduler message queues: spawn a task that blocks on +/// `recv_blocking` and logs every message it receives to serial. The +/// shell then posts to it with `send …` (id from `jobs`). +fn cmd_msgrecv(_p: &Parsed) { + match rsnix::sched::spawn(task_msgrecv, "msgrecv") { + Some(id) => term_println(&alloc::format!( + "msgrecv: task {} listening — post with: send {} …", + id, + id + )), + None => term_err("msgrecv: task table full"), + } +} + +/// The recv demo body: block forever, log each message. Headless by +/// design (serial only — it never touches tty/vt/fs, per ADR 0003). +fn task_msgrecv() { + loop { + let msg = rsnix::sched::recv_blocking(); + rsnix::serial_println!( + "msgrecv[{}]: {}", + rsnix::sched::current(), + rsnix::sched::msg_describe(&msg) + ); + } +} + +/// `send [args]` — post a TaskMsg to a task's queue. This +/// is the shell-side knob for the primitive the window manager will use +/// to route input; types: user , timer, mouse +/// (1=L 2=R 4=M), key (enter esc tab space bksp up down left +/// right home end pgup pgdn del ctrl-c f1..f12, or a single char). +fn cmd_send(p: &Parsed) { + let mut args = p.args.iter(); + let id: usize = match args.next().and_then(|s| s.parse().ok()) { + Some(v) => v, + None => { + term_err("send: needs a task id — jobs lists them, msgrecv spawns a listener"); + return; + } + }; + let kind = match args.next() { + Some(k) => k.as_str(), + None => { + term_err("send: needs a type — user | timer | mouse | key "); + return; + } + }; + let msg = match kind { + "user" => { + let Some(n) = args.next().and_then(|s| s.parse().ok()) else { + term_err("send: user needs a number"); + return; + }; + rsnix::sched::TaskMsg::User(n) + } + "timer" => rsnix::sched::TaskMsg::Timer, + "mouse" => { + let dx: i16 = args.next().and_then(|s| s.parse().ok()).unwrap_or(0); + let dy: i16 = args.next().and_then(|s| s.parse().ok()).unwrap_or(0); + let btn: u8 = args.next().and_then(|s| s.parse().ok()).unwrap_or(0); + rsnix::sched::TaskMsg::Mouse(rsnix::ps2::MouseEvent { + dx, + dy, + left: btn & 1 != 0, + right: btn & 2 != 0, + middle: btn & 4 != 0, + }) + } + "key" => match args.next() { + Some(k) => match key_from_name(k.as_str()) { + Some(ev) => rsnix::sched::TaskMsg::Key(ev), + None => { + term_err("send: unknown key name"); + return; + } + }, + None => { + term_err("send: key needs a name"); + return; + } + }, + _ => { + term_err("send: unknown type — user | timer | mouse | key "); + return; + } + }; + match rsnix::sched::send(id, msg) { + Ok(()) => {} + Err(e) => term_err(&alloc::format!("send: {}", e)), + } +} + +/// Map a small key-name vocabulary to a KeyEvent for `send key …`. +fn key_from_name(name: &str) -> Option { + use rsnix::ps2::KeyEvent; + Some(match name { + "enter" => KeyEvent::Enter, + "esc" | "escape" => KeyEvent::Escape, + "tab" => KeyEvent::Tab, + "space" => KeyEvent::Char(' '), + "bksp" | "backspace" => KeyEvent::Backspace, + "up" => KeyEvent::ArrowUp, + "down" => KeyEvent::ArrowDown, + "left" => KeyEvent::ArrowLeft, + "right" => KeyEvent::ArrowRight, + "home" => KeyEvent::Home, + "end" => KeyEvent::End, + "pgup" => KeyEvent::PageUp, + "pgdn" => KeyEvent::PageDown, + "del" | "delete" => KeyEvent::Delete, + "ctrl-c" => KeyEvent::Ctrl('c'), + "ctrl-d" => KeyEvent::Ctrl('d'), + "ctrl-s" => KeyEvent::Ctrl('s'), + "ctrl-x" => KeyEvent::Ctrl('x'), + _ => { + if let Some(n) = name.strip_prefix('f') { + if let Ok(n) = n.parse::() { + if (1..=12).contains(&n) { + KeyEvent::F(n) + } else { + return None; + } + } else { + return None; + } + } else if name.chars().count() == 1 { + KeyEvent::Char(name.chars().next().unwrap()) + } else { + return None; + } + } + }) +} + fn cmd_sleep(p: &Parsed) { let ms: u64 = match p.args.first().and_then(|s| s.parse().ok()) { Some(m) if m > 0 => m, -- cgit v1.2.3