🎉 Phase 7.6 — multi-clients paquet B validé runtime

3 livrables :

1. Cleanup post-disconnect (corrige sub-bug 7.5)
   - DumbClientData::disconnected push dans Arc<Mutex<Vec<ClientId>>>
     partagé (peuplé à accept_pending_clients)
   - SurfaceData.client_id: Mutex<Option<ClientId>> capturé au
     wl_compositor.create_surface pendant que _client: &Client est
     encore vivant (à la déconnexion surf.client() retourne None,
     on ne pourrait plus déduire le mapping)
   - WaylandFrontend.garbage_collect_dead_clients drain la queue
     et nettoie surfaces_by_id + registry + focused_surface +
     cursor_surface_id + pointers/keyboards orphelins
   - Appelée à chaque tick depuis le compositor binaire après
     dispatch_clients

2. wl_buffer.release après commit-copy
   - SurfaceData.pending_buffer passé de Option<BufferData> à
     Option<wl_buffer::WlBuffer> pour avoir le Resource sous la main
   - Au commit, après la lecture des params via
     buf.data::<BufferData>().cloned() et la copie des pixels,
     appel buf.release() qui signale au client qu'il peut réutiliser
     son buffer

3. Filtrage events par client focused
   - forward_input calcule focused_client_id depuis
     focused_surface.client().map(|c| c.id())
   - wl_pointer.{motion,button,axis,frame} et wl_keyboard.key
     n'arrivent qu'aux Resources dont client_id matche le focused
   - PointerButton recalcule focused_cid APRÈS le hit_test+set_focus
     pour que le clic atterrisse bien sur le nouveau client

Pièges trouvés :
- Resource n'a pas de client_id() direct → utiliser
  client().map(|c| c.id())
- À l'instant du disconnected(), surf.client() retourne déjà None
  → capturer le ClientId au CreateSurface, pas après

Validation runtime :
- Test fuzz : surface fantôme du fuzz1 (brutal exit) nettoyée,
  surfaces=0 stable post-fuzz, capture phase7-6-cleanup-no-ghost.png
  confirme visuellement (vs rectangle noir 7.5)
- Test 2 clients : redox-wl-test-client-shm-two avec parent + fork
  affiche A vert + B magenta en parallèle, surfaces=2 stable,
  capture phase7-6-two-clients.png
- Log frontend : [frontend] garbage_collect: client X → destroyed
  1 surfaces (fuzz1), 0 surfaces (fuzz2-4 qui ont cleanup propre)

Doc complète : docs/phase7-6-multi-clients.md

Leyoda 2026 – GPLv3
This commit is contained in:
Votre Nom 2026-05-13 18:51:33 +02:00
parent 7e81dec637
commit a87de02555
5 changed files with 464 additions and 48 deletions

View file

@ -116,6 +116,11 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
dlog(&format!("[comp] dispatch err: {e}"));
}
// 2.5. Phase 7.6 : nettoyer les surfaces des clients déconnectés.
// Sans ça les surfaces persistent après un close socket brutal
// (sub-bug 7.5).
frontend.garbage_collect_dead_clients();
// 3. Input
if let Ok(events) = input.poll() {
if !events.is_empty() {

View file

@ -150,8 +150,11 @@ struct SurfaceData {
/// SurfaceId associé dans le SurfaceRegistry.
/// Initialisé par `wl_compositor.create_surface` via Mutex<Option>.
id: Mutex<Option<SurfaceId>>,
/// Buffer attaché en pending (avant commit).
pending_buffer: Mutex<Option<BufferData>>,
/// Buffer attaché en pending (avant commit). Phase 7.6 : on stocke le
/// `wl_buffer::WlBuffer` Resource directement (au lieu d'une copie de
/// `BufferData`) pour pouvoir envoyer `wl_buffer.release()` au client
/// après le commit-copy.
pending_buffer: Mutex<Option<wl_buffer::WlBuffer>>,
/// Frame callbacks en attente (à signaler après le prochain present).
pending_frame_callbacks: Mutex<Vec<wl_callback::WlCallback>>,
/// Si une xdg_surface a été créée pour cette wl_surface, true tant
@ -166,6 +169,12 @@ struct SurfaceData {
/// `draw_cursor()`. Atomic pour éviter de prendre un Mutex sur le hot
/// path de la composition.
is_cursor: AtomicBool,
/// Phase 7.6 : `ClientId` du client propriétaire, capturé à la
/// `wl_compositor.create_surface` quand `_client` est encore vivant.
/// Utilisé par `garbage_collect_dead_clients` pour retrouver les
/// 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<Option<ClientId>>,
}
/// Données par-xdg_surface : référence à la wl_surface sous-jacente +
@ -186,10 +195,20 @@ struct XdgToplevelData {
}
#[derive(Debug)]
struct DumbClientData;
struct DumbClientData {
/// Phase 7.6 : push le ClientId dans cette queue partagée quand le
/// client se déconnecte. La boucle main du compositor draine cette
/// liste via `garbage_collect_dead_clients` et nettoie les surfaces
/// orphelines (sub-bug 7.5 corrigé).
dead_clients: Arc<Mutex<Vec<ClientId>>>,
}
impl ClientData for DumbClientData {
fn initialized(&self, _client_id: ClientId) {}
fn disconnected(&self, _client_id: ClientId, _reason: DisconnectReason) {}
fn disconnected(&self, client_id: ClientId, _reason: DisconnectReason) {
if let Ok(mut q) = self.dead_clients.lock() {
q.push(client_id);
}
}
}
/// État du frontend, qui est aussi l'état Dispatch côté wayland-server.
@ -238,6 +257,10 @@ pub struct WaylandFrontend {
/// faire set_focus(target) après un hit_test au clic. Peuplé au
/// `wl_compositor.create_surface`, nettoyé au `wl_surface.destroy`.
surfaces_by_id: HashMap<SurfaceId, wl_surface::WlSurface>,
/// Phase 7.6 : queue partagée de ClientId qui se sont déconnectés.
/// Remplie par les callbacks `DumbClientData::disconnected`, drainée
/// par `garbage_collect_dead_clients` dans la boucle main.
dead_clients: Arc<Mutex<Vec<ClientId>>>,
/// Hot-spot du curseur (offset à soustraire à cursor_x/y pour le placement).
cursor_hot_x: i32,
cursor_hot_y: i32,
@ -283,6 +306,7 @@ impl WaylandFrontend {
cursor_hot_y: 0,
cursor_visible: false,
surfaces_by_id: HashMap::new(),
dead_clients: Arc::new(Mutex::new(Vec::new())),
})
}
@ -292,10 +316,13 @@ impl WaylandFrontend {
match self.listener.accept() {
Ok(Some(stream)) => {
stream.set_nonblocking(true).ok();
let cd = DumbClientData {
dead_clients: Arc::clone(&self.dead_clients),
};
let _ = self
.display
.handle()
.insert_client(stream, Arc::new(DumbClientData));
.insert_client(stream, Arc::new(cd));
}
Ok(None) => break, // pas de client en attente
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
@ -305,6 +332,63 @@ impl WaylandFrontend {
Ok(())
}
/// Phase 7.6 : draine la queue des ClientId déconnectés et nettoie
/// les surfaces, pointers, keyboards orphelins. À appeler dans la
/// boucle main du compositor à chaque tick.
///
/// Corrige le sub-bug 7.5 : surface fantôme du client qui exit
/// brutalement sans destroy.
pub fn garbage_collect_dead_clients(&mut self) {
let dead: Vec<ClientId> = match self.dead_clients.lock() {
Ok(mut q) => q.drain(..).collect(),
Err(_) => return,
};
if dead.is_empty() {
return;
}
for cid in dead {
// Surfaces appartenant à ce client → destroy.
// On consulte le `client_id` stocké dans SurfaceData (et non
// `surf.client()` qui retourne déjà None à ce stade — le
// hook disconnected est appelé après que le client soit
// détaché côté wayland-server).
let to_destroy: Vec<SurfaceId> = self
.surfaces_by_id
.iter()
.filter_map(|(sid, surf)| {
let surf_cid = surf
.data::<Arc<SurfaceData>>()
.and_then(|d| d.client_id.lock().ok().and_then(|g| g.clone()));
if surf_cid.as_ref() == Some(&cid) {
Some(*sid)
} else {
None
}
})
.collect();
for sid in &to_destroy {
self.registry.destroy(*sid);
if let Some(removed) = self.surfaces_by_id.remove(sid) {
if self.focused_surface.as_ref() == Some(&removed) {
self.focused_surface = None;
}
}
if self.cursor_surface_id == Some(*sid) {
self.cursor_surface_id = None;
}
}
// Pointers + keyboards orphelins (client mort) → retirer.
// `p.client()` retourne None pour les resources d'un client
// déjà déconnecté.
self.pointers.retain(|p| p.client().is_some());
self.keyboards.retain(|k| k.client().is_some());
println!(
"[frontend] garbage_collect: client {cid:?} → destroyed {} surfaces",
to_destroy.len()
);
}
}
/// Traite les requêtes en attente côté serveur. Met à jour `self.registry`
/// au passage (les Dispatch handlers ont accès à `self`).
pub fn dispatch_clients(&mut self) -> Result<(), Box<dyn std::error::Error>> {
@ -425,13 +509,29 @@ impl WaylandFrontend {
(self.cursor_x, self.cursor_y)
}
/// Renvoie le ClientId de la surface focalisée, s'il y en a une.
/// Utilisé en 7.6 pour filtrer le routage des events pointer/keyboard
/// (un seul client à la fois reçoit les events, contrairement au
/// broadcast 7.2-7.5).
fn focused_client_id(&self) -> Option<ClientId> {
self.focused_surface
.as_ref()
.and_then(|s| s.client().map(|c| c.id()))
}
/// Forward un event input du backend vers la surface focalisée
/// (ou tous les pointers/keyboards par broadcast pour 7.2).
/// uniquement (Phase 7.6 : filtrage par client focused, fin du
/// broadcast 7.2). Si pas de focus, aucun event n'est envoyé aux
/// clients (mais le hit_test au clic peut quand même focaliser
/// quelqu'un).
pub fn forward_input(&mut self, ev: &RedoxInputEvent) {
match ev {
RedoxInputEvent::Key {
scancode, pressed, ..
} => {
let Some(focus_cid) = self.focused_client_id() else {
return;
};
let time = self.alloc_input_time();
let serial = self.alloc_input_serial();
// Wayland keycodes = scancode evdev = scancode +8 on linux
@ -446,7 +546,9 @@ impl WaylandFrontend {
wl_keyboard::KeyState::Released
};
for kb in &self.keyboards {
kb.key(serial, time, key, state);
if kb.client().map(|c| c.id()) == Some(focus_cid.clone()) {
kb.key(serial, time, key, state);
}
}
}
RedoxInputEvent::PointerMotion { x, y } => {
@ -455,11 +557,14 @@ impl WaylandFrontend {
self.cursor_visible = true;
let time = self.alloc_input_time();
if let Some(focus) = self.focused_surface.clone() {
let focus_cid = focus.client().map(|c| c.id());
let (sx, sy) = self.surface_local_cursor(&focus);
for ptr in &self.pointers {
ptr.motion(time, fixed_from_int(sx), fixed_from_int(sy));
if SEAT_VERSION >= 5 {
ptr.frame();
if ptr.client().map(|c| c.id()) == focus_cid {
ptr.motion(time, fixed_from_int(sx), fixed_from_int(sy));
if SEAT_VERSION >= 5 {
ptr.frame();
}
}
}
}
@ -470,11 +575,14 @@ impl WaylandFrontend {
self.cursor_visible = true;
let time = self.alloc_input_time();
if let Some(focus) = self.focused_surface.clone() {
let focus_cid = focus.client().map(|c| c.id());
let (sx, sy) = self.surface_local_cursor(&focus);
for ptr in &self.pointers {
ptr.motion(time, fixed_from_int(sx), fixed_from_int(sy));
if SEAT_VERSION >= 5 {
ptr.frame();
if ptr.client().map(|c| c.id()) == focus_cid {
ptr.motion(time, fixed_from_int(sx), fixed_from_int(sy));
if SEAT_VERSION >= 5 {
ptr.frame();
}
}
}
}
@ -510,6 +618,12 @@ impl WaylandFrontend {
}
}
// Phase 7.6 : recalculer focus_cid après le set_focus qui
// a pu changer juste au-dessus (le clic à eu lieu sur une
// surface autre que la focalisée → set_focus + recalcul).
let Some(focus_cid) = self.focused_client_id() else {
return;
};
let time = self.alloc_input_time();
// Code BTN_LEFT/MIDDLE/RIGHT linux/input-event-codes.h
const BTN_LEFT: u32 = 0x110;
@ -523,7 +637,7 @@ impl WaylandFrontend {
// Note : orbclient envoie l'état complet des 3 boutons à chaque
// event. Côté Wayland on devrait envoyer un event par changement
// de bouton — mais comme on ne sait pas l'état précédent ici,
// on broadcast les 3 à chaque event. À durcir en 7.5.
// on envoie les 3 à chaque event au client focused.
for (btn, pressed) in buttons {
let serial = self.alloc_input_serial();
let state = if pressed {
@ -532,18 +646,28 @@ impl WaylandFrontend {
wl_pointer::ButtonState::Released
};
for ptr in &self.pointers {
ptr.button(serial, time, btn, state);
if ptr.client().map(|c| c.id()) == Some(focus_cid.clone()) {
ptr.button(serial, time, btn, state);
}
}
}
if SEAT_VERSION >= 5 {
for ptr in &self.pointers {
ptr.frame();
if ptr.client().map(|c| c.id()) == Some(focus_cid.clone()) {
ptr.frame();
}
}
}
}
RedoxInputEvent::PointerScroll { dx, dy } => {
let Some(focus_cid) = self.focused_client_id() else {
return;
};
let time = self.alloc_input_time();
for ptr in &self.pointers {
if ptr.client().map(|c| c.id()) != Some(focus_cid.clone()) {
continue;
}
if *dy != 0 {
ptr.axis(
time,
@ -784,6 +908,10 @@ impl wayland_server::Dispatch<wl_compositor::WlCompositor, ()> for WaylandFronte
pending_frame_callbacks: Mutex::new(Vec::new()),
xdg_pending_initial_configure: Mutex::new(false),
is_cursor: AtomicBool::new(false),
// Phase 7.6 : capturer le client_id pendant que `_client`
// 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())),
};
let surf = data_init.init(id, Arc::new(data));
// Phase 7.4 : enregistrer le mapping SurfaceId → WlSurface
@ -964,14 +1092,7 @@ impl wayland_server::Dispatch<wl_surface::WlSurface, Arc<SurfaceData>> for Wayla
// x/y sont le hint de placement par rapport à l'ancien buffer
// (Wayland-spec) ; pour 6.4 on ignore et on garde la position
// courante de la surface.
let bd = match buffer {
Some(buf) => match buf.data::<BufferData>() {
Some(d) => Some(d.clone()),
None => None,
},
None => None,
};
*data.pending_buffer.lock().unwrap() = bd;
*data.pending_buffer.lock().unwrap() = buffer;
}
wl_surface::Request::Damage { .. } | wl_surface::Request::DamageBuffer { .. } => {
// Damage tracking minimal pour 6.4 : on recompose tout. À
@ -999,33 +1120,50 @@ impl wayland_server::Dispatch<wl_surface::WlSurface, Arc<SurfaceData>> for Wayla
let is_cursor = data.is_cursor.load(Ordering::Relaxed);
// Lire le buffer attaché (s'il y en a un)
let bd_opt = data.pending_buffer.lock().unwrap().clone();
if let Some(bd) = bd_opt {
if !bd.valid {
// Phase 7.5 : buffer marqué invalide à la création
// (dimensions ou offset incohérents avec le pool).
// On ignore plutôt que de lire des octets hors-pool.
} else {
// Lire les pixels et créer un SurfaceBuffer compositor-core
let pool = bd.pool.lock().unwrap();
let pixels_opt = unsafe {
pool.read_argb(bd.offset as usize, bd.width, bd.height, bd.stride)
};
if let Some(pixels) = pixels_opt {
let sb = SurfaceBuffer::from_pixels(bd.width, bd.height, pixels);
// Pour une surface curseur, on stocke le buffer
// mais visible=false (cf draw_cursor).
state.registry.modify_pending(id, |s| {
s.buffer = Some(sb);
s.visible = !is_cursor;
});
// Lire le buffer attaché (s'il y en a un). On `take()` pour
// que le pending_buffer soit vidé après chaque commit
// (sémantique Wayland : le buffer attaché n'est consommé
// qu'au commit ; le client peut le réutiliser ensuite).
let buf_opt = data.pending_buffer.lock().unwrap().take();
if let Some(buf) = buf_opt {
// Phase 7.6 : récupérer les params BufferData via le
// UserData du wl_buffer Resource.
if let Some(bd) = buf.data::<BufferData>().cloned() {
if !bd.valid {
// Phase 7.5 : buffer marqué invalide à la création
// (dimensions ou offset incohérents avec le pool).
// On ignore plutôt que de lire des octets hors-pool.
} else {
println!(
"[frontend] commit: read_argb refused buffer (overrun guard)"
);
let pool = bd.pool.lock().unwrap();
let pixels_opt = unsafe {
pool.read_argb(
bd.offset as usize,
bd.width,
bd.height,
bd.stride,
)
};
drop(pool);
if let Some(pixels) = pixels_opt {
let sb =
SurfaceBuffer::from_pixels(bd.width, bd.height, pixels);
// Pour une surface curseur, on stocke le buffer
// mais visible=false (cf draw_cursor).
state.registry.modify_pending(id, |s| {
s.buffer = Some(sb);
s.visible = !is_cursor;
});
} else {
println!(
"[frontend] commit: read_argb refused buffer (overrun guard)"
);
}
}
}
// Phase 7.6 : signaler au client qu'il peut réutiliser
// le buffer. Notre copy-on-commit fait qu'on n'a plus
// besoin du buffer côté serveur dès maintenant.
buf.release();
}
state.registry.commit(id);
if !is_cursor {