🎉 Phase 13.2.a — wl_output v3 complet (gating v1/v2/v3 validé en natif)

Dette protocolaire transversale comblée : wl_output n'était pas déclaré
comme global, plusieurs toolkits client (sctk, GTK, Qt) le considèrent
implicitement requis. Maintenant exposé à v3 avec gating conforme :

- geometry, mode : envoyés à toutes les versions (>= 1)
- scale : seulement si bind >= 3 (gating conservateur)
- done : seulement si bind >= 2

Mode flags = CURRENT | PREFERRED, refresh 60 000 mHz, scale_factor=1,
transform Normal, subpixel Unknown, make=Redox, model=redox-wl-output:0.
Position (0,0), single output, taille pixel issue de screen_w/screen_h
(setté par set_screen_size au boot compositor).

Nouveau crate de validation : redox-wl-test-wl-output. Deux niveaux :
1. Client autonome pour test runtime dans QEMU (bind aux 3 versions,
   dump events, PASS/FAIL via exit code)
2. Test natif (cargo test, tourne sur l'hôte sans QEMU) qui simule
   serveur+client en-process via UnixStream::pair() et valide les 11
   assertions du gating. Vérifié PASS le 2026-05-16 sur CachyOS.

Le test natif sert de garde-fou anti-régression pour CI ultérieur. Plus
besoin de relancer QEMU pour valider ce path.

run-qemu.sh : copie maintenant aussi le test client si compilé (skip
silencieux sinon).

Leyoda 2026 – GPLv3
This commit is contained in:
Votre Nom 2026-05-16 12:51:11 +02:00
parent 095d7e5550
commit 7413745dbe
5 changed files with 651 additions and 2 deletions

View file

@ -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<Option<std::fs::File>>);
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<DebugSink> = 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<u32>,
output_global_version: Option<u32>,
// 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<usize>,
}
impl Dispatch<wl_registry::WlRegistry, ()> for State {
fn event(
state: &mut Self,
_r: &wl_registry::WlRegistry,
event: wl_registry::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
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<wl_output::WlOutput, ()> for State {
fn event(
state: &mut Self,
_r: &wl_output::WlOutput,
event: wl_output::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
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::<State>();
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::<wl_output::WlOutput, _, _>(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<String> = 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
}
}