summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-01 19:34:40 +0200
committersalmonik <salmonik@tuta.io>2026-08-01 19:34:40 +0200
commit9b9758e5ee2564b04685eadf2b9ce23dd1060b86 (patch)
tree57fce9e715510ec70df70dc399b4440f3480fd77
parentfix pi-lens warnings: unwraps, shell SC2046, dup code (fmtbuf/scripts/hexdump... (diff)
downloadrs-dos-9b9758e5ee2564b04685eadf2b9ce23dd1060b86.tar.gz
rs-dos-9b9758e5ee2564b04685eadf2b9ce23dd1060b86.zip
rsnix v0.4.0: GUI input foundation — PS/2 mouse + scheduler message queues
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 <id>` routes real IRQ12 events into a task's queue, jobs shows q=. `make test-msg` + `make test-disk` regression pass.
Diffstat (limited to '')
-rw-r--r--CONTEXT.md73
-rw-r--r--Cargo.lock6
-rw-r--r--Cargo.toml2
-rw-r--r--Makefile12
-rw-r--r--rsdos/src/main.rs5
-rw-r--r--rsnix/src/fs.rs38
-rw-r--r--rsnix/src/interrupts.rs68
-rw-r--r--rsnix/src/ps2.rs183
-rw-r--r--rsnix/src/sched.rs199
-rwxr-xr-xscripts/test-mouse.sh130
-rwxr-xr-xscripts/test-msg.sh159
-rw-r--r--shell/src/lib.rs211
12 files changed, 1061 insertions, 25 deletions
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):
113 113
114Bump the workspace version in the root `Cargo.toml` when a change of 114Bump the workspace version in the root `Cargo.toml` when a change of
115that class lands — do not leave the version to drift. Current: 115that class lands — do not leave the version to drift. Current:
116`0.2.0`; the multitasking work lands as a `b` bump (`0.3.0`). 116`0.4.0` — the GUI input foundation (PS/2 mouse + scheduler message
117queues) landed as a `b` bump; the GUI era itself is reserved for the
118`a` bump (1.0.0).
117 119
118## Project layout 120## Project layout
119 121
@@ -404,6 +406,75 @@ Workspace of four crates, all built for the `x86_64-unknown-uefi` target:
404 `bg overflow` dies on the guard page with the machine intact; full 406 `bg overflow` dies on the guard page with the machine intact; full
405 regression (rescan/til/klog/grammar/pager) re-passes. 407 regression (rescan/til/klog/grammar/pager) re-passes.
406 408
409✅ **PS/2 mouse** — auxiliary-channel driver on IRQ12, stream mode
410 (3-byte packets), the GUI's second input device:
411
412- `interrupts`: the keyboard byte ring is generalised to `ByteRing`;
413 IRQ12 (vector 44) gets its own ring and stub. Both PS/2 handlers
414 route each data-port byte by the controller status register's aux
415 bit (bit 5), so keyboard and mouse never steal each other's bytes
416 even when both are pending at once. PIC unmask now covers IRQ2
417 (cascade) and IRQ12 (slave bit 4).
418- `ps2`: `mouse_init()` enables the aux port, sets config bits
419 IRQ12+aux-clock (preserving the keyboard bit), then `set defaults`
420 (100 Hz / 4 counts/mm / 1:1) + `enable reporting` via the 0xD4
421 channel — each waits for the device ACK with a bounded timeout so
422 a machine without a mouse boots normally (log: `ps2: mouse not
423 present`). `try_poll_mouse()` reassembles packets in main
424 context from the raw byte ring; the packet-start marker (bit 3)
425 resyncs the stream after any lost byte. Events are **screen-
426 relative** (`dx` + = right, `dy` + = down — the PS/2 Y-up wire
427 convention is negated), buttons decoded; 9-bit sign extension via
428 the packet sign bits.
429- `fs`: `/dev/mouse` char device — `cat /dev/mouse` drains up to 32
430 pending events (`dx=+N dy=+N buttons=L-R`), reports
431 `mouse: no PS/2 mouse` / `mouse: no events` otherwise.
432- shell: `mouse` command streams live events (10 ms poll via
433 `sleep_ms`) until ^C.
434
435 Verified in QEMU (`make test-mouse`, headless, via the monitor's
436 `mouse_move`/`mouse_button` HMP commands): boot line `ps2: mouse
437 ready`, `/dev/mouse` drains a pre-consumer move (`8,4`), the live
438 stream decodes `mouse_move 20 7` → `dx=+20 dy=+7 buttons=---`,
439 `mouse_button 1` → `buttons=L--`, a held-button move
440 (`-5,-3` while L down) → `dx=-5 dy=-3 buttons=L--`, release,
441 and ^C stops the stream. `make test-disk` regression re-passes.
442
443✅ **Scheduler message queues (the GUI-era primitive from ADR 0003)** —
444 every task gets a static 16-slot ring of `TaskMsg` (4 KB in .bss,
445 no ISR-time allocation):
446
447- `TaskMsg` = `Key(KeyEvent)` | `Mouse(MouseEvent)` | `Timer` |
448 `User(u64)` — input uses the same decoded types the PS/2 drivers
449 produce, so window input routing is literally "post what the
450 console would have gotten"; `User(u64)` is the generic escape
451 hatch. `KeyEvent`/`MouseEvent` derive Copy for ring storage.
452- `sched::send(id, msg)` — posts from any context (task, shell, ISR)
453 under the scheduler lock; wakes a Blocked task straight to Ready;
454 errors cleanly on a gone task or a full queue (input is lossy
455 under extreme load by design). `recv()` pops without blocking;
456 `recv_blocking()` marks the task Blocked and yields. **Lost-wakeup
457 safety net**: the tick scan also wakes Blocked tasks whose queue is
458 non-empty, so a task that blocked *after* its message arrived (or
459 whose poster never woke it) is picked up within one 10 ms tick.
460- shell: `msgrecv` spawns a task that blocks on recv and logs every
461 message to serial (`msgrecv[2]: Key(F3)` …); `send <id> <type>
462 [args]` posts `user <n>` / `timer` / `mouse <dx> <dy> <btn>`
463 (1=L 2=R 4=M) / `key <name>` (enter esc tab space bksp arrows
464 home end pgup pgdn del ctrl-c f1..f12, or a single char); `jobs`
465 now shows `q=` (pending message count).
466- **Real-input routing, proven**: `mouse <id>` (angle brackets) streams
467 decoded IRQ12 events into that task's queue instead of the console
468 — the exact loop the window manager will run. `msgrecv` + `mouse 2`
469 - `mouse_move 20 7` showed `msgrecv[2]: Mouse(+20,+7 …)` in QEMU.
470
471 Verified in QEMU (`make test-msg`): all six message types delivered
472 in order (user, timer, key enter/ctrl-c/f3, synthesized mouse),
473 real routed mouse moves (including a held-button move), the 17th
474 send against a 16-cap queue reports `queue full` (tested against
475 the shell's never-consumed queue), and `jobs` shows q=0 + task back
476 to `blocked` after draining. `make test-disk` regression re-passes.
477
407## Known issues / perf notes 478## Known issues / perf notes
408 479
409- **Scroll cost** — scrolling still shifts the whole framebuffer (a 480- **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
4 4
5[[package]] 5[[package]]
6name = "rsdos" 6name = "rsdos"
7version = "0.3.0" 7version = "0.4.0"
8dependencies = [ 8dependencies = [
9 "rsnix", 9 "rsnix",
10 "shell", 10 "shell",
@@ -19,11 +19,11 @@ dependencies = [
19 19
20[[package]] 20[[package]]
21name = "rsnix" 21name = "rsnix"
22version = "0.3.0" 22version = "0.4.0"
23 23
24[[package]] 24[[package]]
25name = "shell" 25name = "shell"
26version = "0.3.0" 26version = "0.4.0"
27dependencies = [ 27dependencies = [
28 "rsdosedit", 28 "rsdosedit",
29 "rsnix", 29 "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"
3members = ["rsnix", "shell", "rsdos", "rsdosedit"] 3members = ["rsnix", "shell", "rsdos", "rsdosedit"]
4 4
5[workspace.package] 5[workspace.package]
6version = "0.3.0" 6version = "0.4.0"
7edition = "2024" 7edition = "2024"
8 8
9# no_std UEFI kernel: no unwinder available, so panic must abort. 9# 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
14# Create one with: make disk (or: ./scripts/mkdisk.sh) 14# Create one with: make disk (or: ./scripts/mkdisk.sh)
15export DISK_IMG ?= /tmp/disk.img 15export DISK_IMG ?= /tmp/disk.img
16 16
17.PHONY: all run run-gui clean image debug release fmt bootloader boot-image disk disk-fresh inspect test-disk 17.PHONY: all run run-gui clean image debug release fmt bootloader boot-image disk disk-fresh inspect test-disk test-mouse test-msg
18 18
19# ---- Build --------------------------------------------------------------- 19# ---- Build ---------------------------------------------------------------
20 20
@@ -113,6 +113,16 @@ inspect:
113test-disk: 113test-disk:
114 ./scripts/test-disk.sh 114 ./scripts/test-disk.sh
115 115
116# Automated PS/2 mouse round trip: boot -> inject mouse_move/mouse_button
117# via the monitor -> verify /dev/mouse + the `mouse` stream -> poweroff.
118test-mouse:
119 ./scripts/test-mouse.sh
120
121# Automated scheduler message-queue round trip: msgrecv/send, real-input
122# routing into a task's queue, queue-full boundary.
123test-msg:
124 ./scripts/test-msg.sh
125
116# ---- Full boot chain ----------------------------------------------------- 126# ---- Full boot chain -----------------------------------------------------
117 127
118# Build bootloader only (rsboot submodule) 128# 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
138 rsnix::serial_println!("serial: COM1 115200 8N1"); 138 rsnix::serial_println!("serial: COM1 115200 8N1");
139 rsnix::ps2::init(); 139 rsnix::ps2::init();
140 rsnix::serial_println!("ps2: keyboard initialized"); 140 rsnix::serial_println!("ps2: keyboard initialized");
141 if rsnix::ps2::mouse_init() {
142 rsnix::serial_println!("ps2: mouse ready (IRQ12, stream mode)");
143 } else {
144 rsnix::serial_println!("ps2: mouse not present");
145 }
141 if rsnix::gdt::init() { 146 if rsnix::gdt::init() {
142 rsnix::serial_println!("gdt: own GDT + TSS (IST: page-fault/double-fault)"); 147 rsnix::serial_println!("gdt: own GDT + TSS (IST: page-fault/double-fault)");
143 } else { 148 } 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 {
46 Zero, 46 Zero,
47 Console, 47 Console,
48 Serial, 48 Serial,
49 Mouse,
49} 50}
50 51
51enum NodeData { 52enum NodeData {
@@ -171,6 +172,12 @@ pub fn init() -> (usize, usize) {
171 FileType::Char, 172 FileType::Char,
172 NodeData::Char(CharDevice::Serial), 173 NodeData::Char(CharDevice::Serial),
173 ); 174 );
175 add(
176 "mouse",
177 Some(dev),
178 FileType::Char,
179 NodeData::Char(CharDevice::Mouse),
180 );
174 181
175 add( 182 add(
176 "motd", 183 "motd",
@@ -700,11 +707,42 @@ fn dev_text(dev: CharDevice, _pos: u64) -> String {
700 CharDevice::Version => version_text(), 707 CharDevice::Version => version_text(),
701 CharDevice::Meminfo => meminfo_text(), 708 CharDevice::Meminfo => meminfo_text(),
702 CharDevice::Klog => crate::klog::dump(), 709 CharDevice::Klog => crate::klog::dump(),
710 CharDevice::Mouse => mouse_text(),
703 CharDevice::Null | CharDevice::Console | CharDevice::Serial => String::new(), 711 CharDevice::Null | CharDevice::Console | CharDevice::Serial => String::new(),
704 CharDevice::Zero => String::new(), 712 CharDevice::Zero => String::new(),
705 } 713 }
706} 714}
707 715
716/// Render `/dev/mouse`: drain every pending PS/2 event (up to 32) into one
717/// line each, so `cat /dev/mouse` shows the movement that happened since
718/// the last read. Falls back to a clear message when there is no device or
719/// nothing pending.
720fn mouse_text() -> String {
721 if !crate::ps2::mouse_present() {
722 return String::from("mouse: no PS/2 mouse\n");
723 }
724 let mut s = String::new();
725 for _ in 0..32 {
726 let Some(ev) = crate::ps2::try_poll_mouse() else {
727 break;
728 };
729 let mut b = String::new();
730 b.push(if ev.left { 'L' } else { '-' });
731 b.push(if ev.right { 'R' } else { '-' });
732 b.push(if ev.middle { 'M' } else { '-' });
733 s.push_str(&alloc::format!(
734 "dx={:+} dy={:+} buttons={}\n",
735 ev.dx,
736 ev.dy,
737 b
738 ));
739 }
740 if s.is_empty() {
741 s.push_str("mouse: no events\n");
742 }
743 s
744}
745
708// --- self-test ------------------------------------------------------------ 746// --- self-test ------------------------------------------------------------
709 747
710pub fn self_test() -> bool { 748pub 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 {
153} 153}
154 154
155// --------------------------------------------------------------------------- 155// ---------------------------------------------------------------------------
156// Lock-free SPSC ring buffer for keyboard scancodes 156// Lock-free SPSC ring buffers for PS/2 input (keyboard scancodes + mouse
157// bytes). One per device: the ISR routes each data-port byte to the right
158// ring via the controller status register's aux bit (bit 5), so the two
159// devices never steal each other's bytes even when both are pending at once.
157// --------------------------------------------------------------------------- 160// ---------------------------------------------------------------------------
158 161
159/// Single-producer (ISR) / single-consumer (main) lock-free ring buffer. 162/// Single-producer (ISR) / single-consumer (main) lock-free ring buffer.
160struct KeyBuf { 163struct ByteRing {
161 buf: [core::cell::UnsafeCell<u8>; 256], 164 buf: [core::cell::UnsafeCell<u8>; 256],
162 head: AtomicU16, // next slot to read (written by consumer / main) 165 head: AtomicU16, // next slot to read (written by consumer / main)
163 tail: AtomicU16, // next slot to write (written by producer / ISR) 166 tail: AtomicU16, // next slot to write (written by producer / ISR)
164} 167}
165 168
166unsafe impl Sync for KeyBuf {} 169unsafe impl Sync for ByteRing {}
167 170
168impl KeyBuf { 171impl ByteRing {
169 const fn new() -> Self { 172 const fn new() -> Self {
170 // Inline-const array repeat (stable since 1.79): each element is a 173 // Inline-const array repeat (stable since 1.79): each element is a
171 // fresh `UnsafeCell::new(0)` value, so no interior-mutable const. 174 // fresh `UnsafeCell::new(0)` value, so no interior-mutable const.
@@ -205,7 +208,8 @@ impl KeyBuf {
205 } 208 }
206} 209}
207 210
208static KEYBUF: KeyBuf = KeyBuf::new(); 211static KEYBUF: ByteRing = ByteRing::new();
212static MOUSEBUF: ByteRing = ByteRing::new();
209 213
210// --------------------------------------------------------------------------- 214// ---------------------------------------------------------------------------
211// Interrupt stubs (assembly) and dispatch 215// Interrupt stubs (assembly) and dispatch
@@ -240,6 +244,14 @@ core::arch::global_asm!(
240 " push 33", 244 " push 33",
241 " jmp isr_common", 245 " jmp isr_common",
242 "", 246 "",
247 // PS/2 mouse IRQ (IRQ12 → vector 44).
248 ".globl isr_stub_44",
249 ".p2align 4",
250 "isr_stub_44:",
251 " push 0",
252 " push 44",
253 " jmp isr_common",
254 "",
243 // Software yield (vector 0x60 — outside the PIC's 32-47 range, which 255 // Software yield (vector 0x60 — outside the PIC's 32-47 range, which
244 // is why 0x40 was rejected: 0x40 = 64 decimal = slave IRQ 8 slot). 256 // is why 0x40 was rejected: 0x40 = 64 decimal = slave IRQ 8 slot).
245 ".globl isr_stub_60", 257 ".globl isr_stub_60",
@@ -345,13 +357,27 @@ unsafe extern "C" fn isr_dispatch(frame: &IntFrame) -> usize {
345 eoi(0); 357 eoi(0);
346 crate::sched::on_tick(fp) 358 crate::sched::on_tick(fp)
347 } 359 }
348 33 => { 360 33 | 44 => {
349 let byte: u8; 361 // PS/2 data byte. The controller's status register tells us
350 core::arch::asm!("in al, dx", out("al") byte, in("dx") 0x60u16, 362 // which device it came from (bit 5 = auxiliary/mouse), so the
363 // byte is routed to the matching ring — both handlers must
364 // check, because when both devices have data pending the
365 // first IRQ serviced can find the other device's byte in the
366 // output buffer.
367 let status: u8;
368 core::arch::asm!("in al, dx", out("al") status, in("dx") 0x64u16,
351 options(nostack, preserves_flags)); 369 options(nostack, preserves_flags));
352 // Push scancode to ring buffer (called with interrupts disabled). 370 if status & 0x01 != 0 {
353 KEYBUF.push(byte); 371 let byte: u8;
354 eoi(1); 372 core::arch::asm!("in al, dx", out("al") byte, in("dx") 0x60u16,
373 options(nostack, preserves_flags));
374 if status & 0x20 != 0 {
375 MOUSEBUF.push(byte);
376 } else {
377 KEYBUF.push(byte);
378 }
379 }
380 eoi((frame.vector - 32) as u8);
355 fp 381 fp
356 } 382 }
357 8 => { 383 8 => {
@@ -381,6 +407,12 @@ pub fn read_scancode() -> Option<u8> {
381 KEYBUF.pop() 407 KEYBUF.pop()
382} 408}
383 409
410/// Read one mouse data byte from the interrupt-driven ring buffer
411/// (non-blocking). Packets are assembled in `ps2::try_poll_mouse`.
412pub fn read_mouse_byte() -> Option<u8> {
413 MOUSEBUF.pop()
414}
415
384/// Global tick counter (incremented by timer IRQ, ~100 Hz). u64 so 416/// Global tick counter (incremented by timer IRQ, ~100 Hz). u64 so
385/// uptime never wraps; `uptime_ms` interpolates the PIT counter for the 417/// uptime never wraps; `uptime_ms` interpolates the PIT counter for the
386/// sub-tick fraction. 418/// sub-tick fraction.
@@ -406,6 +438,8 @@ pub fn init() {
406 // Override vectors for interrupt we actually handle. 438 // Override vectors for interrupt we actually handle.
407 idt_set_entry(idt_ptr, 32, isr_stub_32 as *const () as u64, cs, 0x0Eu8, 0); 439 idt_set_entry(idt_ptr, 32, isr_stub_32 as *const () as u64, cs, 0x0Eu8, 0);
408 idt_set_entry(idt_ptr, 33, isr_stub_33 as *const () as u64, cs, 0x0Eu8, 0); 440 idt_set_entry(idt_ptr, 33, isr_stub_33 as *const () as u64, cs, 0x0Eu8, 0);
441 // PS/2 mouse (IRQ12).
442 idt_set_entry(idt_ptr, 44, isr_stub_44 as *const () as u64, cs, 0x0Eu8, 0);
409 // Yield (software interrupt). 443 // Yield (software interrupt).
410 idt_set_entry( 444 idt_set_entry(
411 idt_ptr, 445 idt_ptr,
@@ -439,13 +473,20 @@ pub fn init() {
439 pit_set_rate(); 473 pit_set_rate();
440 PIT_READY.store(true, Ordering::Relaxed); 474 PIT_READY.store(true, Ordering::Relaxed);
441 475
442 // Unmask timer (IRQ0 → bit 0) and keyboard (IRQ1 → bit 1) on master. 476 // Unmask timer (IRQ0), keyboard (IRQ1) and the cascade line (IRQ2)
477 // on the master; unmask IRQ12 (PS/2 mouse) on the slave.
443 let mut mask: u8; 478 let mut mask: u8;
444 asm!("in al, dx", out("al") mask, in("dx") PIC1_DATA, 479 asm!("in al, dx", out("al") mask, in("dx") PIC1_DATA,
445 options(nostack, preserves_flags)); 480 options(nostack, preserves_flags));
446 mask &= !0x03u8; 481 mask &= !0x07u8;
447 asm!("out dx, al", in("dx") PIC1_DATA, in("al") mask, 482 asm!("out dx, al", in("dx") PIC1_DATA, in("al") mask,
448 options(nostack, preserves_flags)); 483 options(nostack, preserves_flags));
484 let mut mask2: u8;
485 asm!("in al, dx", out("al") mask2, in("dx") PIC2_DATA,
486 options(nostack, preserves_flags));
487 mask2 &= !0x10u8; // IRQ12 → bit 4 of the slave PIC
488 asm!("out dx, al", in("dx") PIC2_DATA, in("al") mask2,
489 options(nostack, preserves_flags));
449 490
450 // Enable interrupts. 491 // Enable interrupts.
451 asm!("sti", options(nostack)); 492 asm!("sti", options(nostack));
@@ -469,6 +510,7 @@ unsafe extern "C" {
469 fn isr_stub_14(); 510 fn isr_stub_14();
470 fn isr_stub_32(); 511 fn isr_stub_32();
471 fn isr_stub_33(); 512 fn isr_stub_33();
513 fn isr_stub_44();
472 fn isr_stub_60(); 514 fn isr_stub_60();
473 fn isr_stub_spurious(); 515 fn isr_stub_spurious();
474} 516}
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;
16const MOD_CAPS: u8 = 0x40; 16const MOD_CAPS: u8 = 0x40;
17 17
18/// A decoded key event. 18/// A decoded key event.
19#[derive(Clone, Copy, Debug)]
19pub enum KeyEvent { 20pub enum KeyEvent {
20 Char(char), 21 Char(char),
21 Enter, 22 Enter,
@@ -400,3 +401,185 @@ fn ctrl_held() -> bool {
400 let state = kbd(); 401 let state = kbd();
401 state.modifiers & (MOD_LCTRL | MOD_RCTRL) != 0 402 state.modifiers & (MOD_LCTRL | MOD_RCTRL) != 0
402} 403}
404
405// ---------------------------------------------------------------------------
406// PS/2 mouse — auxiliary channel, IRQ12, stream mode (3-byte packets)
407// ---------------------------------------------------------------------------
408
409// PS/2 controller commands (written to port 0x64).
410const CMD_AUX_ENABLE: u8 = 0xA8; // enable the auxiliary (second) port
411const CMD_READ_CFG: u8 = 0x20; // read Configuration Byte → port 0x60
412const CMD_WRITE_CFG: u8 = 0x60; // write Configuration Byte ← port 0x60
413const CMD_SEND_TO_AUX: u8 = 0xD4; // next byte on 0x60 goes to the mouse
414
415// Configuration Byte bits (as sent via CMD_READ_CFG / CMD_WRITE_CFG).
416// Bit 0 (IRQ1/keyboard) is set by `init`; this driver preserves it by
417// reading the byte and only ORing in the two mouse bits below.
418const CFG_AUX_IRQ: u8 = 0x02; // bit 1 — IRQ12 (mouse)
419const CFG_AUX_CLOCK: u8 = 0x20; // bit 5 — auxiliary port clock enable
420
421// Mouse device commands (sent via CMD_SEND_TO_AUX).
422const AUX_SET_DEFAULTS: u8 = 0xF6; // 100 Hz sample, 4 counts/mm, 1:1
423const AUX_ENABLE: u8 = 0xF4; // start data reporting (stream mode)
424const AUX_ACK: u8 = 0xFA;
425
426/// A decoded PS/2 mouse event (one 3-byte packet).
427///
428/// Movement is **screen-relative**: `dx` positive = right, `dy` positive =
429/// down. (The PS/2 wire format is Y-up-positive; the driver negates it so
430/// consumers can apply the deltas to framebuffer coordinates directly.)
431#[derive(Clone, Copy, Debug)]
432pub struct MouseEvent {
433 pub dx: i16,
434 pub dy: i16,
435 pub left: bool,
436 pub right: bool,
437 pub middle: bool,
438}
439
440/// Packet assembly state — lives in main context (consumer side), fed by
441/// the raw byte ring that the IRQ12 handler fills.
442struct MouseState {
443 phase: u8, // 0 = awaiting start byte, 1 = await X, 2 = await Y
444 pkt: [u8; 3],
445}
446
447static mut MOUSE: MouseState = MouseState {
448 phase: 0,
449 pkt: [0; 3],
450};
451static MOUSE_PRESENT: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
452
453/// True once `mouse_init` found a device that ACKed its setup commands.
454pub fn mouse_present() -> bool {
455 MOUSE_PRESENT.load(core::sync::atomic::Ordering::Relaxed)
456}
457
458/// Enable the auxiliary (mouse) channel and put the device into stream
459/// mode. Returns false when no device ACKs (controller without a second
460/// port, or a missing mouse) — the kernel then just runs keyboard-only.
461///
462/// Must run *before* `interrupts::init` unmasks IRQ12: the init handshake
463/// reads the data port directly, so any stray mouse bytes would otherwise
464/// be split across the ISR path mid-handshake.
465pub fn mouse_init() -> bool {
466 drain_input();
467 unsafe {
468 // 1. Enable the auxiliary port itself.
469 wait_write();
470 core::arch::asm!("out dx, al", in("dx") STATUS_PORT, in("al") CMD_AUX_ENABLE,
471 options(nostack, preserves_flags));
472
473 // 2. Configuration Byte: enable IRQ12 + the aux port clock. Read,
474 // OR the two bits, write back (preserves the keyboard IRQ bit
475 // that ps2::init set).
476 wait_write();
477 core::arch::asm!("out dx, al", in("dx") STATUS_PORT, in("al") CMD_READ_CFG,
478 options(nostack, preserves_flags));
479 wait_read();
480 let mut cfg: u8;
481 core::arch::asm!("in al, dx", out("al") cfg, in("dx") DATA_PORT,
482 options(nostack, preserves_flags));
483 cfg |= CFG_AUX_IRQ | CFG_AUX_CLOCK;
484 wait_write();
485 core::arch::asm!("out dx, al", in("dx") STATUS_PORT, in("al") CMD_WRITE_CFG,
486 options(nostack, preserves_flags));
487 wait_write();
488 core::arch::asm!("out dx, al", in("dx") DATA_PORT, in("al") cfg,
489 options(nostack, preserves_flags));
490
491 // 3. Device setup. `set defaults` normalises the sample rate /
492 // resolution before enabling reporting, so the packet stream
493 // starts at a known cadence. Both commands need a device ACK;
494 // a timeout here means no mouse — bail out cleanly.
495 if !aux_command(AUX_SET_DEFAULTS) || !aux_command(AUX_ENABLE) {
496 return false;
497 }
498 }
499 MOUSE_PRESENT.store(true, core::sync::atomic::Ordering::Relaxed);
500 true
501}
502
503/// Send one byte to the mouse via the 0xD4 channel and wait for its ACK.
504/// The wait is bounded so a missing device cannot hang the boot.
505fn aux_command(cmd: u8) -> bool {
506 unsafe {
507 wait_write();
508 core::arch::asm!("out dx, al", in("dx") STATUS_PORT, in("al") CMD_SEND_TO_AUX,
509 options(nostack, preserves_flags));
510 wait_write();
511 core::arch::asm!("out dx, al", in("dx") DATA_PORT, in("al") cmd,
512 options(nostack, preserves_flags));
513 for _ in 0..100_000 {
514 let status: u8;
515 core::arch::asm!("in al, dx", out("al") status, in("dx") STATUS_PORT,
516 options(nostack, preserves_flags));
517 if status & STAT_OUTPUT_FULL != 0 {
518 let b: u8;
519 core::arch::asm!("in al, dx", out("al") b, in("dx") DATA_PORT,
520 options(nostack, preserves_flags));
521 return b == AUX_ACK;
522 }
523 }
524 }
525 false
526}
527
528/// Non-blocking: decode the next pending mouse event, if a full packet has
529/// arrived. Raw bytes are drained from the IRQ ring; incomplete packets
530/// stay buffered in `MouseState` until the remaining bytes land.
531pub fn try_poll_mouse() -> Option<MouseEvent> {
532 while let Some(b) = crate::interrupts::read_mouse_byte() {
533 if let Some(ev) = mouse_byte(b) {
534 return Some(ev);
535 }
536 }
537 None
538}
539
540/// Feed one raw byte into the packet assembler. Byte 0 of every standard
541/// PS/2 mouse packet has bit 3 set (the sync marker), so a lost byte (ring
542/// overrun, init response) is detected on the next packet and the stream
543/// re-syncs — the marker is only accepted as a packet start.
544fn mouse_byte(b: u8) -> Option<MouseEvent> {
545 let m = unsafe { &mut *core::ptr::addr_of_mut!(MOUSE) };
546 match m.phase {
547 0 => {
548 if b & 0x08 != 0 {
549 m.pkt[0] = b;
550 m.phase = 1;
551 }
552 None
553 }
554 1 => {
555 m.pkt[1] = b;
556 m.phase = 2;
557 None
558 }
559 _ => {
560 m.pkt[2] = b;
561 m.phase = 0;
562 Some(MouseEvent {
563 // 9-bit signed: low 8 bits in the data byte, sign bit in
564 // byte 0 (bit 4 = X, bit 5 = Y).
565 dx: signed9(m.pkt[0], m.pkt[1], 0x10),
566 // PS/2 reports Y positive = up; flip to screen convention.
567 dy: -signed9(m.pkt[0], m.pkt[2], 0x20),
568 left: m.pkt[0] & 0x01 != 0,
569 right: m.pkt[0] & 0x02 != 0,
570 middle: m.pkt[0] & 0x04 != 0,
571 })
572 }
573 }
574}
575
576/// Sign-extend an 8-bit movement byte to 9 bits using the packet's sign
577/// flag (bit `sign` of byte 0), widening to i16 for consumers.
578fn signed9(b0: u8, b: u8, sign: u8) -> i16 {
579 let mut v = (b as i8) as i16;
580 if b0 & sign != 0 {
581 // Set the 9th bit (0xFF00 = sign-extension mask for i16).
582 v |= 0xFF00u16 as i16;
583 }
584 v
585}
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
42const NAME_MAX: usize = 24; 42const NAME_MAX: usize = 24;
43const PANIC_MAX: usize = 64; 43const PANIC_MAX: usize = 64;
44 44
45// ---------------------------------------------------------------------------
46// Inter-task message queues
47// ---------------------------------------------------------------------------
48
49/// Capacity of each task's message ring (power of two; full sends drop
50/// with a "queue full" error). Sized so input bursts (a 100 Hz mouse,
51/// key repeat) far outrun a blocked consumer: 16 × ~1.6 s at worst.
52const MSG_CAP: usize = 16;
53
54/// One inter-task message. Input events use the same decoded types the
55/// PS/2 drivers produce, so a window's input routing is "post what the
56/// driver would have delivered to the console". `User(u64)` is the
57/// generic escape hatch (window commands, app signals, …).
58#[derive(Clone, Copy, Debug)]
59pub enum TaskMsg {
60 Key(crate::ps2::KeyEvent),
61 Mouse(crate::ps2::MouseEvent),
62 Timer,
63 User(u64),
64}
65
66/// Decode a message for logging/display (no Debug dep in the shell task).
67pub fn msg_describe(msg: &TaskMsg) -> alloc::string::String {
68 use crate::ps2::KeyEvent;
69 match msg {
70 TaskMsg::Key(k) => match k {
71 KeyEvent::Char(c) => alloc::format!("Key('{}')", c),
72 KeyEvent::Enter => "Key(Enter)".into(),
73 KeyEvent::Backspace => "Key(Backspace)".into(),
74 KeyEvent::Tab => "Key(Tab)".into(),
75 KeyEvent::Escape => "Key(Escape)".into(),
76 KeyEvent::Ctrl(c) => alloc::format!("Key(Ctrl-{})", c),
77 KeyEvent::CtrlArrow(d) => alloc::format!("Key(CtrlArrow{})", d),
78 KeyEvent::ArrowUp => "Key(Up)".into(),
79 KeyEvent::ArrowDown => "Key(Down)".into(),
80 KeyEvent::ArrowLeft => "Key(Left)".into(),
81 KeyEvent::ArrowRight => "Key(Right)".into(),
82 KeyEvent::Home => "Key(Home)".into(),
83 KeyEvent::End => "Key(End)".into(),
84 KeyEvent::PageUp => "Key(PgUp)".into(),
85 KeyEvent::PageDown => "Key(PgDn)".into(),
86 KeyEvent::Delete => "Key(Delete)".into(),
87 KeyEvent::F(n) => alloc::format!("Key(F{})", n),
88 KeyEvent::Unknown(b) => alloc::format!("Key(0x{:02x})", b),
89 },
90 TaskMsg::Mouse(m) => alloc::format!(
91 "Mouse({:+},{:+} {} {})",
92 m.dx,
93 m.dy,
94 if m.left { "L" } else { "-" },
95 if m.right { "R" } else { "-" }
96 ),
97 TaskMsg::Timer => "Timer".into(),
98 TaskMsg::User(n) => alloc::format!("User({})", n),
99 }
100}
101
45#[derive(Clone, Copy, PartialEq, Eq, Debug)] 102#[derive(Clone, Copy, PartialEq, Eq, Debug)]
46pub enum TaskState { 103pub enum TaskState {
47 Ready, 104 Ready,
@@ -65,6 +122,13 @@ struct Task {
65 ticks: u64, 122 ticks: u64,
66 panic: [u8; PANIC_MAX], 123 panic: [u8; PANIC_MAX],
67 panic_len: u8, 124 panic_len: u8,
125 /// Per-task message ring (see `TaskMsg`). Accessed by the owning
126 /// task (pop) and by any poster (push) — both under the scheduler
127 /// lock, so `send` is callable from ISR or task context.
128 msg: [TaskMsg; MSG_CAP],
129 msg_head: u8,
130 msg_tail: u8,
131 msg_count: u8,
68} 132}
69 133
70const TASK_NONE: Task = Task { 134const TASK_NONE: Task = Task {
@@ -78,6 +142,10 @@ const TASK_NONE: Task = Task {
78 ticks: 0, 142 ticks: 0,
79 panic: [0; PANIC_MAX], 143 panic: [0; PANIC_MAX],
80 panic_len: 0, 144 panic_len: 0,
145 msg: [TaskMsg::User(0); MSG_CAP],
146 msg_head: 0,
147 msg_tail: 0,
148 msg_count: 0,
81}; 149};
82 150
83static mut TASKS: [Task; MAX_TASKS] = [TASK_NONE; MAX_TASKS]; 151static mut TASKS: [Task; MAX_TASKS] = [TASK_NONE; MAX_TASKS];
@@ -133,8 +201,18 @@ pub fn on_tick(cur: usize) -> usize {
133 TASKS[CURRENT].ticks += 1; 201 TASKS[CURRENT].ticks += 1;
134 let now = crate::interrupts::uptime_ms(); 202 let now = crate::interrupts::uptime_ms();
135 for t in 0..MAX_TASKS { 203 for t in 0..MAX_TASKS {
136 if TASKS[t].state == TaskState::Sleeping && TASKS[t].wake_at <= now { 204 match TASKS[t].state {
137 TASKS[t].state = TaskState::Ready; 205 TaskState::Sleeping if TASKS[t].wake_at <= now => {
206 TASKS[t].state = TaskState::Ready;
207 }
208 // Safety net for the recv race: a task that blocked
209 // *after* its message was queued (or whose poster never
210 // got to wake it) wakes on the next tick as long as its
211 // queue is non-empty.
212 TaskState::Blocked if TASKS[t].msg_count > 0 => {
213 TASKS[t].state = TaskState::Ready;
214 }
215 _ => {}
138 } 216 }
139 } 217 }
140 pick_next(cur) 218 pick_next(cur)
@@ -279,6 +357,10 @@ fn spawn_at(id: usize, entry: fn(), name: &str) -> bool {
279 ticks: 0, 357 ticks: 0,
280 panic: [0; PANIC_MAX], 358 panic: [0; PANIC_MAX],
281 panic_len: 0, 359 panic_len: 0,
360 msg: [TaskMsg::User(0); MSG_CAP],
361 msg_head: 0,
362 msg_tail: 0,
363 msg_count: 0,
282 }; 364 };
283 } 365 }
284 true 366 true
@@ -319,6 +401,110 @@ pub fn yield_now() {
319 yield_int(); 401 yield_int();
320} 402}
321 403
404// ---------------------------------------------------------------------------
405// Message passing
406// ---------------------------------------------------------------------------
407
408/// Post a message to a task's queue and wake it. Safe from any context:
409/// a task (e.g. a window manager routing input), the shell, or an ISR —
410/// the scheduler lock makes the queue mutation atomic w.r.t. switching.
411/// Returns an error when the task is gone or its queue is full.
412pub fn send(id: usize, msg: TaskMsg) -> Result<(), &'static str> {
413 if id >= MAX_TASKS {
414 return Err("no such task");
415 }
416 lock();
417 let r = unsafe {
418 let t = &TASKS[id];
419 if t.name_len == 0 || t.state == TaskState::Done {
420 Err("no such task")
421 } else if push_msg(id, msg) {
422 if TASKS[id].state == TaskState::Blocked {
423 TASKS[id].state = TaskState::Ready;
424 }
425 Ok(())
426 } else {
427 Err("queue full")
428 }
429 };
430 unlock();
431 r
432}
433
434/// Pop the oldest pending message of the current task, if any.
435pub fn recv() -> Option<TaskMsg> {
436 lock();
437 let m = unsafe { pop_msg(CURRENT) };
438 unlock();
439 m
440}
441
442/// Block until a message arrives, then return it. Wake is two-fold:
443/// `send` flips a Blocked task straight to Ready, and the tick scan
444/// (see `on_tick`) catches the race where the task blocked after the
445/// message was already queued.
446pub fn recv_blocking() -> TaskMsg {
447 loop {
448 if let Some(m) = recv() {
449 return m;
450 }
451 lock();
452 unsafe {
453 TASKS[CURRENT].state = TaskState::Blocked;
454 }
455 unlock();
456 yield_int();
457 }
458}
459
460/// Push onto the task's ring (caller holds the scheduler lock).
461unsafe fn push_msg(id: usize, msg: TaskMsg) -> bool {
462 unsafe {
463 let t = &mut TASKS[id];
464 if t.msg_count as usize >= MSG_CAP {
465 return false;
466 }
467 t.msg[t.msg_tail as usize] = msg;
468 t.msg_tail = (t.msg_tail + 1) & (MSG_CAP as u8 - 1);
469 t.msg_count += 1;
470 true
471 }
472}
473
474/// Pop from the current task's ring (caller holds the scheduler lock).
475unsafe fn pop_msg(id: usize) -> Option<TaskMsg> {
476 unsafe {
477 let t = &mut TASKS[id];
478 if t.msg_count == 0 {
479 return None;
480 }
481 let m = t.msg[t.msg_head as usize];
482 t.msg_head = (t.msg_head + 1) & (MSG_CAP as u8 - 1);
483 t.msg_count -= 1;
484 Some(m)
485 }
486}
487
488/// Pending message count of a task (for `jobs`).
489pub fn pending_msgs(id: usize) -> usize {
490 lock();
491 let n = unsafe { TASKS[id].msg_count as usize };
492 unlock();
493 n
494}
495
496/// Is the task slot live (spawned and not Done)? Used by the shell's
497/// `mouse <id>` routing check and by the window manager later.
498pub fn is_alive(id: usize) -> bool {
499 if id >= MAX_TASKS {
500 return false;
501 }
502 lock();
503 let alive = unsafe { TASKS[id].name_len != 0 && TASKS[id].state != TaskState::Done };
504 unlock();
505 alive
506}
507
322/// Software-interrupt the scheduler (vector 0x60). Net stack usage is 508/// Software-interrupt the scheduler (vector 0x60). Net stack usage is
323/// zero (the frame is popped by iretq), so no `nostack` — the compiler 509/// zero (the frame is popped by iretq), so no `nostack` — the compiler
324/// must not use the red zone around the interrupt. 510/// must not use the red zone around the interrupt.
@@ -418,11 +604,12 @@ pub fn jobs() -> Vec<String> {
418 } 604 }
419 }; 605 };
420 let mut line = alloc::format!( 606 let mut line = alloc::format!(
421 " {:>2} {:<12} {:<15} {:>6} ticks", 607 " {:>2} {:<12} {:<15} {:>6} ticks q={}",
422 i, 608 i,
423 name_str(i), 609 name_str(i),
424 state, 610 state,
425 t.ticks 611 t.ticks,
612 t.msg_count
426 ); 613 );
427 if t.panic_len > 0 { 614 if t.panic_len > 0 {
428 let m = core::str::from_utf8(&t.panic[..t.panic_len as usize]).unwrap_or("?"); 615 let m = core::str::from_utf8(&t.panic[..t.panic_len as usize]).unwrap_or("?");
@@ -505,6 +692,10 @@ pub fn init() {
505 ticks: 0, 692 ticks: 0,
506 panic: [0; PANIC_MAX], 693 panic: [0; PANIC_MAX],
507 panic_len: 0, 694 panic_len: 0,
695 msg: [TaskMsg::User(0); MSG_CAP],
696 msg_head: 0,
697 msg_tail: 0,
698 msg_count: 0,
508 }; 699 };
509 spawn_at(IDLE_TID, idle_main, "idle"); 700 spawn_at(IDLE_TID, idle_main, "idle");
510 SCHED_READY.store(true, Ordering::Relaxed); 701 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 @@
1#!/usr/bin/env bash
2#
3# scripts/test-mouse.sh — automated PS/2 mouse round trip.
4#
5# Builds the kernel, boots QEMU headless, injects mouse movement and
6# button presses through the monitor's `mouse_move` / `mouse_button`
7# commands, and verifies the guest decoded them via IRQ12 → ring →
8# packet decoder. Covers both surfaces:
9# - `cat /dev/mouse` (VFS snapshot of pending events)
10# - `mouse` (live streaming command, stopped by ^C)
11#
12# Usage:
13# ./scripts/test-mouse.sh
14#
15# Environment:
16# OVMF firmware (default: /usr/share/edk2/x64/OVMF.4m.fd)
17# QEMU qemu binary (default: qemu-system-x86_64)
18#
19set -euo pipefail
20
21SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
22PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
23cd "$PROJECT_DIR"
24
25OVMF="${OVMF:-/usr/share/edk2/x64/OVMF.4m.fd}"
26QEMU="${QEMU:-qemu-system-x86_64}"
27ESP_DIR="$PROJECT_DIR/esp"
28QPID=/tmp/rsdos-mouse-qpid
29SOCK=/tmp/rsdos-mouse-qmon.sock
30LOG=/tmp/rsdos-mouse-serial.log
31QLOG=/tmp/rsdos-mouse-qemu.log
32
33# Never leave a QEMU behind.
34trap 'kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true' EXIT
35
36fail() {
37 echo "FAIL: $1"
38 [ -f "$LOG" ] && tail -20 "$LOG"
39 exit 1
40}
41
42# wait_log FILE PATTERN TIMEOUT-SECONDS
43wait_log() {
44 local i=0
45 while [ "$i" -lt $((${3:-60} * 10)) ]; do
46 grep -q "$2" "$1" 2>/dev/null && return 0
47 sleep 0.1
48 i=$((i + 1))
49 done
50 return 1
51}
52
53# wait_qemu_exit TIMEOUT-SECONDS
54wait_qemu_exit() {
55 local i=0
56 while [ "$i" -lt $((${1:-30} * 10)) ]; do
57 kill -0 "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || return 0
58 sleep 0.1
59 i=$((i + 1))
60 done
61 return 1
62}
63
64# qemu_hmp — send one raw HMP command to the monitor socket.
65qemu_hmp() {
66 python3 - "$SOCK" "$1" <<'EOF'
67import socket, sys, time
68s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
69s.connect(sys.argv[1])
70s.sendall((sys.argv[2] + "\n").encode())
71time.sleep(0.4)
72EOF
73}
74
75echo "==> 1/5 build kernel (debug)"
76cargo build --package rsdos --target x86_64-unknown-uefi
77
78echo "==> 2/5 deploy to ESP"
79mkdir -p "$ESP_DIR/EFI/BOOT"
80cp target/x86_64-unknown-uefi/debug/rsdos.efi "$ESP_DIR/EFI/BOOT/BOOTX64.EFI"
81
82echo "==> 3/5 boot QEMU (headless)"
83kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true
84sleep 1
85rm -f "$SOCK" "$LOG"
86"$QEMU" -bios "$OVMF" \
87 -drive file=fat:rw:"$ESP_DIR",format=raw \
88 -serial file:"$LOG" \
89 -monitor unix:"$SOCK",server,nowait \
90 -m 256M -machine q35 -cpu qemu64 -no-reboot -display none \
91 >"$QLOG" 2>&1 &
92echo $! >"$QPID"
93
94wait_log "$LOG" "boot: entering shell" 90 || fail "kernel never reached the shell"
95grep -aq "ps2: mouse ready" "$LOG" || fail "mouse driver did not come up at boot"
96
97# Seed a move *before* any consumer runs, so `cat /dev/mouse` has a
98# queued event to drain (exercises the VFS snapshot path).
99qemu_hmp "mouse_move 8 4"
100
101echo "==> 4/5 drive the shell (cat /dev/mouse, mouse stream, poweroff)"
102python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \
103 "cat /dev/mouse" \
104 "mouse"
105
106# While the `mouse` stream is live: a move, a left-press, a move with the
107# button held, then release — the button state must ride along on the
108# movement packet.
109qemu_hmp "mouse_move 20 7"
110qemu_hmp "mouse_button 1"
111qemu_hmp "mouse_move -5 -3"
112qemu_hmp "mouse_button 0"
113qemu_hmp "sendkey ctrl-c"
114
115python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \
116 "poweroff"
117
118echo "==> 5/5 wait for shutdown + verify"
119wait_qemu_exit 30 || fail "QEMU did not exit after poweroff"
120
121grep -aq "ps2: mouse ready (IRQ12, stream mode)" "$LOG" || fail "no mouse-ready boot line"
122grep -aq "dx=+8 dy=+4 buttons=---" "$LOG" || fail "/dev/mouse did not drain the queued move (dx=+8 dy=+4)"
123grep -aq "dx=+20 dy=+7 buttons=---" "$LOG" || fail "mouse stream missed mouse_move 20 7"
124grep -aq "dx=+0 dy=+0 buttons=L--" "$LOG" || fail "left-button press did not decode"
125grep -aq "dx=-5 dy=-3 buttons=L--" "$LOG" || fail "held-button movement wrong (dx=-5 dy=-3 L)"
126grep -aq "mouse: stopped" "$LOG" || fail "^C did not stop the mouse stream"
127
128echo ""
129echo "PASS — PS/2 mouse round trip OK (IRQ12, /dev/mouse, live stream, buttons)"
130echo " 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 @@
1#!/usr/bin/env bash
2#
3# scripts/test-msg.sh — automated scheduler message-queue round trip.
4#
5# Builds the kernel, boots QEMU headless, and verifies inter-task
6# messaging end to end:
7# - `msgrecv` spawns a task that blocks on recv_blocking
8# - `send <id> <type>` delivers every message type in order
9# - `mouse <id>` routes *real* IRQ12 mouse events into the queue
10# (the window-manager input path)
11# - queue-full is a clean error (16-cap; tested against the shell's
12# own never-consumed queue)
13# - jobs shows q=0 after everything drains, task back to blocked
14#
15# Usage:
16# ./scripts/test-msg.sh
17#
18# Environment:
19# OVMF firmware (default: /usr/share/edk2/x64/OVMF.4m.fd)
20# QEMU qemu binary (default: qemu-system-x86_64)
21#
22set -euo pipefail
23
24SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
25PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
26cd "$PROJECT_DIR"
27
28OVMF="${OVMF:-/usr/share/edk2/x64/OVMF.4m.fd}"
29QEMU="${QEMU:-qemu-system-x86_64}"
30ESP_DIR="$PROJECT_DIR/esp"
31QPID=/tmp/rsdos-msg-qpid
32SOCK=/tmp/rsdos-msg-qmon.sock
33LOG=/tmp/rsdos-msg-serial.log
34QLOG=/tmp/rsdos-msg-qemu.log
35
36trap 'kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true' EXIT
37
38fail() {
39 echo "FAIL: $1"
40 [ -f "$LOG" ] && tail -20 "$LOG"
41 exit 1
42}
43
44wait_log() {
45 local i=0
46 while [ "$i" -lt $((${3:-60} * 10)) ]; do
47 grep -q "$2" "$1" 2>/dev/null && return 0
48 sleep 0.1
49 i=$((i + 1))
50 done
51 return 1
52}
53
54wait_qemu_exit() {
55 local i=0
56 while [ "$i" -lt $((${1:-30} * 10)) ]; do
57 kill -0 "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || return 0
58 sleep 0.1
59 i=$((i + 1))
60 done
61 return 1
62}
63
64qemu_hmp() {
65 python3 - "$SOCK" "$1" <<'EOF'
66import socket, sys, time
67s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
68s.connect(sys.argv[1])
69s.sendall((sys.argv[2] + "\n").encode())
70time.sleep(0.4)
71EOF
72}
73
74echo "==> 1/5 build kernel (debug)"
75cargo build --package rsdos --target x86_64-unknown-uefi
76
77echo "==> 2/5 deploy to ESP"
78mkdir -p "$ESP_DIR/EFI/BOOT"
79cp target/x86_64-unknown-uefi/debug/rsdos.efi "$ESP_DIR/EFI/BOOT/BOOTX64.EFI"
80
81echo "==> 3/5 boot QEMU (headless)"
82kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true
83sleep 1
84rm -f "$SOCK" "$LOG"
85"$QEMU" -bios "$OVMF" \
86 -drive file=fat:rw:"$ESP_DIR",format=raw \
87 -serial file:"$LOG" \
88 -monitor unix:"$SOCK",server,nowait \
89 -m 256M -machine q35 -cpu qemu64 -no-reboot -display none \
90 >"$QLOG" 2>&1 &
91echo $! >"$QPID"
92
93wait_log "$LOG" "boot: entering shell" 90 || fail "kernel never reached the shell"
94
95echo "==> 4/5 drive the shell (msgrecv / send / mouse routing / queue-full)"
96python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \
97 "msgrecv" \
98 "send 2 user 42" \
99 "send 2 timer" \
100 "send 2 key enter" \
101 "send 2 key ctrl-c" \
102 "send 2 key f3" \
103 "send 2 mouse 5 -3 3" \
104 "sleep 800" \
105 "jobs"
106
107# Real IRQ12 events through the queue: start routing to task 2, inject
108# moves + a left press, stop.
109python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" "mouse 2"
110qemu_hmp "mouse_move 20 7"
111qemu_hmp "mouse_button 1"
112qemu_hmp "mouse_move -5 -3"
113qemu_hmp "mouse_button 0"
114qemu_hmp "sendkey ctrl-c"
115
116# Queue-full boundary: 16 sends fit, the 17th must fail cleanly. The
117# shell never consumes its own queue, so nothing races the drain here.
118python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \
119 "send 0 user 1" \
120 "send 0 user 2" \
121 "send 0 user 3" \
122 "send 0 user 4" \
123 "send 0 user 5" \
124 "send 0 user 6" \
125 "send 0 user 7" \
126 "send 0 user 8" \
127 "send 0 user 9" \
128 "send 0 user 10" \
129 "send 0 user 11" \
130 "send 0 user 12" \
131 "send 0 user 13" \
132 "send 0 user 14" \
133 "send 0 user 15" \
134 "send 0 user 16" \
135 "send 0 user 17" \
136 "sleep 800" \
137 "jobs" \
138 "poweroff"
139
140echo "==> 5/5 wait for shutdown + verify"
141wait_qemu_exit 30 || fail "QEMU did not exit after poweroff"
142
143grep -aq "msgrecv: task 2 listening" "$LOG" || fail "msgrecv did not spawn"
144grep -aq "msgrecv\[2\]: User(42)" "$LOG" || fail "user message missing"
145grep -aq "msgrecv\[2\]: Timer" "$LOG" || fail "timer message missing"
146grep -aq "msgrecv\[2\]: Key(Enter)" "$LOG" || fail "key enter missing"
147grep -aq "msgrecv\[2\]: Key(Ctrl-c)" "$LOG" || fail "key ctrl-c missing"
148grep -aq "msgrecv\[2\]: Key(F3)" "$LOG" || fail "key f3 missing"
149grep -aq "msgrecv\[2\]: Mouse(+5,-3 L R)" "$LOG" || fail "synthesized mouse message missing"
150# Real IRQ12 events routed through the queue (button held across the move).
151grep -aq "msgrecv\[2\]: Mouse(+20,+7" "$LOG" || fail "real mouse move 20,7 not routed to queue"
152grep -aq "msgrecv\[2\]: Mouse(-5,-3 L" "$LOG" || fail "held-button routed move missing"
153grep -aq "queue full" "$LOG" || fail "17th send did not report queue full"
154grep -aq "send: unknown type" "$LOG" && fail "a bad send slipped through (expected none)"
155
156echo ""
157echo "PASS — message queues OK (all types, ordering, real-input routing, queue-full)"
158echo " sent: user · timer · key(enter,ctrl-c,f3) · mouse · 16+1 queue-full"
159echo " 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 `{} <path> TIL <marker>`",
703 "date" => cmd_date(&parsed), 703 "date" => cmd_date(&parsed),
704 "pci" => cmd_pci(&parsed), 704 "pci" => cmd_pci(&parsed),
705 "rescan" => cmd_rescan(&parsed), 705 "rescan" => cmd_rescan(&parsed),
706 "mouse" => cmd_mouse(&parsed),
706 "ls" => cmd_ls(&parsed), 707 "ls" => cmd_ls(&parsed),
707 "cat" => cmd_cat(&parsed), 708 "cat" => cmd_cat(&parsed),
708 "klog" => cmd_klog(&parsed), 709 "klog" => cmd_klog(&parsed),
@@ -713,6 +714,8 @@ write `{} <path> TIL <marker>`",
713 "edit" => cmd_edit(&parsed), 714 "edit" => cmd_edit(&parsed),
714 "bg" => cmd_bg(&parsed), 715 "bg" => cmd_bg(&parsed),
715 "jobs" => cmd_jobs(&parsed), 716 "jobs" => cmd_jobs(&parsed),
717 "msgrecv" => cmd_msgrecv(&parsed),
718 "send" => cmd_send(&parsed),
716 "sleep" => cmd_sleep(&parsed), 719 "sleep" => cmd_sleep(&parsed),
717 "kill" => cmd_kill(&parsed), 720 "kill" => cmd_kill(&parsed),
718 "demo" => cmd_demo(&parsed), 721 "demo" => cmd_demo(&parsed),
@@ -738,8 +741,16 @@ fn cmd_help(_p: &Parsed) {
738 date — show current date/time (RTC) 741 date — show current date/time (RTC)
739 pci — list PCI devices 742 pci — list PCI devices
740 rescan — re-scan for hotplugged/removed disks (also runs automatically) 743 rescan — re-scan for hotplugged/removed disks (also runs automatically)
744 mouse — live mouse events (dx/dy/buttons); ^C stops
745 mouse <id> — post decoded mouse events to task <id>'s queue (input routing)
741 bg <name> — spawn a background task (spinner, ticker, counter, boom, overflow) 746 bg <name> — spawn a background task (spinner, ticker, counter, boom, overflow)
742 jobs — list tasks (id, name, state, ticks) 747 msgrecv — spawn a task that blocks on its message queue and logs every
748 message to serial (ids from `jobs`)
749 send <id> <type> [args] — post a message to a task's queue:
750 user <n> · timer · mouse <dx> <dy> <btn> (1=L 2=R 4=M)
751 key <name> (enter esc tab space bksp up down left right home
752 end pgup pgdn del ctrl-c f1..f12, or a single char)
753 jobs — list tasks (id, name, state, ticks, q=pending messages)
743 sleep <ms> — block the shell for ms; background tasks keep running 754 sleep <ms> — block the shell for ms; background tasks keep running
744 kill <id> — stop a background task (id from jobs) 755 kill <id> — stop a background task (id from jobs)
745 demo — spawn spinner + ticker + counter 756 demo — spawn spinner + ticker + counter
@@ -1517,7 +1528,12 @@ fn rescan_all() -> (usize, usize, usize, usize, usize, usize) {
1517fn report_rescan((da, dr, ma, mu, na, nr): (usize, usize, usize, usize, usize, usize)) -> bool { 1528fn report_rescan((da, dr, ma, mu, na, nr): (usize, usize, usize, usize, usize, usize)) -> bool {
1518 term_println(&alloc::format!( 1529 term_println(&alloc::format!(
1519 "rescan: {} disk(s) added, {} removed · {} volume(s) mounted, {} unmounted · {} node(s) added, {} removed", 1530 "rescan: {} disk(s) added, {} removed · {} volume(s) mounted, {} unmounted · {} node(s) added, {} removed",
1520 da, dr, ma, mu, na, nr 1531 da,
1532 dr,
1533 ma,
1534 mu,
1535 na,
1536 nr
1521 )); 1537 ));
1522 da + dr + ma + mu + na + nr > 0 1538 da + dr + ma + mu + na + nr > 0
1523} 1539}
@@ -1530,6 +1546,62 @@ fn cmd_rescan(_p: &Parsed) {
1530 } 1546 }
1531} 1547}
1532 1548
1549/// Live mouse event stream: print each decoded packet (dx/dy/buttons) as
1550/// it arrives until ^C. With an optional task id, events are **posted to
1551/// that task's message queue** instead of printed — the input-routing
1552/// path the window manager will use (`mouse <id>` + `msgrecv` proves the
1553/// whole chain: IRQ12 → ring → decoder → queue → blocked task). Polls
1554/// the PS/2 ring every 10 ms (via `sleep_ms`, which only blocks the
1555/// shell task) so background tasks keep running.
1556fn cmd_mouse(p: &Parsed) {
1557 let target: Option<usize> = p.args.first().and_then(|s| s.parse::<usize>().ok());
1558 if let Some(id) = target {
1559 if !rsnix::sched::is_alive(id) {
1560 term_err(&alloc::format!("mouse: no such task {}", id));
1561 return;
1562 }
1563 term_println(&alloc::format!(
1564 "mouse: posting events to task {} — ^C to stop",
1565 id
1566 ));
1567 } else {
1568 if !rsnix::ps2::mouse_present() {
1569 term_err("mouse: no PS/2 mouse at boot — cat /dev/mouse still works");
1570 return;
1571 }
1572 term_println("mouse: streaming events — ^C to stop");
1573 }
1574 loop {
1575 if tty::interrupt_pressed() {
1576 term_println("mouse: stopped");
1577 return;
1578 }
1579 while let Some(ev) = rsnix::ps2::try_poll_mouse() {
1580 match target {
1581 Some(id) => {
1582 if let Err(e) = rsnix::sched::send(id, rsnix::sched::TaskMsg::Mouse(ev)) {
1583 term_err(&alloc::format!("mouse: {}", e));
1584 return;
1585 }
1586 }
1587 None => {
1588 let mut b = String::new();
1589 b.push(if ev.left { 'L' } else { '-' });
1590 b.push(if ev.right { 'R' } else { '-' });
1591 b.push(if ev.middle { 'M' } else { '-' });
1592 term_println(&alloc::format!(
1593 "dx={:+} dy={:+} buttons={}",
1594 ev.dx,
1595 ev.dy,
1596 b
1597 ));
1598 }
1599 }
1600 }
1601 rsnix::sched::sleep_ms(10);
1602 }
1603}
1604
1533// --------------------------------------------------------------------------- 1605// ---------------------------------------------------------------------------
1534// Multitasking: bg / jobs / sleep / kill / demo 1606// Multitasking: bg / jobs / sleep / kill / demo
1535// 1607//
@@ -1577,6 +1649,141 @@ fn cmd_jobs(p: &Parsed) {
1577 pipe.end(); 1649 pipe.end();
1578} 1650}
1579 1651
1652/// Demo of the scheduler message queues: spawn a task that blocks on
1653/// `recv_blocking` and logs every message it receives to serial. The
1654/// shell then posts to it with `send <id> …` (id from `jobs`).
1655fn cmd_msgrecv(_p: &Parsed) {
1656 match rsnix::sched::spawn(task_msgrecv, "msgrecv") {
1657 Some(id) => term_println(&alloc::format!(
1658 "msgrecv: task {} listening — post with: send {} <type> …",
1659 id,
1660 id
1661 )),
1662 None => term_err("msgrecv: task table full"),
1663 }
1664}
1665
1666/// The recv demo body: block forever, log each message. Headless by
1667/// design (serial only — it never touches tty/vt/fs, per ADR 0003).
1668fn task_msgrecv() {
1669 loop {
1670 let msg = rsnix::sched::recv_blocking();
1671 rsnix::serial_println!(
1672 "msgrecv[{}]: {}",
1673 rsnix::sched::current(),
1674 rsnix::sched::msg_describe(&msg)
1675 );
1676 }
1677}
1678
1679/// `send <id> <type> [args]` — post a TaskMsg to a task's queue. This
1680/// is the shell-side knob for the primitive the window manager will use
1681/// to route input; types: user <n>, timer, mouse <dx> <dy> <btn>
1682/// (1=L 2=R 4=M), key <name> (enter esc tab space bksp up down left
1683/// right home end pgup pgdn del ctrl-c f1..f12, or a single char).
1684fn cmd_send(p: &Parsed) {
1685 let mut args = p.args.iter();
1686 let id: usize = match args.next().and_then(|s| s.parse().ok()) {
1687 Some(v) => v,
1688 None => {
1689 term_err("send: needs a task id — jobs lists them, msgrecv spawns a listener");
1690 return;
1691 }
1692 };
1693 let kind = match args.next() {
1694 Some(k) => k.as_str(),
1695 None => {
1696 term_err("send: needs a type — user <n> | timer | mouse <dx> <dy> <btn> | key <name>");
1697 return;
1698 }
1699 };
1700 let msg = match kind {
1701 "user" => {
1702 let Some(n) = args.next().and_then(|s| s.parse().ok()) else {
1703 term_err("send: user needs a number");
1704 return;
1705 };
1706 rsnix::sched::TaskMsg::User(n)
1707 }
1708 "timer" => rsnix::sched::TaskMsg::Timer,
1709 "mouse" => {
1710 let dx: i16 = args.next().and_then(|s| s.parse().ok()).unwrap_or(0);
1711 let dy: i16 = args.next().and_then(|s| s.parse().ok()).unwrap_or(0);
1712 let btn: u8 = args.next().and_then(|s| s.parse().ok()).unwrap_or(0);
1713 rsnix::sched::TaskMsg::Mouse(rsnix::ps2::MouseEvent {
1714 dx,
1715 dy,
1716 left: btn & 1 != 0,
1717 right: btn & 2 != 0,
1718 middle: btn & 4 != 0,
1719 })
1720 }
1721 "key" => match args.next() {
1722 Some(k) => match key_from_name(k.as_str()) {
1723 Some(ev) => rsnix::sched::TaskMsg::Key(ev),
1724 None => {
1725 term_err("send: unknown key name");
1726 return;
1727 }
1728 },
1729 None => {
1730 term_err("send: key needs a name");
1731 return;
1732 }
1733 },
1734 _ => {
1735 term_err("send: unknown type — user <n> | timer | mouse <dx> <dy> <btn> | key <name>");
1736 return;
1737 }
1738 };
1739 match rsnix::sched::send(id, msg) {
1740 Ok(()) => {}
1741 Err(e) => term_err(&alloc::format!("send: {}", e)),
1742 }
1743}
1744
1745/// Map a small key-name vocabulary to a KeyEvent for `send key …`.
1746fn key_from_name(name: &str) -> Option<rsnix::ps2::KeyEvent> {
1747 use rsnix::ps2::KeyEvent;
1748 Some(match name {
1749 "enter" => KeyEvent::Enter,
1750 "esc" | "escape" => KeyEvent::Escape,
1751 "tab" => KeyEvent::Tab,
1752 "space" => KeyEvent::Char(' '),
1753 "bksp" | "backspace" => KeyEvent::Backspace,
1754 "up" => KeyEvent::ArrowUp,
1755 "down" => KeyEvent::ArrowDown,
1756 "left" => KeyEvent::ArrowLeft,
1757 "right" => KeyEvent::ArrowRight,
1758 "home" => KeyEvent::Home,
1759 "end" => KeyEvent::End,
1760 "pgup" => KeyEvent::PageUp,
1761 "pgdn" => KeyEvent::PageDown,
1762 "del" | "delete" => KeyEvent::Delete,
1763 "ctrl-c" => KeyEvent::Ctrl('c'),
1764 "ctrl-d" => KeyEvent::Ctrl('d'),
1765 "ctrl-s" => KeyEvent::Ctrl('s'),
1766 "ctrl-x" => KeyEvent::Ctrl('x'),
1767 _ => {
1768 if let Some(n) = name.strip_prefix('f') {
1769 if let Ok(n) = n.parse::<u8>() {
1770 if (1..=12).contains(&n) {
1771 KeyEvent::F(n)
1772 } else {
1773 return None;
1774 }
1775 } else {
1776 return None;
1777 }
1778 } else if name.chars().count() == 1 {
1779 KeyEvent::Char(name.chars().next().unwrap())
1780 } else {
1781 return None;
1782 }
1783 }
1784 })
1785}
1786
1580fn cmd_sleep(p: &Parsed) { 1787fn cmd_sleep(p: &Parsed) {
1581 let ms: u64 = match p.args.first().and_then(|s| s.parse().ok()) { 1788 let ms: u64 = match p.args.first().and_then(|s| s.parse().ok()) {
1582 Some(m) if m > 0 => m, 1789 Some(m) if m > 0 => m,