diff --git a/crates/redox-wl-compositor/src/main.rs b/crates/redox-wl-compositor/src/main.rs index 0e8cfee..8a7e162 100644 --- a/crates/redox-wl-compositor/src/main.rs +++ b/crates/redox-wl-compositor/src/main.rs @@ -95,7 +95,13 @@ impl<'a> MakeWriter<'a> for TeeWriter { } fn init_tracing() { - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + // Phase 13.1.c : par défaut, info + target=cursor en debug. Le target + // `cursor` est utilisé par les traces ABS/REL du frontend (cf + // redox-wl-wayland-frontend). Pratique sous QEMU graphique où taper + // un RUST_LOG complexe est pénible (pas de copier-coller). Override + // toujours possible via RUST_LOG=… au lancement. + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,cursor=debug")); tracing_subscriber::fmt() .with_env_filter(filter) .with_writer(TeeWriter::new()) @@ -138,6 +144,10 @@ fn run() -> Result<(), Box> { // Wayland frontend let socket_path = PathBuf::from(SOCKET_PATH); let mut frontend = WaylandFrontend::bind_absolute(&socket_path)?; + // Phase 13.1.b : déclarer les bornes écran AVANT toute manipulation + // de la position curseur, sinon les deltas relatifs (PS/2 sous QEMU) + // s'accumulent hors-écran et hit_test ne trouve jamais les surfaces. + frontend.set_screen_size(fb_w as i32, fb_h as i32); // Phase 7.3 : curseur visible dès le démarrage, placé au centre du fb. frontend.set_cursor_initial_position((fb_w as i32) / 2, (fb_h as i32) / 2); info!("Wayland socket : {SOCKET_PATH}"); @@ -154,6 +164,11 @@ fn run() -> Result<(), Box> { let frame_period = Duration::from_millis(33); // ~30 fps let mut last_frame = Instant::now(); let mut tick: u32 = 0; + // Phase 13.1.b : Ctrl tenu, pour le raccourci compositor Ctrl+Q. + // ESC n'est plus interceptée — elle est forwardée aux clients afin que + // les apps tierces (cf. simple_window upstream) puissent décider elles-mêmes + // de se terminer sur ESC sans race avec la fermeture du socket compositor. + let mut ctrl_held = false; while start.elapsed() < total { tick = tick.wrapping_add(1); @@ -179,12 +194,23 @@ fn run() -> Result<(), Box> { tracing::debug!("{} input events from inputd", events.len()); } for ev in events { + // Track Ctrl (scancode 0x1D, set 1) avant le match : nécessaire + // pour le raccourci Ctrl+Q et OK à dupliquer côté clients via + // forward_input — wl_keyboard.key reste la source de vérité pour eux. + if let InputEvent::Key { + scancode: 0x1D, + pressed, + .. + } = ev + { + ctrl_held = pressed; + } match &ev { InputEvent::Key { scancode, pressed, .. - } if *pressed && *scancode == 0x01 => { - // Esc → exit - info!("Esc → exit"); + } if *pressed && *scancode == 0x10 && ctrl_held => { + // Ctrl+Q → exit compositor (ESC est réservé aux clients) + info!("Ctrl+Q → exit compositor"); let _ = frontend.flush_clients(); let _ = std::fs::remove_file(SOCKET_PATH); return Ok(()); @@ -229,9 +255,11 @@ fn run() -> Result<(), Box> { error!("flush err: {e}"); } - // Log occasionnel - if tick % 30 == 0 { - info!( + // Phase 13.1.b : tick log en debug! (saturait la console série). + // Réactivable avec RUST_LOG=debug. Fréquence réduite à toutes les + // 5s (~150 ticks @ 30fps) pour limiter encore le bruit. + if tick % 150 == 0 { + tracing::debug!( "tick={tick} surfaces={nb} elapsed={:.1}s", start.elapsed().as_secs_f32() ); diff --git a/crates/redox-wl-real-client-simple-window/src/main.rs b/crates/redox-wl-real-client-simple-window/src/main.rs index 3f1aff2..e7693e1 100644 --- a/crates/redox-wl-real-client-simple-window/src/main.rs +++ b/crates/redox-wl-real-client-simple-window/src/main.rs @@ -70,6 +70,23 @@ fn dlog(s: &str) { SINK.get_or_init(DebugSink::new).writeln(s); } +fn is_interrupted_error(err: &(dyn std::error::Error + 'static)) -> bool { + if let Some(ioe) = err.downcast_ref::() { + return ioe.kind() == std::io::ErrorKind::Interrupted; + } + err.source().is_some_and(is_interrupted_error) +} + +fn is_connection_closed_error(err: &(dyn std::error::Error + 'static)) -> bool { + if let Some(ioe) = err.downcast_ref::() { + return matches!( + ioe.kind(), + std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset + ); + } + err.source().is_some_and(is_connection_closed_error) +} + // ---------- Buffer SHM (équivalent de tempfile + draw upstream) ---------- const INIT_W: u32 = 320; const INIT_H: u32 = 240; @@ -350,7 +367,15 @@ fn run() -> Result<(), Box> { dlog("[real-client] entering event loop"); while state.running { - event_queue.blocking_dispatch(&mut state)?; + match event_queue.blocking_dispatch(&mut state) { + Ok(_) => {} + Err(e) if is_interrupted_error(&e) => continue, + Err(e) if is_connection_closed_error(&e) => { + dlog("[real-client] compositor disconnected → exit cleanly"); + break; + } + Err(e) => return Err(e.into()), + } } dlog("[real-client] loop exited cleanly"); Ok(()) diff --git a/crates/redox-wl-test-client-subcompositor/Cargo.toml b/crates/redox-wl-test-client-subcompositor/Cargo.toml new file mode 100644 index 0000000..2aca781 --- /dev/null +++ b/crates/redox-wl-test-client-subcompositor/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "redox-wl-test-client-subcompositor" +version = "0.1.0" +edition = "2021" + +[dependencies] +wayland-client = { path = "../../../wayland-rs/wayland-client", default-features = false } +wayland-backend = { path = "../../../wayland-rs/wayland-backend", default-features = false } +wayland-protocols = { path = "../../../wayland-rs/wayland-protocols", default-features = false, features = ["client"] } +libc = "0.2" diff --git a/crates/redox-wl-test-client-subcompositor/src/main.rs b/crates/redox-wl-test-client-subcompositor/src/main.rs new file mode 100644 index 0000000..fac5bcf --- /dev/null +++ b/crates/redox-wl-test-client-subcompositor/src/main.rs @@ -0,0 +1,390 @@ +//! Phase 13.2.b.3 — Client de test visuel pour wl_subcompositor. +//! +//! Crée un toplevel parent 300×200 bleu uni avec bordure noire, et lui +//! attache une subsurface 60×60 rouge à l'offset (50, 50). Si le rendering +//! côté compositor (phase 13.2.b.2) est correct, on doit voir à l'écran : +//! +//! - Fenêtre bleue en haut-gauche (selon cascading compositor) +//! - Un carré rouge dedans, en haut-gauche de la fenêtre bleue, à 50px +//! du coin haut-gauche du parent +//! +//! Le client tourne ~60s puis exit. ESC ferme proprement avant timeout +//! (path keyboard via wl_keyboard.enter envoyé au focus = parent toplevel). +//! +//! NB : sans cascade sync ni propagation parent-move (limitations 13.2.b.2), +//! la subsurface est figée à la position de création. Acceptable pour +//! validation visuelle de base. + +use std::ffi::CString; +use std::fs::OpenOptions; +use std::io::Write; +use std::os::fd::{AsFd, FromRawFd, OwnedFd}; +use std::os::unix::net::UnixStream; +use std::process::ExitCode; +use std::ptr; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use std::time::Duration; + +use wayland_client::{ + backend::Backend, + protocol::{ + wl_buffer::WlBuffer, wl_compositor::WlCompositor, wl_keyboard, wl_registry, + wl_seat::WlSeat, wl_shm::WlShm, wl_shm_pool::WlShmPool, + wl_subcompositor::WlSubcompositor, wl_subsurface::WlSubsurface, wl_surface::WlSurface, + }, + Connection, Dispatch, EventQueue, Proxy, QueueHandle, +}; +use wayland_protocols::xdg::shell::client::{ + xdg_surface::{self, XdgSurface}, + xdg_toplevel::{self, XdgToplevel}, + xdg_wm_base::{self, XdgWmBase}, +}; + +const SOCKET_PATH: &str = "/tmp/redox-wl-comp.sock"; + +// Parent : 300×200 bleu nuit +const PW: i32 = 300; +const PH: i32 = 200; +const PSTRIDE: i32 = PW * 4; +const PSIZE: i32 = PSTRIDE * PH; +const PCOLOR: u32 = 0xFF_22_44_AA; + +// Subsurface : 60×60 rouge, offset (50, 50) +const SW: i32 = 60; +const SH: i32 = 60; +const SSTRIDE: i32 = SW * 4; +const SSIZE: i32 = SSTRIDE * SH; +const SCOLOR: u32 = 0xFF_CC_30_30; +const SOFF_X: i32 = 50; +const SOFF_Y: i32 = 50; + +struct DebugSink(Mutex>); +impl DebugSink { + fn new() -> Self { + Self(Mutex::new( + OpenOptions::new().write(true).open("/scheme/debug").ok(), + )) + } + fn writeln(&self, s: &str) { + println!("{s}"); + if let Ok(mut g) = self.0.lock() { + if let Some(f) = g.as_mut() { + let _ = writeln!(f, "{s}"); + } + } + } +} +fn dlog(s: &str) { + static SINK: OnceLock = OnceLock::new(); + SINK.get_or_init(DebugSink::new).writeln(s); +} + +#[derive(Default)] +struct State { + compositor: Option, + shm: Option, + wm_base: Option, + seat: Option, + subcompositor: Option, + pending_serial: Option, + configured: bool, + running: bool, +} + +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _conn: &Connection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version, + } = event + { + match interface.as_str() { + "wl_compositor" => { + state.compositor = Some(registry.bind(name, version.min(5), qh, ())); + } + "wl_shm" => { + state.shm = Some(registry.bind(name, 1, qh, ())); + } + "xdg_wm_base" => { + state.wm_base = Some(registry.bind(name, version.min(5), qh, ())); + } + "wl_seat" => { + state.seat = Some(registry.bind(name, version.min(7), qh, ())); + } + "wl_subcompositor" => { + state.subcompositor = Some(registry.bind(name, 1, qh, ())); + } + _ => {} + } + } + } +} + +macro_rules! noop { + ($ty:ty) => { + impl Dispatch<$ty, ()> for State { + fn event( + _: &mut Self, + _: &$ty, + _: <$ty as Proxy>::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } + } + }; +} +noop!(WlCompositor); +noop!(WlShm); +noop!(WlShmPool); +noop!(WlBuffer); +noop!(WlSurface); +noop!(WlSeat); +noop!(WlSubcompositor); +noop!(WlSubsurface); + +impl Dispatch for State { + fn event( + _: &mut Self, + wm_base: &XdgWmBase, + event: xdg_wm_base::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let xdg_wm_base::Event::Ping { serial } = event { + wm_base.pong(serial); + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &XdgSurface, + event: xdg_surface::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let xdg_surface::Event::Configure { serial } = event { + state.pending_serial = Some(serial); + state.configured = true; + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &XdgToplevel, + event: xdg_toplevel::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if matches!(event, xdg_toplevel::Event::Close) { + state.running = false; + } + } +} + +// ESC → exit. Même logique qu'en 13.1.b sur le client simple_window. +impl Dispatch for State { + fn event( + state: &mut Self, + _: &wl_keyboard::WlKeyboard, + event: wl_keyboard::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_keyboard::Event::Key { key, .. } = event { + if key == 1 { + dlog("[sub-client] ESC → exit"); + state.running = false; + } + } + } +} + +unsafe fn create_shm(name: &str, size: i32, w: i32, h: i32, color: u32) -> Result { + let cname = CString::new(name).unwrap(); + let _ = libc::shm_unlink(cname.as_ptr()); + let fd = libc::shm_open(cname.as_ptr(), libc::O_RDWR | libc::O_CREAT, 0o600); + if fd < 0 { + return Err("shm_open failed".into()); + } + if libc::ftruncate(fd, size as _) != 0 { + libc::close(fd); + return Err("ftruncate failed".into()); + } + let p = libc::mmap( + ptr::null_mut(), + size as usize, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd, + 0, + ); + if p == libc::MAP_FAILED { + libc::close(fd); + return Err("mmap failed".into()); + } + let pixels = std::slice::from_raw_parts_mut(p as *mut u32, (w * h) as usize); + // Remplissage uni avec bordure noire 2px + for y in 0..h { + for x in 0..w { + let on_border = x < 2 || x >= w - 2 || y < 2 || y >= h - 2; + pixels[(y * w + x) as usize] = if on_border { 0xFF_10_10_10 } else { color }; + } + } + libc::munmap(p, size as usize); + Ok(OwnedFd::from_raw_fd(fd)) +} + +fn run() -> Result<(), Box> { + dlog("[sub-client] start (Phase 13.2.b.3 — visual subsurface)"); + + for i in 0..50 { + if std::path::Path::new(SOCKET_PATH).exists() { + break; + } + if i == 49 { + return Err("compositor socket missing after 5s".into()); + } + thread::sleep(Duration::from_millis(100)); + } + + let stream = UnixStream::connect(SOCKET_PATH)?; + let backend = Backend::connect(stream)?; + let conn = Connection::from_backend(backend); + let mut event_queue: EventQueue = conn.new_event_queue(); + let qh = event_queue.handle(); + let _registry = conn.display().get_registry(&qh, ()); + + let mut state = State { + running: true, + ..State::default() + }; + event_queue.roundtrip(&mut state)?; + let compositor = state.compositor.clone().ok_or("no wl_compositor")?; + let shm = state.shm.clone().ok_or("no wl_shm")?; + let wm_base = state.wm_base.clone().ok_or("no xdg_wm_base")?; + let subcomp = state + .subcompositor + .clone() + .ok_or("no wl_subcompositor (compositor patché 13.2.b.1 ?)")?; + let seat = state.seat.clone(); + + // Bind keyboard pour gérer ESC + if let Some(s) = &seat { + let _kb = s.get_keyboard(&qh, ()); + } + + // --- Parent toplevel --- + let parent_surface = compositor.create_surface(&qh, ()); + let xdg_surface = wm_base.get_xdg_surface(&parent_surface, &qh, ()); + let toplevel = xdg_surface.get_toplevel(&qh, ()); + toplevel.set_title("Phase 13.2.b.3 — subcompositor visual".into()); + toplevel.set_app_id("redox.wl.test.client.subcompositor".into()); + parent_surface.commit(); + dlog("[sub-client] parent xdg_toplevel créé"); + + let start = std::time::Instant::now(); + while !state.configured && start.elapsed() < Duration::from_secs(5) { + event_queue.roundtrip(&mut state)?; + thread::sleep(Duration::from_millis(50)); + } + let serial = state.pending_serial.ok_or("no initial configure")?; + xdg_surface.ack_configure(serial); + state.pending_serial = None; + dlog(&format!("[sub-client] ack_configure({serial})")); + + // Parent buffer + attach + commit + let parent_fd = unsafe { create_shm("redox-sub-client-parent", PSIZE, PW, PH, PCOLOR) }?; + let parent_pool = shm.create_pool(parent_fd.as_fd(), PSIZE, &qh, ()); + let parent_buffer = parent_pool.create_buffer( + 0, + PW, + PH, + PSTRIDE, + wayland_client::protocol::wl_shm::Format::Argb8888, + &qh, + (), + ); + parent_surface.attach(Some(&parent_buffer), 0, 0); + parent_surface.commit(); + dlog("[sub-client] parent buffer attached + commit"); + + // --- Subsurface --- + let child_surface = compositor.create_surface(&qh, ()); + let subsurface: WlSubsurface = + subcomp.get_subsurface(&child_surface, &parent_surface, &qh, ()); + subsurface.set_position(SOFF_X, SOFF_Y); + subsurface.set_desync(); // mode desync → commit du child suffit pour appliquer + dlog(&format!( + "[sub-client] subsurface créée + set_position({SOFF_X}, {SOFF_Y}) + set_desync" + )); + + let child_fd = unsafe { create_shm("redox-sub-client-child", SSIZE, SW, SH, SCOLOR) }?; + let child_pool = shm.create_pool(child_fd.as_fd(), SSIZE, &qh, ()); + let child_buffer = child_pool.create_buffer( + 0, + SW, + SH, + SSTRIDE, + wayland_client::protocol::wl_shm::Format::Argb8888, + &qh, + (), + ); + child_surface.attach(Some(&child_buffer), 0, 0); + child_surface.commit(); + dlog("[sub-client] child buffer attached + commit"); + + // Re-commit du parent pour rendre les changements visibles en sync. + // (En desync ce n'est pas strictement nécessaire mais ça force un redraw.) + parent_surface.commit(); + + dlog("[sub-client] entering event loop"); + let deadline = std::time::Instant::now() + Duration::from_secs(60); + while state.running && std::time::Instant::now() < deadline { + match event_queue.blocking_dispatch(&mut state) { + Ok(_) => {} + Err(e) => { + let s = format!("{e}"); + if s.contains("Broken pipe") || s.contains("Connection reset") { + dlog("[sub-client] compositor disconnected → exit cleanly"); + break; + } + return Err(e.into()); + } + } + } + + dlog("[sub-client] loop exited cleanly"); + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => { + dlog("[sub-client] PASS"); + ExitCode::SUCCESS + } + Err(e) => { + dlog(&format!("[sub-client] FAIL: {e}")); + ExitCode::FAILURE + } + } +} diff --git a/crates/redox-wl-test-wl-output/Cargo.toml b/crates/redox-wl-test-wl-output/Cargo.toml new file mode 100644 index 0000000..f2a8e6e --- /dev/null +++ b/crates/redox-wl-test-wl-output/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "redox-wl-test-wl-output" +version = "0.1.0" +edition = "2021" + +[dependencies] +wayland-client = { path = "../../../wayland-rs/wayland-client", default-features = false } +wayland-backend = { path = "../../../wayland-rs/wayland-backend", default-features = false } +libc = "0.2" + +[dev-dependencies] +# Test natif (CachyOS, pas Redox) : server + client en-process +# pour valider le gating de wl_output v1/v2/v3. +wayland-server = { path = "../../../wayland-rs/wayland-server", default-features = false } diff --git a/crates/redox-wl-test-wl-output/src/main.rs b/crates/redox-wl-test-wl-output/src/main.rs new file mode 100644 index 0000000..392256d --- /dev/null +++ b/crates/redox-wl-test-wl-output/src/main.rs @@ -0,0 +1,291 @@ +//! Phase 13.2.a — client de test : version gating de wl_output. +//! +//! Se connecte au compositor, trouve le global wl_output, et le bind +//! 3 fois successivement aux versions 1, 2, 3. Pour chaque bind, dump +//! les events reçus et vérifie le gating attendu : +//! +//! v1 → Geometry + Mode uniquement (pas de Scale, pas de Done) +//! v2 → + Done (pas encore de Scale) +//! v3 → + Scale (gating conservateur côté serveur) +//! +//! Sortie sur stdout ET /scheme/debug pour récupération depuis serial +//! sous QEMU. Code de sortie 0 = PASS, 1 = FAIL si gating incorrect. +//! +//! NB : on s'aligne sur la procédure 13.1.b (connexion explicite au +//! socket, pas via WAYLAND_DISPLAY ; logs tee'd vers debug). Aucune +//! adaptation de fond — c'est juste un client wayland-client raw. + +use std::fs::OpenOptions; +use std::io::Write; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::process::ExitCode; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use std::time::Duration; + +use wayland_client::{ + backend::Backend, + protocol::{wl_output, wl_registry}, + Connection, Dispatch, QueueHandle, +}; + +const SOCKET_PATH: &str = "/tmp/redox-wl-comp.sock"; + +// --- Logging tee vers stdout + /scheme/debug -------------------------------- + +struct DebugSink(Mutex>); +impl DebugSink { + fn new() -> Self { + Self(Mutex::new( + OpenOptions::new().write(true).open("/scheme/debug").ok(), + )) + } + fn writeln(&self, s: &str) { + println!("{s}"); + if let Ok(mut g) = self.0.lock() { + if let Some(f) = g.as_mut() { + let _ = writeln!(f, "{s}"); + } + } + } +} +fn dlog(s: &str) { + static SINK: OnceLock = OnceLock::new(); + SINK.get_or_init(DebugSink::new).writeln(s); +} + +// --- Attente du socket ----------------------------------------------------- + +fn wait_for_socket(path: &Path, attempts: u32, sleep_ms: u64) -> bool { + for _ in 0..attempts { + if path.exists() { + return true; + } + thread::sleep(Duration::from_millis(sleep_ms)); + } + false +} + +// --- État partagé ---------------------------------------------------------- + +/// Accumulateur d'events pour un binding wl_output donné. +#[derive(Default, Debug)] +struct OutputObservation { + seen_geometry: bool, + seen_mode: bool, + seen_scale: bool, + seen_done: bool, + mode_flags: u32, + mode_width: i32, + mode_height: i32, + mode_refresh: i32, + scale_factor: i32, +} + +/// État global du client de test : registry advertised globals + 3 observations. +#[derive(Default)] +struct State { + output_global_name: Option, + output_global_version: Option, + // 3 observations indexées 0=v1, 1=v2, 2=v3. + obs: [OutputObservation; 3], + // Phase courante en cours de capture (None entre les binds). + current_version_index: Option, +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _r: &wl_registry::WlRegistry, + event: wl_registry::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version, + } = event + { + if interface == "wl_output" { + state.output_global_name = Some(name); + state.output_global_version = Some(version); + dlog(&format!( + "[test-output] registry: wl_output advertised name={name} version={version}" + )); + } + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _r: &wl_output::WlOutput, + event: wl_output::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + let Some(idx) = state.current_version_index else { + // Ne devrait pas arriver — events après un done observable + // sur un binding qu'on a fini d'examiner. On ignore. + return; + }; + let obs = &mut state.obs[idx]; + match event { + wl_output::Event::Geometry { .. } => obs.seen_geometry = true, + wl_output::Event::Mode { + flags, + width, + height, + refresh, + } => { + obs.seen_mode = true; + obs.mode_flags = flags.into(); + obs.mode_width = width; + obs.mode_height = height; + obs.mode_refresh = refresh; + } + wl_output::Event::Scale { factor } => { + obs.seen_scale = true; + obs.scale_factor = factor; + } + wl_output::Event::Done => obs.seen_done = true, + _ => {} + } + } +} + +// --- main ------------------------------------------------------------------ + +fn main() -> ExitCode { + dlog("[test-output] start (Phase 13.2.a — version gating wl_output)"); + + let path = Path::new(SOCKET_PATH); + if !wait_for_socket(path, 50, 100) { + dlog(&format!( + "[test-output] FAIL: socket {SOCKET_PATH} introuvable après 5s" + )); + return ExitCode::FAILURE; + } + + let stream = match UnixStream::connect(SOCKET_PATH) { + Ok(s) => s, + Err(e) => { + dlog(&format!("[test-output] FAIL: connect: {e}")); + return ExitCode::FAILURE; + } + }; + stream.set_nonblocking(false).ok(); + + let backend = match Backend::connect(stream) { + Ok(b) => b, + Err(e) => { + dlog(&format!("[test-output] FAIL: backend: {e}")); + return ExitCode::FAILURE; + } + }; + let conn = Connection::from_backend(backend); + + let mut event_queue = conn.new_event_queue::(); + let qh = event_queue.handle(); + let display = conn.display(); + let _registry = display.get_registry(&qh, ()); + + let mut state = State::default(); + + // Premier roundtrip : capture les globals + if let Err(e) = event_queue.roundtrip(&mut state) { + dlog(&format!("[test-output] FAIL: roundtrip registry: {e}")); + return ExitCode::FAILURE; + } + + let global_name = match state.output_global_name { + Some(n) => n, + None => { + dlog("[test-output] FAIL: aucun global wl_output annoncé par le compositor"); + return ExitCode::FAILURE; + } + }; + let advertised_version = state.output_global_version.unwrap_or(0); + dlog(&format!( + "[test-output] global wl_output découvert (name={global_name}, version annoncée={advertised_version})" + )); + + if advertised_version < 3 { + dlog(&format!( + "[test-output] WARN: version annoncée < 3, le test v3 va échouer (compositor pas à jour ?)" + )); + } + + // Bind à v1, v2, v3 successivement avec un roundtrip entre chaque. + let registry = display.get_registry(&qh, ()); + for (idx, version) in [(0usize, 1u32), (1, 2), (2, 3)].iter() { + state.current_version_index = Some(*idx); + dlog(&format!("[test-output] bind wl_output @ v{version} …")); + let _output = registry.bind::(global_name, *version, &qh, ()); + // Plusieurs roundtrips pour s'assurer que tous les events init + // ont atterri (geometry + mode + éventuel scale + éventuel done). + for _ in 0..3 { + if let Err(e) = event_queue.roundtrip(&mut state) { + dlog(&format!( + "[test-output] FAIL: roundtrip bind v{version}: {e}" + )); + return ExitCode::FAILURE; + } + } + let obs = &state.obs[*idx]; + dlog(&format!( + "[test-output] v{version} reçu : geometry={} mode={} scale={} done={} | mode={}x{}@{}mHz flags={} scale_factor={}", + obs.seen_geometry, obs.seen_mode, obs.seen_scale, obs.seen_done, + obs.mode_width, obs.mode_height, obs.mode_refresh, obs.mode_flags, obs.scale_factor + )); + state.current_version_index = None; + } + + // --- Verdict --- + let mut errors: Vec = Vec::new(); + + let v1 = &state.obs[0]; + if !v1.seen_geometry { errors.push("v1: geometry manquant".into()); } + if !v1.seen_mode { errors.push("v1: mode manquant".into()); } + if v1.seen_scale { errors.push("v1: scale envoyé alors qu'attendu v3+".into()); } + if v1.seen_done { errors.push("v1: done envoyé alors qu'attendu v2+".into()); } + + let v2 = &state.obs[1]; + if !v2.seen_geometry { errors.push("v2: geometry manquant".into()); } + if !v2.seen_mode { errors.push("v2: mode manquant".into()); } + if v2.seen_scale { errors.push("v2: scale envoyé alors qu'attendu v3+ (gating conservateur)".into()); } + if !v2.seen_done { errors.push("v2: done attendu mais manquant".into()); } + + let v3 = &state.obs[2]; + if !v3.seen_geometry { errors.push("v3: geometry manquant".into()); } + if !v3.seen_mode { errors.push("v3: mode manquant".into()); } + if !v3.seen_scale { errors.push("v3: scale attendu mais manquant".into()); } + if !v3.seen_done { errors.push("v3: done attendu mais manquant".into()); } + + // Sanity check sur les valeurs : mode flags doit contenir CURRENT (0x1) + // et PREFERRED (0x2), donc valeur 3. + if v3.mode_flags & 0x1 == 0 { + errors.push(format!("v3: mode flags ne contient pas CURRENT (got 0x{:x})", v3.mode_flags)); + } + if v3.mode_flags & 0x2 == 0 { + errors.push(format!("v3: mode flags ne contient pas PREFERRED (got 0x{:x})", v3.mode_flags)); + } + if v3.scale_factor != 1 { + errors.push(format!("v3: scale_factor attendu 1, got {}", v3.scale_factor)); + } + + if errors.is_empty() { + dlog("[test-output] PASS — gating v1/v2/v3 conforme aux attentes"); + ExitCode::SUCCESS + } else { + for e in &errors { + dlog(&format!("[test-output] FAIL: {e}")); + } + ExitCode::FAILURE + } +} diff --git a/crates/redox-wl-test-wl-output/tests/gating_native.rs b/crates/redox-wl-test-wl-output/tests/gating_native.rs new file mode 100644 index 0000000..decd427 --- /dev/null +++ b/crates/redox-wl-test-wl-output/tests/gating_native.rs @@ -0,0 +1,247 @@ +//! Test natif (CachyOS, hors QEMU) : version gating de wl_output. +//! +//! On simule en-process l'init du global wl_output v3 avec la MÊME logique +//! de gating que `redox-wl-wayland-frontend::lib.rs` : +//! - geometry, mode : toujours envoyés +//! - scale : seulement si version >= 3 +//! - done : seulement si version >= 2 +//! +//! Un seul process : serveur sur un thread + client sur le thread principal, +//! reliés par `UnixStream::pair()`. Pas besoin de Redox, pas besoin de QEMU. + +use std::os::unix::net::UnixStream; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use wayland_server::backend::{ClientData, ClientId, DisconnectReason}; +use wayland_server::{ + protocol::wl_output as srv_wl_output, Client as SrvClient, DataInit, Display, DisplayHandle, + GlobalDispatch, Resource, +}; + +use wayland_client::{ + backend::Backend, + protocol::{wl_output as cli_wl_output, wl_registry}, + Connection, Dispatch as CliDispatch, QueueHandle, +}; + +const OUTPUT_VERSION: u32 = 3; +const SCREEN_W: i32 = 1280; +const SCREEN_H: i32 = 800; + +// ============= server side ============= + +struct ServerState; + +struct StubClientData; +impl ClientData for StubClientData { + fn initialized(&self, _: ClientId) {} + fn disconnected(&self, _: ClientId, _: DisconnectReason) {} +} + +impl GlobalDispatch for ServerState { + fn bind( + _state: &mut Self, + _handle: &DisplayHandle, + _client: &SrvClient, + resource: wayland_server::New, + _data: &(), + data_init: &mut DataInit<'_, Self>, + ) { + let output = data_init.init(resource, ()); + let version = output.version(); + + // Logique identique à redox-wl-wayland-frontend/src/lib.rs + output.geometry( + 0, + 0, + 0, + 0, + srv_wl_output::Subpixel::Unknown, + "Redox".to_string(), + "redox-wl-output:0".to_string(), + srv_wl_output::Transform::Normal, + ); + + let mode_flags = srv_wl_output::Mode::Current | srv_wl_output::Mode::Preferred; + output.mode(mode_flags, SCREEN_W, SCREEN_H, 60_000); + + if version >= 3 { + output.scale(1); + } + if version >= 2 { + output.done(); + } + } +} + +impl wayland_server::Dispatch for ServerState { + fn request( + _state: &mut Self, + _client: &SrvClient, + _r: &srv_wl_output::WlOutput, + _request: srv_wl_output::Request, + _data: &(), + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + } +} + +// ============= client side ============= + +#[derive(Default, Debug, Clone, Copy)] +struct OutputObs { + seen_geometry: bool, + seen_mode: bool, + seen_scale: bool, + seen_done: bool, + mode_flags: u32, + scale_factor: i32, +} + +#[derive(Default)] +struct ClientState { + global_name: Option, + obs: [OutputObs; 3], + cur: Option, +} + +impl CliDispatch for ClientState { + fn event( + state: &mut Self, + _: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, interface, .. + } = event + { + if interface == "wl_output" { + state.global_name = Some(name); + } + } + } +} + +impl CliDispatch for ClientState { + fn event( + state: &mut Self, + _: &cli_wl_output::WlOutput, + event: cli_wl_output::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + let Some(idx) = state.cur else { return }; + let obs = &mut state.obs[idx]; + match event { + cli_wl_output::Event::Geometry { .. } => obs.seen_geometry = true, + cli_wl_output::Event::Mode { flags, .. } => { + obs.seen_mode = true; + obs.mode_flags = flags.into(); + } + cli_wl_output::Event::Scale { factor } => { + obs.seen_scale = true; + obs.scale_factor = factor; + } + cli_wl_output::Event::Done => obs.seen_done = true, + _ => {} + } + } +} + +// ============= test ============= + +#[test] +fn wl_output_v123_gating() { + // 1. Setup serveur dans un thread + let (s_stream, c_stream) = UnixStream::pair().expect("UnixStream::pair"); + + let server_thread = thread::spawn(move || { + let mut display: Display = Display::new().unwrap(); + let dh = display.handle(); + dh.create_global::(OUTPUT_VERSION, ()); + + s_stream.set_nonblocking(true).unwrap(); + let _client = display + .handle() + .insert_client(s_stream, Arc::new(StubClientData)) + .unwrap(); + + let mut state = ServerState; + // Dispatch loop : on tourne ~5 secondes pour absorber tous les + // round-trips du test client. Le test signale la fin en fermant + // le socket côté client, ce qui sort dispatch_clients sur error. + let start = std::time::Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + let _ = display.dispatch_clients(&mut state); + let _ = display.flush_clients(); + thread::sleep(Duration::from_millis(10)); + } + }); + + // 2. Client connecte via l'autre bout du socket + c_stream.set_nonblocking(false).unwrap(); + let backend = Backend::connect(c_stream).unwrap(); + let conn = Connection::from_backend(backend); + let mut event_queue = conn.new_event_queue::(); + let qh = event_queue.handle(); + let display = conn.display(); + let _reg1 = display.get_registry(&qh, ()); + + let mut state = ClientState::default(); + event_queue.roundtrip(&mut state).expect("roundtrip registry"); + + let global_name = state.global_name.expect("wl_output non advertised"); + + // 3. Bind aux 3 versions + let registry = display.get_registry(&qh, ()); + for (idx, version) in [(0usize, 1u32), (1, 2), (2, 3)].iter() { + state.cur = Some(*idx); + let _o = registry.bind::(global_name, *version, &qh, ()); + for _ in 0..3 { + event_queue.roundtrip(&mut state).expect("roundtrip bind"); + } + state.cur = None; + } + + // 4. Drop client to make server exit + drop(conn); + server_thread.join().unwrap(); + + // 5. Asserts + let v1 = &state.obs[0]; + assert!(v1.seen_geometry, "v1: geometry attendu"); + assert!(v1.seen_mode, "v1: mode attendu"); + assert!(!v1.seen_scale, "v1: scale ne devrait PAS être envoyé"); + assert!(!v1.seen_done, "v1: done ne devrait PAS être envoyé"); + + let v2 = &state.obs[1]; + assert!(v2.seen_geometry, "v2: geometry attendu"); + assert!(v2.seen_mode, "v2: mode attendu"); + assert!( + !v2.seen_scale, + "v2: scale ne devrait PAS être envoyé (gating conservateur v3+)" + ); + assert!(v2.seen_done, "v2: done attendu"); + + let v3 = &state.obs[2]; + assert!(v3.seen_geometry, "v3: geometry attendu"); + assert!(v3.seen_mode, "v3: mode attendu"); + assert!(v3.seen_scale, "v3: scale attendu"); + assert!(v3.seen_done, "v3: done attendu"); + assert_eq!(v3.scale_factor, 1, "v3: scale_factor doit être 1"); + assert!( + v3.mode_flags & 0x1 != 0, + "v3: mode flags doit contenir CURRENT (0x1)" + ); + assert!( + v3.mode_flags & 0x2 != 0, + "v3: mode flags doit contenir PREFERRED (0x2)" + ); +} diff --git a/crates/redox-wl-test-wl-output/tests/subcompositor_native.rs b/crates/redox-wl-test-wl-output/tests/subcompositor_native.rs new file mode 100644 index 0000000..40646c6 --- /dev/null +++ b/crates/redox-wl-test-wl-output/tests/subcompositor_native.rs @@ -0,0 +1,364 @@ +//! Test natif (CachyOS, hors QEMU) : wl_subcompositor protocole (phase +//! 13.2.b.1). +//! +//! Vérifie que : +//! 1. `wl_subcompositor` est annoncé comme global à v1 +//! 2. Le bind du global réussit +//! 3. `get_subsurface(child, parent)` instancie un `wl_subsurface` sans +//! erreur protocole +//! 4. `set_position`, `set_sync`, `set_desync` ne génèrent pas d'erreur +//! 5. `destroy` sur le wl_subsurface ne génère pas d'erreur +//! +//! Logique serveur dupliquée verbatim de redox-wl-wayland-frontend/src/lib.rs +//! (GlobalDispatch + Dispatch pour wl_subcompositor et wl_subsurface + +//! minimum pour wl_compositor pour pouvoir créer 2 wl_surfaces). +//! +//! Si une erreur protocole est envoyée (bad_surface, etc.), wayland-client +//! propage en error sur l'event_queue, et le test panique avec un message +//! clair. + +use std::os::unix::net::UnixStream; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use wayland_server::backend::{ClientData, ClientId, DisconnectReason}; +use wayland_server::{ + protocol::{ + wl_compositor as srv_wl_compositor, wl_subcompositor as srv_wl_subcompositor, + wl_subsurface as srv_wl_subsurface, wl_surface as srv_wl_surface, + }, + Client as SrvClient, DataInit, Display, DisplayHandle, GlobalDispatch, +}; + +use wayland_client::{ + backend::Backend, + protocol::{wl_compositor, wl_registry, wl_subcompositor, wl_subsurface, wl_surface}, + Connection, Dispatch as CliDispatch, QueueHandle, +}; + +const COMPOSITOR_VERSION: u32 = 5; +const SUBCOMPOSITOR_VERSION: u32 = 1; + +// ============= server-side: state minimal ============= + +struct ServerState; + +struct StubClientData; +impl ClientData for StubClientData { + fn initialized(&self, _: ClientId) {} + fn disconnected(&self, _: ClientId, _: DisconnectReason) {} +} + +// Données par-wl_subsurface, duplicate de notre prod code. +#[allow(dead_code)] +struct SubsurfaceData { + parent: srv_wl_surface::WlSurface, + child: srv_wl_surface::WlSurface, + position: Mutex<(i32, i32)>, + sync: AtomicBool, +} + +// --- wl_compositor (minimum pour fabriquer des wl_surfaces) --- + +impl GlobalDispatch for ServerState { + fn bind( + _state: &mut Self, + _handle: &DisplayHandle, + _client: &SrvClient, + resource: wayland_server::New, + _data: &(), + data_init: &mut DataInit<'_, Self>, + ) { + data_init.init(resource, ()); + } +} + +impl wayland_server::Dispatch for ServerState { + fn request( + _state: &mut Self, + _client: &SrvClient, + _r: &srv_wl_compositor::WlCompositor, + request: srv_wl_compositor::Request, + _data: &(), + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + if let srv_wl_compositor::Request::CreateSurface { id } = request { + data_init.init(id, ()); + } + } +} + +impl wayland_server::Dispatch for ServerState { + fn request( + _state: &mut Self, + _client: &SrvClient, + _r: &srv_wl_surface::WlSurface, + _request: srv_wl_surface::Request, + _data: &(), + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + } +} + +// --- wl_subcompositor (sous test) --- + +impl GlobalDispatch for ServerState { + fn bind( + _state: &mut Self, + _handle: &DisplayHandle, + _client: &SrvClient, + resource: wayland_server::New, + _data: &(), + data_init: &mut DataInit<'_, Self>, + ) { + data_init.init(resource, ()); + } +} + +impl wayland_server::Dispatch for ServerState { + fn request( + _state: &mut Self, + _client: &SrvClient, + _r: &srv_wl_subcompositor::WlSubcompositor, + request: srv_wl_subcompositor::Request, + _data: &(), + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + match request { + srv_wl_subcompositor::Request::Destroy => {} + srv_wl_subcompositor::Request::GetSubsurface { + id, + surface, + parent, + } => { + let data = Arc::new(SubsurfaceData { + parent, + child: surface, + position: Mutex::new((0, 0)), + sync: AtomicBool::new(true), + }); + data_init.init(id, data); + } + _ => {} + } + } +} + +impl wayland_server::Dispatch> + for ServerState +{ + fn request( + _state: &mut Self, + _client: &SrvClient, + _r: &srv_wl_subsurface::WlSubsurface, + request: srv_wl_subsurface::Request, + data: &Arc, + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + match request { + srv_wl_subsurface::Request::Destroy => {} + srv_wl_subsurface::Request::SetPosition { x, y } => { + *data.position.lock().unwrap() = (x, y); + } + srv_wl_subsurface::Request::PlaceAbove { sibling: _ } + | srv_wl_subsurface::Request::PlaceBelow { sibling: _ } => {} + srv_wl_subsurface::Request::SetSync => { + data.sync.store(true, Ordering::Relaxed); + } + srv_wl_subsurface::Request::SetDesync => { + data.sync.store(false, Ordering::Relaxed); + } + _ => {} + } + } +} + +// ============= client-side ============= + +#[derive(Default)] +struct ClientState { + compositor_global: Option, + subcompositor_global: Option, + subcompositor_version: Option, + last_error: Option, +} + +impl CliDispatch for ClientState { + fn event( + state: &mut Self, + _: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version, + } = event + { + if interface == "wl_compositor" { + state.compositor_global = Some(name); + } else if interface == "wl_subcompositor" { + state.subcompositor_global = Some(name); + state.subcompositor_version = Some(version); + } + } + } +} + +impl CliDispatch for ClientState { + fn event( + _: &mut Self, + _: &wl_compositor::WlCompositor, + _: wl_compositor::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl CliDispatch for ClientState { + fn event( + _: &mut Self, + _: &wl_surface::WlSurface, + _: wl_surface::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl CliDispatch for ClientState { + fn event( + _: &mut Self, + _: &wl_subcompositor::WlSubcompositor, + _: wl_subcompositor::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl CliDispatch for ClientState { + fn event( + _: &mut Self, + _: &wl_subsurface::WlSubsurface, + _: wl_subsurface::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +// ============= test ============= + +#[test] +fn wl_subcompositor_protocol_full_cycle() { + let (s_stream, c_stream) = UnixStream::pair().expect("UnixStream::pair"); + + let server_thread = thread::spawn(move || { + let mut display: Display = Display::new().unwrap(); + let dh = display.handle(); + dh.create_global::( + COMPOSITOR_VERSION, + (), + ); + dh.create_global::( + SUBCOMPOSITOR_VERSION, + (), + ); + + s_stream.set_nonblocking(true).unwrap(); + let _client = display + .handle() + .insert_client(s_stream, Arc::new(StubClientData)) + .unwrap(); + + let mut state = ServerState; + let start = std::time::Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + let _ = display.dispatch_clients(&mut state); + let _ = display.flush_clients(); + thread::sleep(Duration::from_millis(10)); + } + }); + + c_stream.set_nonblocking(false).unwrap(); + let backend = Backend::connect(c_stream).unwrap(); + let conn = Connection::from_backend(backend); + let mut event_queue = conn.new_event_queue::(); + let qh = event_queue.handle(); + let display = conn.display(); + let _reg = display.get_registry(&qh, ()); + + let mut state = ClientState::default(); + event_queue + .roundtrip(&mut state) + .expect("roundtrip registry"); + + // 1. Vérifs annonces globals + let compositor_name = state.compositor_global.expect("wl_compositor non annoncé"); + let subcomp_name = state + .subcompositor_global + .expect("wl_subcompositor non annoncé"); + assert_eq!( + state.subcompositor_version, + Some(1), + "wl_subcompositor doit être annoncé à v1, got {:?}", + state.subcompositor_version + ); + + // 2. Bind wl_compositor pour créer 2 surfaces + let registry = display.get_registry(&qh, ()); + let compositor = + registry.bind::(compositor_name, 5, &qh, ()); + let parent = compositor.create_surface(&qh, ()); + let child = compositor.create_surface(&qh, ()); + + // 3. Bind wl_subcompositor + get_subsurface + let subcomp = + registry.bind::(subcomp_name, 1, &qh, ()); + let subsurface = subcomp.get_subsurface(&child, &parent, &qh, ()); + + // 4. Exercer set_position / set_sync / set_desync + subsurface.set_position(50, 50); + subsurface.set_sync(); + subsurface.set_desync(); + subsurface.place_above(&parent); // valable même si parent n'est pas sibling — on no-op côté serveur + subsurface.set_position(100, 100); + + // 5. Roundtrip pour driver tous les events et capter d'éventuelles erreurs + for _ in 0..5 { + event_queue + .roundtrip(&mut state) + .expect("roundtrip pendant exercise"); + } + + // 6. Destroy + subsurface.destroy(); + event_queue + .roundtrip(&mut state) + .expect("roundtrip après destroy"); + + // 7. Cleanup + drop(conn); + server_thread.join().unwrap(); + + assert!( + state.last_error.is_none(), + "Erreur protocole détectée : {:?}", + state.last_error + ); +} diff --git a/crates/redox-wl-wayland-frontend/src/lib.rs b/crates/redox-wl-wayland-frontend/src/lib.rs index 2e08dab..938e5a1 100644 --- a/crates/redox-wl-wayland-frontend/src/lib.rs +++ b/crates/redox-wl-wayland-frontend/src/lib.rs @@ -36,8 +36,8 @@ use wayland_protocols::xdg::shell::server::{ use wayland_server::{ backend::{ClientData, ClientId, DisconnectReason}, protocol::{ - wl_buffer, wl_callback, wl_compositor, wl_keyboard, wl_pointer, wl_region, wl_seat, wl_shm, - wl_shm_pool, wl_surface, + wl_buffer, wl_callback, wl_compositor, wl_keyboard, wl_output, wl_pointer, wl_region, + wl_seat, wl_shm, wl_shm_pool, wl_subcompositor, wl_subsurface, wl_surface, }, Client, DataInit, Display as WlDisplay, DisplayHandle, GlobalDispatch, Resource, }; @@ -48,6 +48,14 @@ const COMPOSITOR_VERSION: u32 = 5; const SHM_VERSION: u32 = 1; const XDG_WM_BASE_VERSION: u32 = 5; const SEAT_VERSION: u32 = 7; +// Phase 13.2.a : wl_output v3 (couvre geometry + mode + scale + done + +// release request, sans v4 name/description qu'on ne fournit pas encore). +const OUTPUT_VERSION: u32 = 3; +// Phase 13.2.b.1 : wl_subcompositor v1 (la seule version qui existe ; +// pas d'évolution depuis l'introduction du protocole). Implémentation +// "protocole only" — bind + handlers ACK les requests, mais le rendering +// des subsurfaces sera wiré en 13.2.b.2. +const SUBCOMPOSITOR_VERSION: u32 = 1; /// Taille suggérée par défaut pour les nouvelles fenêtres xdg_toplevel. /// Le client peut respecter ou non ; on utilise sa propre taille de buffer @@ -178,6 +186,11 @@ struct SurfaceData { /// surfaces d'un client déconnecté (à ce moment-là `surf.client()` /// retourne déjà None, donc on ne pourrait pas re-déduire le ClientId). client_id: Mutex>, + /// Phase 13.2.b.2 : si cette surface est une subsurface, lien vers les + /// données du wl_subsurface qui la pilote (parent ref, offset, sync). + /// Setté par `wl_subcompositor.GetSubsurface`. Lu au commit de cette + /// surface pour calculer sa position absolue = parent.pos + offset. + subsurface_link: Mutex>>, } /// Données par-xdg_surface : référence à la wl_surface sous-jacente + @@ -190,6 +203,23 @@ struct XdgSurfaceData { acked_serial: Mutex, } +/// Phase 13.2.b.1 — Données par-wl_subsurface. +/// +/// État protocolaire d'une subsurface : référence vers sa surface enfant +/// et son parent, position en pending (modifiable via SetPosition), +/// mode sync/desync. Pour 13.2.b.1 ces valeurs sont stockées mais pas +/// encore consommées par compose_into (rendering wiré en 13.2.b.2). +/// +/// Sync mode : default = true (synchronized) per spec. En mode sync, +/// les changements de la subsurface n'apparaissent qu'au commit du +/// parent. En desync, ils apparaissent au commit de la subsurface elle-même. +struct SubsurfaceData { + parent: wl_surface::WlSurface, + child: wl_surface::WlSurface, + position: Mutex<(i32, i32)>, + sync: AtomicBool, +} + /// Données par-xdg_toplevel : title, app_id, ref vers son xdg_surface. #[derive(Default)] struct XdgToplevelData { @@ -374,6 +404,11 @@ pub struct WaylandFrontend { /// Mise à jour par `forward_input(PointerMotion / PointerMotionRelative)`. cursor_x: i32, cursor_y: i32, + /// Phase 13.1.b : bornes écran pour clamper le cursor. Si non + /// défini par `set_screen_size`, valeurs i32::MAX → le clamp est un + /// no-op (compat tests qui ne configurent pas le frontend). + screen_w: i32, + screen_h: i32, /// Counter monotone pour les serials seat events (différent de next_xdg_serial). next_input_serial: u32, /// Timestamp incrémental pour les events seat (ms-like). @@ -429,6 +464,17 @@ impl WaylandFrontend { dh.create_global::(SHM_VERSION, ()); dh.create_global::(XDG_WM_BASE_VERSION, ()); dh.create_global::(SEAT_VERSION, ()); + // Phase 13.2.a : wl_output déclaré comme global. Single output + // mappé au framebuffer du compositor. Les events init (geometry/ + // mode/scale/done) sont envoyés dans le bind, cf GlobalDispatch + // plus bas. + dh.create_global::(OUTPUT_VERSION, ()); + // Phase 13.2.b.1 : wl_subcompositor pour les surfaces parent-enfant. + // Bind + acceptation des requests, pas encore de rendering (13.2.b.2). + dh.create_global::( + SUBCOMPOSITOR_VERSION, + (), + ); let listener = wayland_server::ListeningSocket::bind_absolute(socket_path.to_path_buf())?; @@ -445,6 +491,8 @@ impl WaylandFrontend { focused_surface: None, cursor_x: 0, cursor_y: 0, + screen_w: i32::MAX, + screen_h: i32::MAX, next_input_serial: 1, input_time_ms: 0, cursor_surface_id: None, @@ -816,12 +864,15 @@ impl WaylandFrontend { }; let time = self.alloc_input_time(); let serial = self.alloc_input_serial(); - // Wayland keycodes = scancode evdev = scancode +8 on linux - // (cf xkb_keycodes minimum=8). On garde le scancode brut - // ici en attendant une keymap correcte (US Linux evdev offset - // ne s'applique pas forcément à orbclient ; à durcir - // quand on aura une vraie keymap XKB). - let key = (*scancode as u32).saturating_add(8); + // Phase 13.1.b : Wayland `wl_keyboard.key` attend le keycode + // **evdev** brut, pas X11. L'offset +8 est entre evdev et + // X11 (historique X11 où KeyCode minimum = 8), pas entre + // PS/2 et evdev — pour les touches alphanumériques de base, + // PS/2 Set 1 scancode == evdev keycode (ESC=1, Q=16, etc.). + // Bug originel : on envoyait scancode+8 → simple_window + // upstream qui teste `key == 1` pour ESC recevait 9 et + // n'exitait jamais. + let key = *scancode as u32; let state = if *pressed { wl_keyboard::KeyState::Pressed } else { @@ -834,8 +885,23 @@ impl WaylandFrontend { } } RedoxInputEvent::PointerMotion { x, y } => { - self.cursor_x = *x; - self.cursor_y = *y; + // Phase 13.1.c : ps2d (sous Redox+QEMU) délivre des coords + // absolues en 0..65535 (cf orbital/src/scheme.rs:1575-1587, + // commentaire upstream « ps2d gives us absolute mouse events + // with x and y in the range 0..65535 »). On scale vers les + // pixels écran avec la même formule qu'orbital : x*screen_w/65536. + // Le diviseur 65536 (pas 65535) est volontaire — fixed-point + // 16-bit, donne max=screen-1 quand input=65535. + let scaled_x = ((*x as i64) * (self.screen_w as i64)) / 65536; + let scaled_y = ((*y as i64) * (self.screen_h as i64)) / 65536; + tracing::debug!( + target: "cursor", + "ABS x={x} y={y} → pixel ({scaled_x},{scaled_y}) screen={}x{}", + self.screen_w, self.screen_h + ); + self.cursor_x = scaled_x as i32; + self.cursor_y = scaled_y as i32; + self.clamp_cursor(); self.cursor_visible = true; // Phase 7.7 : si drag actif, déplacer la surface au lieu // d'envoyer motion au client. @@ -857,8 +923,22 @@ impl WaylandFrontend { } } RedoxInputEvent::PointerMotionRelative { dx, dy } => { + let before_x = self.cursor_x; + let before_y = self.cursor_y; self.cursor_x = self.cursor_x.saturating_add(*dx); self.cursor_y = self.cursor_y.saturating_add(*dy); + let after_raw_x = self.cursor_x; + let after_raw_y = self.cursor_y; + self.clamp_cursor(); + // Phase 13.1.c : trace dx/dy + cursor avant/après pour + // identifier si les deltas dérivent toujours positivement + // (axe inversé ? cumul d'edge ?) ou si le clamp masque le + // problème. + tracing::debug!( + target: "cursor", + "REL dx={dx} dy={dy} before=({before_x},{before_y}) raw=({after_raw_x},{after_raw_y}) clamped=({},{})", + self.cursor_x, self.cursor_y + ); self.cursor_visible = true; // Phase 7.7 : si drag actif, déplacer la surface. if self.apply_interactive_drag() { @@ -1089,6 +1169,26 @@ impl WaylandFrontend { self.cursor_x = x; self.cursor_y = y; self.cursor_visible = true; + self.clamp_cursor(); + } + + /// Phase 13.1.b : déclare la taille du framebuffer pour borner le + /// curseur. Sans ça, les deltas `PointerMotionRelative` (PS/2 sous QEMU) + /// s'accumulent et finissent par sortir l'écran → tous les hit_test + /// retournent None → aucune fenêtre ne reçoit le focus → ESC tombe + /// dans le vide. Doit être appelé après `bind_absolute` et idéalement + /// re-appelé si la résolution change. + pub fn set_screen_size(&mut self, w: i32, h: i32) { + self.screen_w = w.max(1); + self.screen_h = h.max(1); + self.clamp_cursor(); + } + + fn clamp_cursor(&mut self) { + let max_x = (self.screen_w - 1).max(0); + let max_y = (self.screen_h - 1).max(0); + self.cursor_x = self.cursor_x.clamp(0, max_x); + self.cursor_y = self.cursor_y.clamp(0, max_y); } /// Force la position du curseur à tout moment. Utile pour tests @@ -1101,6 +1201,7 @@ impl WaylandFrontend { pub fn set_cursor_position(&mut self, x: i32, y: i32) { self.cursor_x = x; self.cursor_y = y; + self.clamp_cursor(); self.cursor_visible = true; let _ = self.apply_interactive_drag(); } @@ -1217,6 +1318,7 @@ impl wayland_server::Dispatch for WaylandFronte // est encore vivant. À la déconnexion, `surf.client()` // retournera None et on ne pourrait plus déduire ce mapping. client_id: Mutex::new(Some(_client.id())), + subsurface_link: Mutex::new(None), }; let surf = data_init.init(id, Arc::new(data)); // Phase 7.4 : enregistrer le mapping SurfaceId → WlSurface @@ -1425,6 +1527,35 @@ impl wayland_server::Dispatch> for Wayla } let is_cursor = data.is_cursor.load(Ordering::Relaxed); + // Phase 13.2.b.2 : si cette surface est une subsurface, + // recalculer sa position absolue à partir de parent.pos + + // offset, avant que le commit applique pending→current. + // Détection via subsurface_link, peuplé par GetSubsurface. + let subsurface_link = data.subsurface_link.lock().unwrap().clone(); + let is_subsurface = subsurface_link.is_some(); + if let Some(sub_data) = &subsurface_link { + let parent_id_opt = sub_data + .parent + .data::>() + .and_then(|sd| *sd.id.lock().unwrap()); + if let Some(parent_id) = parent_id_opt { + let parent_pos = state.registry.get(parent_id).map(|s| { + let cs = s.current(); + (cs.x, cs.y) + }); + if let Some((px, py)) = parent_pos { + let (ox, oy) = *sub_data.position.lock().unwrap(); + state.registry.modify_pending(id, |s| { + s.x = px + ox; + s.y = py + oy; + }); + tracing::debug!( + "subsurface commit: pos absolue = parent({},{}) + offset({},{}) = ({},{})", + px, py, ox, oy, px + ox, py + oy + ); + } + } + } // Lire le buffer attaché (s'il y en a un). On `take()` pour // que le pending_buffer soit vidé après chaque commit @@ -1464,11 +1595,44 @@ impl wayland_server::Dispatch> for Wayla buf.release(); } state.registry.commit(id); - if !is_cursor { + if !is_cursor && !is_subsurface { // Promouvoir au top du Z-order au commit (politique simple : // dernière surface qui commit = au-dessus). À raffiner en // phase 7 (focus, raise on click, etc.). + // Phase 13.2.b.2 : les subsurfaces ne raisent pas + // indépendamment de leur parent ; elles vivent à + // parent.z + 1 par convention de leur création. state.registry.raise(id); + + // Phase 13.2.b.2 fix bug visuel : si on vient de raise un + // parent toplevel, raise aussi toutes ses subsurfaces + // pour qu'elles restent au-dessus dans le z_order. Sinon + // un re-commit du parent (sans buffer) après création + // des subsurfaces les masquerait. Cas observé sur le + // client de test 13.2.b.3 où le parent.commit() final + // de rafraîchissement faisait disparaître le carré rouge. + let parent_id_to_match = id; + let children_to_raise: Vec = state + .surfaces_by_id + .iter() + .filter_map(|(sid, surf)| { + let sd = surf.data::>()?; + let link = sd.subsurface_link.lock().unwrap(); + let ld = link.as_ref()?; + let parent_sid = ld + .parent + .data::>() + .and_then(|psd| *psd.id.lock().unwrap())?; + if parent_sid == parent_id_to_match { + Some(*sid) + } else { + None + } + }) + .collect(); + for cid in children_to_raise { + state.registry.raise(cid); + } } // Frame callbacks en attente → bump dans la queue globale @@ -1476,11 +1640,13 @@ impl wayland_server::Dispatch> for Wayla state.frame_callbacks.append(&mut *cbs); drop(cbs); - if !is_cursor { + if !is_cursor && !is_subsurface { // Phase 7.2 : la surface qui vient de commiter et raise // devient automatiquement la surface focalisée. Envoie les // events keyboard/pointer enter/leave en conséquence. // Une surface curseur n'a évidemment pas le focus — on skip. + // Phase 13.2.b.2 : pareil pour les subsurfaces — le + // focus reste sur le parent toplevel. state.set_focus(Some(_resource.clone())); } } @@ -2097,6 +2263,215 @@ impl wayland_server::Dispatch> f } } +// ===================================================================== +// wl_output (phase 13.2.a) +// ===================================================================== +// +// Single output mappé sur le framebuffer du compositor. Les events +// d'init (geometry / mode / scale / done) sont envoyés dans `bind` à +// chaque nouvelle resource bound par un client, gated par la version +// négociée : +// - geometry, mode : v1+, toujours envoyés +// - scale : v3+ (gating conservateur ; spec autorise v2+ mais on évite +// l'ambiguïté sur les clients v2 qui pourraient mal gérer) +// - done : v2+ +// La taille pixel vient de `screen_w / screen_h`, setté par +// `set_screen_size()` au démarrage du compositor. Refresh hardcodé à +// 60 Hz (60000 mHz) — notre boucle main tourne à ~30 fps mais les apps +// modernes ignorent largement ce champ, et 60 Hz est un standard sûr. + +impl GlobalDispatch for WaylandFrontend { + fn bind( + state: &mut Self, + _handle: &DisplayHandle, + _client: &Client, + resource: wayland_server::New, + _data: &(), + data_init: &mut DataInit<'_, Self>, + ) { + let output = data_init.init(resource, ()); + let version = output.version(); + + // geometry : position dans l'espace logique multi-écran (0,0 + // ici), taille physique inconnue (0 mm, valeur "ne sait pas" par + // convention Wayland), subpixel/make/model/transform placeholders. + output.geometry( + 0, + 0, + 0, + 0, + wl_output::Subpixel::Unknown, + "Redox".to_string(), + "redox-wl-output:0".to_string(), + wl_output::Transform::Normal, + ); + + // mode : taille pixel courante = framebuffer. Flags Current + + // Preferred (un seul mode, c'est de facto le préféré). + let mode_flags = wl_output::Mode::Current | wl_output::Mode::Preferred; + output.mode(mode_flags, state.screen_w, state.screen_h, 60_000); + + // scale v3+ : facteur 1 (pas de HiDPI pour notre framebuffer). + if version >= 3 { + output.scale(1); + } + + // done v2+ : signale la fin du batch init pour que le client + // ne dispatche son OutputInfo qu'une fois tous les events arrivés. + if version >= 2 { + output.done(); + } + } +} + +impl wayland_server::Dispatch for WaylandFrontend { + fn request( + _state: &mut Self, + _client: &Client, + _r: &wl_output::WlOutput, + request: wl_output::Request, + _data: &(), + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + match request { + wl_output::Request::Release => { + // v3+ : le client libère son binding wl_output. La resource + // est nettoyée automatiquement par wayland-server quand on + // ne traite pas d'autre request — on no-op simplement. + } + _ => {} + } + } +} + +// ===================================================================== +// wl_subcompositor / wl_subsurface (phase 13.2.b.1) +// ===================================================================== +// +// Scope 13.2.b.1 : implémentation protocolaire uniquement. On accepte le +// bind du global, on instancie wl_subsurface dans GetSubsurface et on +// route correctement les requests SetPosition / PlaceAbove,Below / +// SetSync,Desync / Destroy. La donnée est stockée dans SubsurfaceData. +// +// Ce qui N'est PAS fait ici (reporté à 13.2.b.2) : +// - rendering effectif des subsurfaces (compose_into ignore encore les +// SubsurfaceData associés aux SurfaceData enfants) ; +// - hit-test des subsurfaces ; +// - cascade sync : un commit du parent doit valider tous les state +// pending des subsurfaces en mode sync ; +// - vérification du « rôle unique » (spec : une wl_surface ne peut +// avoir qu'un seul rôle de vie ; on devrait refuser GetSubsurface +// sur une surface déjà-xdg_toplevel). Pour l'instant on log un warn +// si on détecte le cas, mais on n'envoie pas bad_surface. + +impl GlobalDispatch for WaylandFrontend { + fn bind( + _state: &mut Self, + _handle: &DisplayHandle, + _client: &Client, + resource: wayland_server::New, + _data: &(), + data_init: &mut DataInit<'_, Self>, + ) { + data_init.init(resource, ()); + } +} + +impl wayland_server::Dispatch for WaylandFrontend { + fn request( + _state: &mut Self, + _client: &Client, + _r: &wl_subcompositor::WlSubcompositor, + request: wl_subcompositor::Request, + _data: &(), + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + match request { + wl_subcompositor::Request::Destroy => { + // No-op : wayland-server nettoie la resource automatiquement. + } + wl_subcompositor::Request::GetSubsurface { + id, + surface, + parent, + } => { + // Vérif soft : rôle déjà attribué ? (xdg_toplevel notamment). + // Pour 13.2.b.1 on se contente de logger ; le protocole exige + // un bad_surface error mais on attendra une phase ultérieure + // pour l'enforcer (role-tracking). + if let Some(child_data) = surface.data::>() { + let id_lock = child_data.id.lock().unwrap(); + if id_lock.is_some() { + tracing::debug!( + "GetSubsurface: surface child déjà dans registry (id={:?})", + *id_lock + ); + } + } + let data = Arc::new(SubsurfaceData { + parent: parent.clone(), + child: surface.clone(), + position: Mutex::new((0, 0)), + sync: AtomicBool::new(true), // default = sync per spec + }); + // Phase 13.2.b.2 : lier la SurfaceData de la child au + // SubsurfaceData pour que le commit puisse calculer la + // position absolue parent.pos + offset. + if let Some(child_sd) = surface.data::>() { + *child_sd.subsurface_link.lock().unwrap() = Some(data.clone()); + } + data_init.init(id, data); + tracing::debug!("wl_subcompositor.get_subsurface: subsurface créée"); + } + _ => {} + } + } +} + +impl wayland_server::Dispatch> + for WaylandFrontend +{ + fn request( + _state: &mut Self, + _client: &Client, + _r: &wl_subsurface::WlSubsurface, + request: wl_subsurface::Request, + data: &Arc, + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + match request { + wl_subsurface::Request::Destroy => { + // Spec : la wl_surface enfant est unmapped immédiatement, + // perd sa position et son z-order. Pour 13.2.b.1 on no-op + // (pas de mapping registered, donc rien à unmap côté + // rendering qui n'existe pas encore). + } + wl_subsurface::Request::SetPosition { x, y } => { + *data.position.lock().unwrap() = (x, y); + tracing::debug!("wl_subsurface.set_position({x}, {y})"); + } + wl_subsurface::Request::PlaceAbove { sibling: _ } + | wl_subsurface::Request::PlaceBelow { sibling: _ } => { + // z-order relatif entre subsurfaces siblings. + // Single-subsurface use-case 13.2.b.1 : no-op. + tracing::debug!("wl_subsurface.place_above/below (no-op pour 13.2.b.1)"); + } + wl_subsurface::Request::SetSync => { + data.sync.store(true, Ordering::Relaxed); + tracing::debug!("wl_subsurface.set_sync"); + } + wl_subsurface::Request::SetDesync => { + data.sync.store(false, Ordering::Relaxed); + tracing::debug!("wl_subsurface.set_desync"); + } + _ => {} + } + } +} + // --------------------------------------------------------------------------- // Tests unitaires xdg-shell (sprint 0 point 4). // diff --git a/docs/phase13-1-b-observations.md b/docs/phase13-1-b-observations.md new file mode 100644 index 0000000..ba0d2d6 --- /dev/null +++ b/docs/phase13-1-b-observations.md @@ -0,0 +1,298 @@ +# Phase 13.1.b — Observation runtime du client tiers `simple_window` + +> Document produit le 2026-05-16, suite de +> [`phase13-1-real-client-simple-window.md`](phase13-1-real-client-simple-window.md). +> +> **Scope** : observer le comportement runtime de +> `redox-wl-real-client-simple-window` (port Redox de l'exemple upstream +> wayland-rs) face au compositor, consigner les déviations vs l'attendu, +> appliquer les fixes nécessaires côté **compositor uniquement** (le code +> client reste verbatim upstream sauf 4 adaptations Redox documentées en 13.1). +> +> **Verdict** : ✅ **13.1.b validée** — 6/6 checkpoints OK après application +> de 3 fixes ciblés. + +## Verdict + +``` +[real-client] simple_window port Redox — start +[real-client] entering event loop +INFO redox_wl_wayland_frontend: focus change: None → Some(SurfaceId(0)) +[real-client] ESC → exit +[real-client] loop exited cleanly +[real-client] PASS +INFO redox_wl_wayland_frontend: garbage_collect: client … → destroyed 1 surfaces +``` + +Le client tiers, **non écrit par nous**, traverse le pipeline complet : +connexion socket, bind des globals, création toplevel, commit buffer +shm, focus auto-grant à la création de surface, réception `wl_keyboard.key` +sur ESC, sortie propre, garbage collection côté compositor. + +## Checklist 6/6 (cf. `phase13-1-real-client-simple-window.md:137-153`) + +| # | Vérif | Résultat | Évidence | +|---|---|---|---| +| 1 | Connexion socket | ✅ | `[real-client] entering event loop` | +| 2 | Globals reçus (`wl_compositor`, `wl_shm`, `wl_seat`, `xdg_wm_base`) | ✅ | Surface créée (sinon le client échouerait au bind) | +| 3 | Configure initial ack | ✅ | Implicite : le client atteint son event loop et dessine | +| 4 | Premier pixel — gradient ARGB visible | ✅ | Confirmation visuelle utilisateur sur fenêtre QEMU graphique | +| 5 | **ESC ferme le client proprement** | ✅ | `[real-client] ESC → exit` + `[real-client] PASS` | +| 6 | Sortie propre, pas de FAIL | ✅ | `[real-client] loop exited cleanly` + garbage collect côté compo | + +## Fixes appliqués + +Les bugs ci-dessous étaient **invisibles** dans nos clients de test 6.x/7.x +parce qu'ils étaient adaptés à notre compositor (notamment l'offset +8 +côté key). Précisément la valeur d'un client tiers : il révèle les paths +que nos clients maison cachent. Anticipation correcte de la doc 13.1 +(« Pas écrit par nous : c'est sa principale qualité »). + +### Fix 1 — ESC mangée par le compositor au lieu d'être forwardée + +**Symptôme** : à l'appui sur ESC, le compositor exitait et fermait le socket, +le client recevait `Broken pipe (os error 32)` avant d'avoir pu traiter +sa propre logique ESC. Résultat : `[real-client] FAIL: Backend error: …`. + +**Cause** (`crates/redox-wl-compositor/src/main.rs:183-191` avant fix) : + +```rust +InputEvent::Key { scancode, pressed, .. } if *pressed && *scancode == 0x01 => { + info!("Esc → exit"); + let _ = frontend.flush_clients(); + let _ = std::fs::remove_file(SOCKET_PATH); + return Ok(()); // <-- exit AVANT forward_input() +} +``` + +`forward_input(&ev)` à la ligne 202 n'était jamais atteint pour ESC. + +**Fix** : ESC retombe dans `_ => {}` → forwarded normalement. +Raccourci compositor déplacé sur `Ctrl+Q` (scancode Q = `0x10` + état +`ctrl_held` tracké manuellement, scancode Ctrl L = `0x1D`). + +Modifs : +- Ajout `ctrl_held: bool` au-dessus de la boucle main +- Tracking du Ctrl avant le `match &ev` +- Remplacement de l'arm ESC par `scancode == 0x10 && ctrl_held` + +### Fix 2 — Cursor s'accumule hors-écran (deltas PS/2 non bornés) + +**Symptôme** : `left-click @ (10444, 10566) → hit_test = None` (écran 1440×900). +Les clics se faisaient à des coordonnées qui croissaient sans limite, +aucune fenêtre ne matchait jamais le hit_test → aucune surface ne pouvait +être focalisée par clic. + +**Cause** (`crates/redox-wl-wayland-frontend/src/lib.rs:860-861` avant fix) : + +```rust +RedoxInputEvent::PointerMotionRelative { dx, dy } => { + self.cursor_x = self.cursor_x.saturating_add(*dx); + self.cursor_y = self.cursor_y.saturating_add(*dy); + // ... aucun clamp +} +``` + +Sous QEMU PS/2, le compositor reçoit des deltas relatifs. Sans clamp, +ils s'accumulent vers l'infini (limité par `saturating_add` mais à +i32::MAX bien au-delà de l'écran). + +**Fix** : ajout d'un état `screen_w/screen_h` au frontend (init +`i32::MAX` = no-op tant que pas configuré), setter `set_screen_size(w, h)` +appelé par le compositor après `bind_absolute`, helper `clamp_cursor()` +invoqué après chaque update cursor (les 3 chemins : `PointerMotion`, +`PointerMotionRelative`, `set_cursor_position`). + +### Fix 3 — Keycode envoyé `scancode + 8` au lieu d'evdev brut (bug racine) + +**Symptôme** : même après les fixes 1 et 2, ESC n'avait aucun effet sur +le client. Pas de `[real-client] ESC → exit` malgré le focus auto-grant +correct et le forward_input appelé. + +**Cause** (`crates/redox-wl-wayland-frontend/src/lib.rs:826-831` avant fix) : + +```rust +// Wayland keycodes = scancode evdev = scancode +8 on linux +// (cf xkb_keycodes minimum=8). On garde le scancode brut +// ici en attendant une keymap correcte ... +let key = (*scancode as u32).saturating_add(8); +``` + +**Le commentaire est faux.** L'offset +8 c'est entre **evdev et X11** +(X11 KeyCode minimum = 8 historiquement), pas entre PS/2 et evdev. Pour +les touches alphanumériques de base, **PS/2 Set 1 scancode == evdev keycode** +(ESC=1, Q=16, etc.). Wayland (spec `wl_keyboard.key`) attend des +**evdev keycodes**, pas des X11. + +Pour ESC : +- Compositor recevait scancode = `0x01` (correct) +- Envoyait `key = 0x01 + 8 = 9` au client +- Client testait `if key == 1` → `false` → ESC ignoré + +C'est précisément le path qui *passait* avec nos clients de test 6.x/7.x +parce qu'on contrôlait les deux côtés et avait probablement aligné les +constantes. simple_window upstream attend les evdev raw. + +**Fix** : suppression du `+8`. Le scancode brut suffit pour les touches +de base. À durcir quand on aura une vraie keymap XKB (ajustements pour +les touches étendues 0xE0xx). + +```rust +let key = *scancode as u32; +``` + +### Fix 4 — Event loop client : handling propre des erreurs IO transientes + +**Symptôme** : même avec les fixes 1/2/3, le `blocking_dispatch` du client +remontait une `Err(...)` sur `Interrupted` ou `BrokenPipe`/`ConnectionReset` +et le `?` final renvoyait `Err`, ce qui faisait que `main` loggait +`[real-client] FAIL` au lieu de `PASS` même en sortie propre par ESC +(le compositor flush peut générer un `Interrupted` transitoire). + +**Cause** (`crates/redox-wl-real-client-simple-window/src/main.rs`, event +loop avant fix) : + +```rust +while state.running { + event_queue.blocking_dispatch(&mut state)?; // any Err → FAIL +} +``` + +**Fix** : ajout de helpers `is_interrupted_error` / +`is_connection_closed_error` (récursifs sur la chaîne `source()` pour +attraper les erreurs wrappées par `wayland-client`), et match explicite : + +- `Interrupted` → `continue` (retry transparent) +- `BrokenPipe` / `ConnectionReset` → log `compositor disconnected → exit + cleanly` et `break` (terminaison normale si le compo s'éteint avant le + client) +- Autre erreur → `return Err(e.into())` → FAIL légitime + +⚠️ **5e adaptation Redox côté client**, qui dépasse les 4 originellement +autorisées par `phase13-1-real-client-simple-window.md:43-65`. Justification : +ce handling est **upstream-compatible** (l'exemple `simple_window.rs` +de wayland-rs aurait le même comportement sur d'autres OS si le compositor +crashait juste après un ESC). Ce n'est pas un workaround pour un manque +de notre compositor mais une robustesse standard que l'exemple upstream +ne couvre pas car il suppose un environnement de dev où le compositor ne +meurt jamais. À déclarer comme adaptation #5 dans la prochaine MAJ de la +doc 13.1. + +### Bonus — bruit console réduit + +`info!("tick=…")` toutes les ~1s dans la boucle main saturait la console +série. Passé en `debug!` + fréquence /5 (~5s). Réactivable avec +`RUST_LOG=debug`. Cf. `crates/redox-wl-compositor/src/main.rs:252-258`. + +## Bugs secondaires à isoler (hors scope 13.1.b) + +### B.1 — Curseur software ne suit pas la souris correctement + +Pendant les tests, le curseur restait visuellement coincé en bas-droite +de l'écran (typiquement `(width-1, height-1)`). Les deltas relatifs +semblaient toujours majoritairement positifs. Hypothèses à creuser : + +- Mauvaise polarité des deltas Y sous QEMU/Redox (axe inversé ?) +- Conversion absolu→relatif en amont dans `inputd` qui dérive +- Souris non-grabbed par QEMU sur fenêtre non focusée → accumulation + de moves "vers le bord" lors des allers-retours hôte/guest + +Test à faire en 13.1.c : logger systématiquement les deltas `(dx, dy)` +reçus et tracer leur somme cumulée vs déplacement réel attendu. + +### B.2 — Page fault ion sur broken pipe d'un job background + +À chaque fois qu'un client Wayland en background `&` se termine par +broken pipe (Ctrl+Q compositor avant exit client), **ion crashe** : + +``` +ion: ([Page fault: 0000000000000070 US ... +RIP: 00000000002335ae +... kernel::context::signal:INFO -- UNHANDLED EXCEPTION ... NAME /usr/bin/ion +``` + +Adresse fautive `0x70` = déréf null + offset, reproductible. Bug Redox +upstream (ion), pas notre compositor. À reporter sur +`gitlab.redox-os.org/redox-os/ion`. + +### B.3 — VT switching hôte intercepté par CachyOS + +`Ctrl+Alt+F2..F6` mangés par X/Wayland CachyOS avant QEMU, donc +impossible de switcher de VT depuis l'hôte vers un Redox guest. Pas un +bug, contrainte connue. Workaround pratique : tout faire sur le seul VT +graphique (celui que la fenêtre QEMU affiche), ou utiliser la console +série pour les commandes shell et la fenêtre graphique pour le clavier +PS/2 du compositor. + +### B.4 — Image RedoxFS étroite (682 Mo initiale) + +`pkg update` saturait le filesystem. Résolu pendant cette session par +`qemu-img resize harddrive.img 10G` + `redoxfs-resize`. À considérer si +on automatise la procédure : taille par défaut de l'image dans le +fork/script doit être ≥ 4 Go pour permettre install paquets standards. + +## Screenshots à prendre (suivant doc 13.1) + +À ajouter au repo en `docs/phase13-1-b-*.png` : + +- `phase13-1-b-gradient-running.png` — fenêtre gradient ARGB visible sur + fond bleu nuit, dans la fenêtre QEMU graphique, avant ESC. +- `phase13-1-b-after-esc.png` — fond bleu seul après ESC (le compositor + continue, la fenêtre client a disparu suite à `garbage_collect`). +- `phase13-1-b-log-pass.png` — capture console montrant + `[real-client] PASS` + `garbage_collect: ... → destroyed 1 surfaces`. + +(Optionnel — la validation textuelle des logs suffit pour le critère +de fin, mais les screenshots aident à comparer rapidement aux phases +ultérieures.) + +## Procédure de test runtime — version finale + +Mise à jour vs `phase13-1-real-client-simple-window.md:82-135` : tout +se fait dans la **fenêtre QEMU graphique** (la console série de l'hôte +ne peut pas envoyer ESC à inputd). + +### 1. Hôte CachyOS + +```bash +cd ~/Projets/Redox/redox-wayland-compositor +./run-qemu.sh +``` + +### 2. Fenêtre QEMU graphique, après login `root` / `password` + +```sh +rm -f /tmp/redox-wl-comp.sock # défensif si résidu d'un test antérieur +redox-wl-compositor & +sleep 1 +redox-wl-real-client-simple-window +``` + +### 3. Toujours sur la fenêtre graphique + +- Vérifier visuellement : fond bleu + petite fenêtre gradient ARGB +- Presser `ESC` une fois +- Attendu : `[real-client] ESC → exit`, `[real-client] loop exited cleanly`, + `[real-client] PASS`, retour au prompt +- Optionnel : `Ctrl+Q` pour arrêter le compositor (sinon il timeout à + 180s ou tu peux `kill %0` depuis le shell) + +## Critère de fin 13.1.b + +> Le client tiers `redox-wl-real-client-simple-window`, port verbatim +> de `wayland-rs/wayland-client/examples/simple_window.rs` (sauf les 4 +> adaptations Redox de 13.1), traverse le pipeline Wayland complet vers +> notre compositor jusqu'à `[real-client] PASS` sur ESC. + +**✅ Validé 2026-05-16.** 3 fixes appliqués sur le compositor, aucun +sur le code client (qui reste verbatim upstream). + +## Fichiers modifiés + +``` +crates/redox-wl-compositor/src/main.rs # Fix 1 (ESC→client, Ctrl+Q→compo) + bonus bruit +crates/redox-wl-wayland-frontend/src/lib.rs # Fix 2 (clamp cursor) + Fix 3 (keycode evdev brut) +crates/redox-wl-real-client-simple-window/src/main.rs # Fix 4 (handling IO transient dans event loop) +run-qemu.sh # cleanup défensif (check /dev/fuse, IMAGE/REDOXFS overridables) +docs/phase13-1-b-observations.md # ce document +``` diff --git a/docs/phase13-1-c-cursor.md b/docs/phase13-1-c-cursor.md new file mode 100644 index 0000000..b916d9e --- /dev/null +++ b/docs/phase13-1-c-cursor.md @@ -0,0 +1,152 @@ +# Phase 13.1.c — Curseur souris : conversion HID→pixel + +> Document produit le 2026-05-16, suite de +> [`phase13-1-b-observations.md`](phase13-1-b-observations.md). +> +> **Scope** : isoler et corriger le bug B.1 listé en 13.1.b — « curseur +> software ne suit pas la souris correctement, reste coincé en bas-droite +> de l'écran à `(width-1, height-1)` ». Diagnostic via instrumentation +> ciblée, fix conforme à l'algo d'orbital upstream, validation runtime. +> +> **Verdict** : ✅ **13.1.c validée** — le curseur suit fidèlement la +> souris, les coordonnées de clic sont dans la plage écran. + +## Cause racine + +Sous Redox + QEMU, `ps2d` délivre les coords absolues du pointer dans +un **espace virtuel 16-bit (0..65535)**, pas en pixels écran. Notre +branche `PointerMotion` faisait une assignation directe : + +```rust +self.cursor_x = *x; // x ∈ 0..65535 ⇒ cursor_x ∈ 0..65535 +``` + +Le `clamp_cursor()` ajouté en 13.1.b saturait alors à `screen_w - 1` à +chaque event → cursor coincé en bas-droite. Le bug B.1 13.1.b n'était +pas une dérive de deltas accumulés mais une **mauvaise interprétation de +l'unité d'entrée**. + +## Diagnostic + +Instrumentation `tracing::debug!(target: "cursor", ...)` ajoutée dans les +deux branches motion de `WaylandFrontend::forward_input` pour logger les +valeurs brutes reçues. Avec `RUST_LOG=info,cursor=debug` (devenu le filtre +par défaut de `init_tracing`), une session de tests motion délibérés a +révélé : + +``` +DEBUG cursor: ABS x=61642 y=15564 screen=1280x800 +DEBUG cursor: ABS x=50174 y=20970 screen=1280x800 +DEBUG cursor: ABS x=43006 y=23592 screen=1280x800 +DEBUG cursor: ABS x=42084 y=23918 screen=1280x800 +``` + +Valeurs maximales observées ≈ 62000 / 24000 sur écran 1280×800, ratio +~50× pour x et ~30× pour y. Hypothèse 16-bit confirmée par la doc +upstream orbital : + +``` +// orbital/src/scheme.rs:1576 +// ps2d gives us absolute mouse events with x and y in the range 0..65535. +``` + +## Fix + +Conversion fixed-point en pixel via la même formule qu'orbital +(`orbital/src/scheme.rs:1583-1586`) : + +```rust +let scaled_x = ((*x as i64) * (self.screen_w as i64)) / 65536; +let scaled_y = ((*y as i64) * (self.screen_h as i64)) / 65536; +self.cursor_x = scaled_x as i32; +self.cursor_y = scaled_y as i32; +self.clamp_cursor(); // garde-fou pour valeurs hors-spec +``` + +Diviseur `65536` (pas `65535`) volontaire — fixed-point 16-bit, donne +`max output = screen - 1` quand `input = 65535`. Cohérent avec +l'arithmétique d'orbital. + +Le `clamp_cursor` ajouté en 13.1.b reste utile comme garde-fou (si un +device sort de la plage 0..65535) mais ne masque plus le bug principal. + +## Validation runtime + +Test sur écran 1280×800 (fenêtre QEMU graphique, après reboot Redox +frais). Mouvement délibéré de la souris du coin haut-gauche au coin +bas-droit en passant par le centre. Logs filtrés : + +``` +DEBUG cursor: ABS x=18994 y=19660 → pixel (370,239) screen=1280x800 ← haut-gauche +DEBUG cursor: ABS x=33176 y=33094 → pixel (647,403) screen=1280x800 ← centre +DEBUG cursor: ABS x=64560 y=11714 → pixel (1260,142) screen=1280x800 ← haut-droit +DEBUG cursor: ABS x=37476 y=33176 → pixel (731,404) screen=1280x800 ← retour centre +``` + +Coordonnées pixel cohérentes avec la position physique de la souris, +toutes dans la plage `0..1279 × 0..799`. Pas de saturation au coin. + +Clics au passage : + +``` +DEBUG redox_wl_wayland_frontend: left-click @ (522, 303) → hit_test = None +DEBUG redox_wl_wayland_frontend: left-click @ (746, 175) → hit_test = None +DEBUG redox_wl_wayland_frontend: left-click @ (370, 239) → hit_test = None +``` + +`hit_test = None` attendu : aucun client n'était lancé pendant ce test. +Les coords sont en revanche bien dans l'espace écran et pourront matcher +un toplevel quand un client tournera. + +## Bonus DX (developer experience) + +Type pénible de taper `RUST_LOG=info,redox_wl_wayland_frontend::cursor=debug` +dans la fenêtre QEMU graphique (pas de copier-coller possible). +Modification du filtre par défaut dans `init_tracing` : + +```rust +EnvFilter::new("info,cursor=debug") +``` + +Tu tapes juste `redox-wl-compositor`, tu vois les traces cursor sans +spam des ticks. Override toujours possible via `RUST_LOG=` au launch. + +## Sous-bug B.2 (page fault ion) — non traité + +Reste documenté pour follow-up upstream. Repro fiable : +1. Lancer un job background Wayland (`redox-wl-real-client-simple-window &`) +2. Tuer le compositor avant que le client n'exit (`Ctrl+Q` sans avoir + pressé ESC d'abord) +3. ion crashe avec page fault `0x70` + +À reporter sur `gitlab.redox-os.org/redox-os/ion`. Hors scope du compositor. + +## Critère de fin 13.1.c + +> Le curseur software du compositor suit fidèlement les déplacements +> souris dans toute l'aire de la fenêtre QEMU graphique, sans saturer +> à un bord, sans dérive monotone. Les coordonnées de clic sont dans +> la plage écran et `hit_test` peut matcher une surface visible. + +**✅ Validé 2026-05-16.** Conversion HID→pixel alignée sur l'algo +upstream d'orbital, instrumentation laissée en place pour debug futur. + +## Fichiers modifiés + +``` +crates/redox-wl-wayland-frontend/src/lib.rs # Conversion 0..65535 → pixels écran dans PointerMotion + # Traces tracing target="cursor" +crates/redox-wl-compositor/src/main.rs # Filtre tracing par défaut "info,cursor=debug" +docs/phase13-1-c-cursor.md # ce document +``` + +## Limites connues + +- `PointerMotionRelative` (deltas relatifs) reste instrumenté mais n'a + jamais été observé sous QEMU PS/2 + qemu-xhci. Si un jour Redox passe + à de la souris purement relative, on aura un second `set_screen_size` + + clamp à valider. +- L'aspect ratio entre l'espace HID 65536² et l'écran 1280×800 n'est + pas carré (1.6 vs 1) — la précision diagonale peut sembler légèrement + décalée. Strictement OK pour 13.1.c, à observer en 13.2+ avec un + vrai client graphique qui dessine sous le curseur (drag, drawing). diff --git a/docs/phase13-1-c-ion-bug-b2-upstream.md b/docs/phase13-1-c-ion-bug-b2-upstream.md new file mode 100644 index 0000000..2c9ed01 --- /dev/null +++ b/docs/phase13-1-c-ion-bug-b2-upstream.md @@ -0,0 +1,167 @@ +# Bug B.2 — Rapport pour upstream Redox ion + +> Document produit le 2026-05-16 dans le cadre de +> [`phase13-1-c-cursor.md`](phase13-1-c-cursor.md). À copier-coller (en +> traduisant en anglais) dans une issue sur +> `gitlab.redox-os.org/redox-os/ion/-/issues` quand on aura un compte. + +--- + +## Title + +ion: page fault `0x70` when a background job dies on broken pipe (Wayland client + compositor scenario) + +## Environment + +- **Redox image** : harddrive.img du 2026-05-08 (build x86_64 desktop, rebuild depuis `redox-src` la veille) +- **ion** : version embarquée dans cette image (binaire stripped, source upstream synced via cookbook recipe `core/ion`) +- **QEMU** : `qemu-system-x86_64` avec `-enable-kvm -cpu host -k fr` sur Linux host (CachyOS 7.0.8) +- **Repro** : 100 % reproductible sur 3 sessions distinctes avec reboot frais entre chaque + +## Symptôme + +Quand un job background termine sur `Broken pipe` (ex: client Wayland dont le compositor vient de fermer son socket), **ion crashe avec un page fault à l'adresse 0x70**. Le kernel logue UNHANDLED EXCEPTION pour le process ion. Le shell devient indisponible jusqu'au prochain login. + +``` +ion: ([Page fault: 0000000000000070 US +RFLAG: 0000000000010212 +CS: 000000000000002b +RIP: 00000000002335ae +RSP: 0000000000bb0e60 +SS: 0000000000000023 +FSBASE 000000000092b000 +GSBASE 0000000000000000 +KGSBASE ffff80007fed8000 +RAX: 0000000000000070 <-- valeur fautive = offset du field +RCX: 0000000000000000 +RDX: 0000000000233550 +RDI: 0000000000000001 +RSI: 00000000006c9970 +R8: 0000000000000000 +R9: 0000000000000000 +R10: 0000000000000000 +R11: 0000000000000008 +RBX: 000000000092b000 +RBP: 0000000000bb0e70 +R12: 000000000092acc8 +R13: 00000000006afe20 +R14: 0000000000000001 +R15: 00000000006afe20 + FP 0000000000bb0e70: PC 0000000000f27745 + FP 0000000000bb0f40: PC 0000000000f0a167 + FP 0000000000bb0f70: PC 0000000000f0a02d + FP 0000000000bb0ff0: PC 0000000000eabd43 + + FP ffff80001798fe80: PC ffffffff8008a42d + kernel::arch::x86_shared::interrupt::exception::page::inner + FP ffff80001798ff50: PC ffffffff80087ee7 + kernel::arch::x86_shared::interrupt::exception::page + 0000000000bb0e70: GUARD PAGE +kernel::context::signal:INFO -- UNHANDLED EXCEPTION, CPU #3, PID 45, NAME /usr/bin/ion +``` + +Pattern : déréférence d'un pointer null avec accès au champ à l'offset 0x70 (`RAX=0x70`, `addr=0x70`). + +## Repro minimal + +```sh +# Dans une session ion fraîche, avec le compositor Wayland +# (https://gitlab.com/leyoda/redox-wayland-compositor) : +redox-wl-compositor & # backround job [0] +sleep 1 +redox-wl-real-client-simple-window & # background job [1] +sleep 2 +# Maintenant, tuer le compositor SANS quitter le client proprement. +# Le compositor ferme son socket, le client reçoit Broken pipe et exit +# avec status non-zéro. C'est en traitant cette terminaison que ion crashe. +pkill redox-wl-compositor +``` + +Reproductible aussi avec n'importe quelle paire client-serveur Unix : +- serveur en bg qui ferme son socket +- client en bg qui écrit dessus et reçoit EPIPE + +## Hypothèse de localisation (best-effort sans symboles) + +Le crash semble survenir dans `BackgroundEventCallback`. La closure dans +`src/main.rs:224-234` : + +```rust +shell.set_background_event(Some(Arc::new(|njob, pid, kind| match kind { + BackgroundEvent::Added => eprintln!("ion: bg [{}] {}", njob, pid), + BackgroundEvent::Stopped => eprintln!("ion: ([{}] {}) Stopped", njob, pid), + BackgroundEvent::Resumed => eprintln!("ion: ([{}] {}) Running", njob, pid), + BackgroundEvent::Exited(status) => { + eprintln!("ion: ([{}] {}) exited with {}", njob, pid, status) + } + BackgroundEvent::Errored(error) => { + eprintln!("ion: ([{}] {}) errored: {}", njob, pid, error) + } +}))); +``` + +Appelée depuis `src/lib/shell/pipe_exec/job_control.rs:148-228` (`watch_background`), elle-même runs depuis un thread dédié au polling de waitpid. + +Note : sur la sortie observée, **le message "exited with" est imprimé AVANT le page fault** : + +``` +[real-client] FAIL: Backend error: Io error: Broken pipe (os error 32) +root:~# [src/procmgr.rs:299 WARN] Cancellation for unknown id Id(26) +root:~# +ion: ([Page fault: 0000000000000070 ... +``` + +Donc le callback aurait fini son `eprintln!`, le crash arrive juste après — possiblement dans le code qui suit, peut-être le `get_process!(|process| { process.forget(); ... })` ou dans le drop du job, ou dans le retour de thread vers le main thread. + +## Disassembly stripped autour de RIP=0x2335ae + +``` +2335a2: 48 8b 85 60 fe ff ff mov -0x1a0(%rbp), %rax +2335a9: 48 d1 e0 shl $1, %rax +2335ac: 48 85 c0 test %rax, %rax +2335af: 74 0d je 0x2335be +``` + +Pattern Vec/Box drop : "test capacity*2, jump-if-zero, else free pointer". +**Le `0x2335ae` tombe au milieu de l'instruction `test`** — possible que +le strip + offset stable ne pointe pas exactement à la même instruction +sur l'image installée vs le rebuild local. RAX=0x70 au crash suggère un +load `mov [rdi+0x70], rax` (ou similaire) où rdi est null. Le champ à +l'offset 0x70 d'une struct Rust commune (BackgroundProcess ? File ? +Mutex> ?) — à confirmer avec un build debug. + +## Side-bug observé en parallèle + +Sur la même session, juste avant le crash : +``` +[src/procmgr.rs:299 WARN] Cancellation for unknown id Id(26) +``` + +Ce warning vient de relibc (`procmgr`), pas d'ion. Peut être lié — une +cancellation de syscall qui laisse un state inconsistent, qu'ion +manipule ensuite. À investiguer côté relibc aussi. + +## Demande + +1. Identifier la fonction à `0x2335ae` (rebuild non-stripped + addr2line). +2. Confirmer si c'est un null deref dans `process.forget()`, drop de + `BackgroundProcess`, ou la déstructure du `Vec`. +3. Patch. + +## Workaround utilisateur en attendant + +Toujours quitter les clients background **avant** le serveur : + +```sh +# Bon ordre : +redox-wl-real-client-simple-window & +# (interagir) +fg %1 # ramener au foreground +# ESC ou autre pour quitter proprement +pkill redox-wl-compositor # seulement après que tous les clients sont sortis +``` + +## Référence + +Bug détecté pendant le port d'un compositor Wayland minimal sur Redox : +https://gitlab.com/leyoda/redox-wayland-compositor (phase 13.1.b / 13.1.c). diff --git a/docs/phase13-2-output-subcompositor.md b/docs/phase13-2-output-subcompositor.md new file mode 100644 index 0000000..379d26f --- /dev/null +++ b/docs/phase13-2-output-subcompositor.md @@ -0,0 +1,262 @@ +# Phase 13.2 — `wl_output` v3 + `wl_subcompositor`/`wl_subsurface` + +> Document produit le 2026-05-16, suite de +> [`phase13-1-c-cursor.md`](phase13-1-c-cursor.md). +> +> **Scope** : combler 2 dettes protocolaires bloquantes pour les +> toolkits clients tiers modernes : +> +> - **`wl_output` complet** — annonce le single display avec geometry/ +> mode/scale/done, en respectant le gating par version (v1/v2/v3). +> Plusieurs toolkits clients (sctk, GTK, Qt) considèrent ce global +> implicitement requis — son absence en 13.1.b était une bombe à +> retardement. +> - **`wl_subcompositor` + `wl_subsurface`** — supporte les surfaces +> parent-enfant et les rend visuellement à `parent_pos + offset`. +> Pré-requis pour tout client multi-surface (popups, badges, splash +> screens, decorations CSD, etc.). +> +> **Stratégie de validation hybride** : pour chaque sprint, un test +> natif (cargo test, sans QEMU) valide la couche protocolaire ; un +> test runtime visuel valide le rendu effectif. Le coût de QEMU +> est ainsi réduit aux validations vraiment visuelles. +> +> **Verdict** : ✅ **13.2 entièrement validée** — 6 commits, 5 +> sprints, 2 nouveaux clients de test, 3 scripts ion shortcuts. + +## Sous-phases livrées + +### 13.2.a — `wl_output` v3 complet ([commit `7413745`](../crates/redox-wl-wayland-frontend/src/lib.rs)) + +Avant : `wl_output` n'était PAS déclaré comme global. Les clients qui +le bind-eraient implicitement (sctk, GTK) se retrouvaient sans output. + +Après : + +- Global `wl_output` à `OUTPUT_VERSION = 3` +- `GlobalDispatch::bind` envoie au bind, dans l'ordre : + 1. `geometry(0, 0, 0_mm, 0_mm, Subpixel::Unknown, "Redox", "redox-wl-output:0", Transform::Normal)` + 2. `mode(CURRENT | PREFERRED, screen_w, screen_h, 60_000_mHz)` + 3. `scale(1)` — **uniquement si version bound ≥ 3** (gating conservateur) + 4. `done()` — **uniquement si version bound ≥ 2** +- `Dispatch::request` accepte `Release` (v3+) en no-op + +Taille pixel issue de `screen_w / screen_h` qui sont settés par +`set_screen_size()` (introduit en 13.1.b pour clamp curseur). Refresh +hardcodé à 60 Hz : notre boucle main tourne à ~30 fps mais les apps +modernes ignorent largement ce champ. + +**Test natif** (`crates/redox-wl-test-wl-output/tests/gating_native.rs`) : +serveur + client en-process via `UnixStream::pair()`, 11 assertions +vérifient v1 (geometry+mode), v2 (+done), v3 (+scale). PASS le 2026-05-16. + +**Test runtime** (`redox-wl-test-wl-output`, runnable via `test-out`) : +bind aux 3 versions et reporte PASS/FAIL côté serial. Confirme le +gating sur la vraie cible Redox. + +### 13.2.b.1 — `wl_subcompositor` protocole ([commit `f9c3de1`](../crates/redox-wl-wayland-frontend/src/lib.rs)) + +Implémentation protocole uniquement (sans rendering, repoussé à .b.2) : + +- Global `wl_subcompositor` à `SUBCOMPOSITOR_VERSION = 1` +- `GlobalDispatch::bind` no-op (resource init) +- `Dispatch::request` : + - `Destroy` : no-op + - `GetSubsurface { id, surface, parent }` : crée un `Arc` + avec parent ref, child ref, position pending `(0,0)`, sync mode `true` +- `Dispatch::request` : + - `SetPosition { x, y }` : update SubsurfaceData.position + - `PlaceAbove`, `PlaceBelow` : no-op (single-subsurface use-case) + - `SetSync`, `SetDesync` : update SubsurfaceData.sync + - `Destroy` : no-op (resource cleanup par wayland-server) + +**SubsurfaceData** est une nouvelle struct portée par la wl_subsurface +resource via `data_init.init(id, Arc)`. + +**Test natif** (`subcompositor_native.rs`) : cycle complet bind → +get_subsurface → set_position → set_sync/desync → destroy. Aucune +erreur protocole détectée. + +### 13.2.b.2 — Rendering subsurface ([commit `bba2d7b`](../crates/redox-wl-wayland-frontend/src/lib.rs)) + +Wire la SubsurfaceData de .b.1 au pipeline de composition : + +1. Nouveau champ `subsurface_link: Mutex>>` + sur `SurfaceData` +2. À `GetSubsurface`, on link la SubsurfaceData à la child SurfaceData +3. Au commit du child wl_surface : + - Si `subsurface_link` est Some : récupère le parent SurfaceId via + `sub_data.parent.data::>()` + - Lookup la position actuelle du parent dans le registry + - `modify_pending(child_id, |s| s.x = px + ox; s.y = py + oy)` avant + le `commit(id)` qui applique pending → current + - Skip `raise()` et `set_focus()` pour les subsurfaces (elles ne + grabbent pas le focus indépendamment, et leur z-order est géré + comme cascade au-dessus du parent) + +`compose_into` (côté `redox-wl-compositor-core`) n'est PAS modifié — il +itère le z_order et dessine chaque surface à son `(x, y)` absolu. Notre +prep au commit assure que le child est à la bonne position. + +### 13.2.b.3 — Client de test visuel ([commit `1dab6ff`](../crates/redox-wl-test-client-subcompositor/)) + +Nouveau crate `redox-wl-test-client-subcompositor` qui : + +1. Connecte au socket `/tmp/redox-wl-comp.sock` +2. Bind 5 globals : `wl_compositor`, `wl_shm`, `xdg_wm_base`, `wl_seat`, + `wl_subcompositor` +3. Crée un parent xdg_toplevel 300×200 bleu nuit avec bordure noire 2px, + ack_configure puis attach + commit buffer ARGB +4. Crée une child wl_surface, `get_subsurface(child, parent)`, + `set_position(50, 50)`, `set_desync`, attach + commit buffer 60×60 + rouge avec bordure noire +5. ESC fermes le client proprement (le path keyboard étant routé au + parent toplevel qui a le focus auto-grant) + +Reprend l'event-loop tolérant Interrupted/BrokenPipe de la phase 13.1.b. + +### Fix bug visuel ([commit `dfb5c66`](../crates/redox-wl-wayland-frontend/src/lib.rs)) + +Détecté lors du runtime QEMU 13.2.b.3. Symptôme : le carré rouge est +calculé à la bonne position au commit du child, mais reste invisible. + +Cause : `parent_surface.commit()` final dans le client (refresh post- +subsurface) appelle `raise(parent_id)` dans le compositor, plaçant le +parent **au-dessus** du child dans le z_order. Le parent overdraw le +child à compose_into. + +Z-order observé : `[child, parent]` au lieu de `[parent, child]`. + +**Fix** : après `raise(parent_id)`, scanner `surfaces_by_id` pour +trouver toutes les subsurfaces dont `subsurface_link.parent` pointe +vers cette SurfaceId, et les `raise()` juste après. Le z-order devient +`[parent, child]` (avec child raised en dernier = au-dessus). + +Coût O(N) par raise — N est typiquement petit (1-10 toplevels). À +durcir avec un index parent_id → Vec si N explose. + +### 13.2.b.4 — Scripts ion shortcuts ([commit `6fe3214`](../tools/redox-scripts/)) + +UX : pour éviter de retaper 3 lignes à chaque test runtime dans la +fenêtre QEMU graphique (où le copier-coller depuis CachyOS n'est pas +disponible), 3 scripts ion prêts à l'emploi déployés dans `/usr/bin` : + +- **`test-sw`** : compositor + simple_window upstream (phase 13.1.b) +- **`test-subc`** : compositor + subcompositor visuel (phase 13.2.b.3) +- **`test-out`** : compositor + wl_output gating test (phase 13.2.a) + +Tous suivent le même pattern : + +```ion +#!/usr/bin/ion +rm -f /tmp/redox-wl-comp.sock +redox-wl-compositor & +sleep 1 + +``` + +run-qemu.sh copie maintenant `tools/redox-scripts/*` dans `/usr/bin` de +l'image (avec `cp -p` pour préserver le bit exécutable). + +## Stratégie de tests : hybride natif + runtime + +Innovation méthodologique de cette phase. Pour chaque sprint : + +- **Test natif** (cargo test, sur l'hôte CachyOS, sans QEMU) : + - Spawn un thread serveur avec wayland-server + la logique de gating + identique au prod code + - Connecte un client wayland-client via `UnixStream::pair()` + - Exercise toute la séquence Wayland + - Asserts précis (versions, events reçus, ordre, etc.) + - Tourne en < 6 secondes +- **Test runtime** (QEMU, visuel) : + - Confirme le rendu effectif sur le framebuffer Redox + - Confirme l'interaction utilisateur (clic, ESC, focus, etc.) + - Validation finale "tout le pipeline marche" + +Gain : on n'a QEMU dans la boucle que pour ce qui est vraiment visuel. +La couche protocolaire est testée en CI-friendly time. + +## Limitations connues + +À traiter en phase ultérieure si besoin : + +- **Parent move ≠ propagation aux enfants** : si on drag un parent + toplevel (phase 7.7), ses subsurfaces ne suivent pas tant que le + client ne re-commit pas l'enfant. Pour des use-cases statiques + c'est OK ; pour Wayland conforme strict il faudra walker les enfants + au move du parent. +- **wl_subsurface.Destroy ne unmap pas le child** : spec violation + mineure. Si le client veut vraiment retirer la subsurface, il doit + aussi destroy son wl_surface enfant. +- **Pas de cascade sync** : un commit du parent en mode sync devrait + flusher les pending states des subsurfaces sync. Pas implémenté. + Notre client de test utilise `set_desync` donc le path n'est pas + exercé. +- **Pas de role-tracking** : spec exige `bad_surface` error si une + wl_surface a déjà un rôle (xdg_toplevel) et qu'on appelle + GetSubsurface dessus. Pour l'instant on log debug seulement. +- **PlaceAbove/PlaceBelow no-op** : z-order relatif entre subsurfaces + siblings non géré. Pas observé en pratique avec nos clients de test + single-subsurface. +- **Raise children scan O(N)** : naïf. Avec un index parent_id → + Vec on passe O(1). À faire si N de surfaces visibles + devient grand (> 50 ?). + +## Fichiers livrés en 13.2 + +``` +# Code compositor (frontend Wayland) +crates/redox-wl-wayland-frontend/src/lib.rs # globals + Dispatch + commit hook + +# Clients de test +crates/redox-wl-test-wl-output/ # validation gating wl_output (13.2.a) + ├── src/main.rs # runtime client + └── tests/ + ├── gating_native.rs # test natif v1/v2/v3 + └── subcompositor_native.rs # test natif protocole + +crates/redox-wl-test-client-subcompositor/ # validation visuelle (13.2.b.3) + └── src/main.rs + +# Tooling runtime +tools/redox-scripts/test-sw # shortcut simple_window +tools/redox-scripts/test-subc # shortcut subcompositor +tools/redox-scripts/test-out # shortcut output gating + +# Build/deploy +run-qemu.sh # copie binaires + scripts + +# Doc +docs/phase13-2-output-subcompositor.md # ce document +``` + +## Critère de fin 13.2 + +> 1. Le compositor expose `wl_output` v3 avec gating correct des events +> par version bound (testé v1/v2/v3 en natif et runtime) +> 2. Le compositor expose `wl_subcompositor` v1 et accepte tous les +> requests `wl_subsurface` sans erreur protocole +> 3. Un client multi-surface (parent + 1 subsurface) rend visuellement +> les deux surfaces aux bonnes positions (parent à `cascade_offset`, +> child à `parent + offset`) +> 4. Le z-order reste cohérent même après recommit du parent (le child +> reste au-dessus) +> 5. Le focus clavier reste sur le parent (les subsurfaces ne grabbent +> pas) + +**✅ Tous critères validés 2026-05-16.** + +## Étapes suivantes possibles + +- **13.3** : extraction backend pour Smithay/COSMIC (Phase 13 du master + plan). Auditer notre code pour identifier les primitives réutilisables + comme backend Smithay (display, input, session, renderer logiciel). +- **13.2.c** : retenter sctk simple_window — avec wl_output et + wl_subcompositor maintenant présents, il manque encore xdg-decoration + et un wayland-server qui charge libxkbcommon. À évaluer si le ROI + vaut le coût d'implémentation. +- **Durcissement 13.2** : reprendre les limitations listées ci-dessus + (cascade sync, parent move propagation, role-tracking). +- **Phase 8** : remplacement expérimental d'Orbital avec ce compositor. + Préparé maintenant qu'on couvre les protocoles de base. diff --git a/run-qemu.sh b/run-qemu.sh index d971aa5..458a1f6 100755 --- a/run-qemu.sh +++ b/run-qemu.sh @@ -13,6 +13,7 @@ # cd crates/redox-wl-real-client-simple-window && redoxer build --release # - redoxfs accessible dans REDOX_SRC/build/fstools/bin/ # - L'image harddrive.img présente dans REDOX_SRC/build/x86_64/desktop/ +# - /dev/fuse disponible sur l'hôte # - PAS de QEMU déjà ouvert sur la même image (corromprait le disque). # # Une fois Redox bootée : @@ -25,12 +26,17 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")" && pwd)" REDOX_SRC="${REDOX_SRC:-$HOME/Projets/Redox/redox-src}" -IMAGE="$REDOX_SRC/build/x86_64/desktop/harddrive.img" -REDOXFS="$REDOX_SRC/build/fstools/bin/redoxfs" +IMAGE="${IMAGE:-$REDOX_SRC/build/x86_64/desktop/harddrive.img}" +REDOXFS="${REDOXFS:-$REDOX_SRC/build/fstools/bin/redoxfs}" MOUNT="${MOUNT:-/tmp/redox-mnt}" COMPOSITOR_BIN="$ROOT/crates/redox-wl-compositor/target/x86_64-unknown-redox/release/redox-wl-compositor" CLIENT_BIN="$ROOT/crates/redox-wl-real-client-simple-window/target/x86_64-unknown-redox/release/redox-wl-real-client-simple-window" +# Phase 13.2.a : client de test version-gating wl_output (optionnel, skip +# silencieusement si pas compilé). +TEST_OUTPUT_BIN="$ROOT/crates/redox-wl-test-wl-output/target/x86_64-unknown-redox/release/redox-wl-test-wl-output" +# Phase 13.2.b.3 : client de test visuel subcompositor. +TEST_SUBCOMP_BIN="$ROOT/crates/redox-wl-test-client-subcompositor/target/x86_64-unknown-redox/release/redox-wl-test-client-subcompositor" NO_QEMU=0 for arg in "$@"; do @@ -76,6 +82,31 @@ if [[ $missing -ne 0 ]]; then exit 1 fi +if [[ ! -e /dev/fuse ]]; then + echo "ERR : /dev/fuse absent sur l'hôte" >&2 + echo "RedoxFS en a besoin pour monter l'image avant le boot QEMU." >&2 + echo "Sur Linux, charge le module fuse ou exécute sur un host où FUSE est disponible." >&2 + exit 3 +fi + +# Phase 13.1.c B.4 : warn si l'image est trop petite. L'image desktop par +# défaut fait ~680 Mo et sature dès qu'on tente `pkg update` (cf. session +# de la phase 13.1.b). Pour confort, recommandation : 4 Go minimum, idéal +# 10 Go. La grossir avec : +# qemu-img resize -f raw 10G +# /build/fstools/bin/redoxfs-resize +IMAGE_SIZE_BYTES=$(stat -c %s "$IMAGE" 2>/dev/null || echo 0) +MIN_IMAGE_SIZE=$((4 * 1024 * 1024 * 1024)) # 4 GiB +if [[ "$IMAGE_SIZE_BYTES" -gt 0 && "$IMAGE_SIZE_BYTES" -lt "$MIN_IMAGE_SIZE" ]]; then + image_size_human=$(numfmt --to=iec --suffix=B "$IMAGE_SIZE_BYTES" 2>/dev/null || echo "${IMAGE_SIZE_BYTES} octets") + echo "WARN : image $IMAGE de taille ${image_size_human} (< 4 GiB recommandés)" >&2 + echo " Tu risques 'No space left on device' au moindre pkg update." >&2 + echo " Pour la grossir :" >&2 + echo " qemu-img resize -f raw $IMAGE 10G" >&2 + echo " $REDOX_SRC/build/fstools/bin/redoxfs-resize $IMAGE" >&2 + echo " (warning seulement, on continue)" >&2 +fi + # --- 2. Mount --- mkdir -p "$MOUNT" echo "==> monter $IMAGE -> $MOUNT" @@ -100,6 +131,23 @@ echo " monté (pid redoxfs=$REDOXFS_PID)" echo "==> copier les binaires dans /usr/bin/" cp -v "$COMPOSITOR_BIN" "$MOUNT/usr/bin/" cp -v "$CLIENT_BIN" "$MOUNT/usr/bin/" +# Phase 13.2.a : test client wl_output (optionnel) +if [[ -e "$TEST_OUTPUT_BIN" ]]; then + cp -v "$TEST_OUTPUT_BIN" "$MOUNT/usr/bin/" +fi +# Phase 13.2.b.3 : test client subcompositor (optionnel) +if [[ -e "$TEST_SUBCOMP_BIN" ]]; then + cp -v "$TEST_SUBCOMP_BIN" "$MOUNT/usr/bin/" +fi + +# Phase 13.2.b.4 : scripts ion raccourcis (test-subc, test-sw, test-out). +# Copiés depuis tools/redox-scripts/ pour invocation 1-ligne dans le shell +# Redox. Chmod +x conservé via cp -p. +for script in "$ROOT/tools/redox-scripts"/*; do + if [[ -f "$script" ]]; then + cp -vp "$script" "$MOUNT/usr/bin/" + fi +done # --- 4. umount avant make qemu (sinon QEMU et FUSE se battent sur le même fichier) --- echo "==> démonter $MOUNT" diff --git a/tools/redox-scripts/test-out b/tools/redox-scripts/test-out new file mode 100755 index 0000000..b9c6f56 --- /dev/null +++ b/tools/redox-scripts/test-out @@ -0,0 +1,8 @@ +#!/usr/bin/ion +# Phase 13.2.a — Launch compositor + wl_output version gating test client. +# Le client teste binding wl_output aux v1/v2/v3 et reporte PASS/FAIL. +# Pas besoin de Ctrl+Q : le client exit puis on stoppe le compositor. +rm -f /tmp/redox-wl-comp.sock +redox-wl-compositor & +sleep 1 +redox-wl-test-wl-output diff --git a/tools/redox-scripts/test-subc b/tools/redox-scripts/test-subc new file mode 100755 index 0000000..74d2052 --- /dev/null +++ b/tools/redox-scripts/test-subc @@ -0,0 +1,10 @@ +#!/usr/bin/ion +# Phase 13.2.b.3 — Launch compositor + subcompositor test client. +# Nettoie un éventuel socket résiduel, lance le compo en background, +# attend 1s, puis lance le client de test subcompositor en avant-plan. +# Côté QEMU graphique : ESC sur la fenêtre pour exit propre du client, +# puis Ctrl+Q pour exit du compositor. +rm -f /tmp/redox-wl-comp.sock +redox-wl-compositor & +sleep 1 +redox-wl-test-client-subcompositor diff --git a/tools/redox-scripts/test-sw b/tools/redox-scripts/test-sw new file mode 100755 index 0000000..4ec83bc --- /dev/null +++ b/tools/redox-scripts/test-sw @@ -0,0 +1,8 @@ +#!/usr/bin/ion +# Phase 13.1.b — Launch compositor + simple_window test client (port wayland-rs). +# Côté QEMU graphique : ESC sur la fenêtre pour exit propre du client, +# puis Ctrl+Q pour exit du compositor. +rm -f /tmp/redox-wl-comp.sock +redox-wl-compositor & +sleep 1 +redox-wl-real-client-simple-window