🎉 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:
parent
7e81dec637
commit
a87de02555
5 changed files with 464 additions and 48 deletions
|
|
@ -116,6 +116,11 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
dlog(&format!("[comp] dispatch err: {e}"));
|
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
|
// 3. Input
|
||||||
if let Ok(events) = input.poll() {
|
if let Ok(events) = input.poll() {
|
||||||
if !events.is_empty() {
|
if !events.is_empty() {
|
||||||
|
|
|
||||||
|
|
@ -150,8 +150,11 @@ struct SurfaceData {
|
||||||
/// SurfaceId associé dans le SurfaceRegistry.
|
/// SurfaceId associé dans le SurfaceRegistry.
|
||||||
/// Initialisé par `wl_compositor.create_surface` via Mutex<Option>.
|
/// Initialisé par `wl_compositor.create_surface` via Mutex<Option>.
|
||||||
id: Mutex<Option<SurfaceId>>,
|
id: Mutex<Option<SurfaceId>>,
|
||||||
/// Buffer attaché en pending (avant commit).
|
/// Buffer attaché en pending (avant commit). Phase 7.6 : on stocke le
|
||||||
pending_buffer: Mutex<Option<BufferData>>,
|
/// `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).
|
/// Frame callbacks en attente (à signaler après le prochain present).
|
||||||
pending_frame_callbacks: Mutex<Vec<wl_callback::WlCallback>>,
|
pending_frame_callbacks: Mutex<Vec<wl_callback::WlCallback>>,
|
||||||
/// Si une xdg_surface a été créée pour cette wl_surface, true tant
|
/// 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
|
/// `draw_cursor()`. Atomic pour éviter de prendre un Mutex sur le hot
|
||||||
/// path de la composition.
|
/// path de la composition.
|
||||||
is_cursor: AtomicBool,
|
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 +
|
/// Données par-xdg_surface : référence à la wl_surface sous-jacente +
|
||||||
|
|
@ -186,10 +195,20 @@ struct XdgToplevelData {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[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 {
|
impl ClientData for DumbClientData {
|
||||||
fn initialized(&self, _client_id: ClientId) {}
|
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.
|
/// É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
|
/// faire set_focus(target) après un hit_test au clic. Peuplé au
|
||||||
/// `wl_compositor.create_surface`, nettoyé au `wl_surface.destroy`.
|
/// `wl_compositor.create_surface`, nettoyé au `wl_surface.destroy`.
|
||||||
surfaces_by_id: HashMap<SurfaceId, wl_surface::WlSurface>,
|
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).
|
/// Hot-spot du curseur (offset à soustraire à cursor_x/y pour le placement).
|
||||||
cursor_hot_x: i32,
|
cursor_hot_x: i32,
|
||||||
cursor_hot_y: i32,
|
cursor_hot_y: i32,
|
||||||
|
|
@ -283,6 +306,7 @@ impl WaylandFrontend {
|
||||||
cursor_hot_y: 0,
|
cursor_hot_y: 0,
|
||||||
cursor_visible: false,
|
cursor_visible: false,
|
||||||
surfaces_by_id: HashMap::new(),
|
surfaces_by_id: HashMap::new(),
|
||||||
|
dead_clients: Arc::new(Mutex::new(Vec::new())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -292,10 +316,13 @@ impl WaylandFrontend {
|
||||||
match self.listener.accept() {
|
match self.listener.accept() {
|
||||||
Ok(Some(stream)) => {
|
Ok(Some(stream)) => {
|
||||||
stream.set_nonblocking(true).ok();
|
stream.set_nonblocking(true).ok();
|
||||||
|
let cd = DumbClientData {
|
||||||
|
dead_clients: Arc::clone(&self.dead_clients),
|
||||||
|
};
|
||||||
let _ = self
|
let _ = self
|
||||||
.display
|
.display
|
||||||
.handle()
|
.handle()
|
||||||
.insert_client(stream, Arc::new(DumbClientData));
|
.insert_client(stream, Arc::new(cd));
|
||||||
}
|
}
|
||||||
Ok(None) => break, // pas de client en attente
|
Ok(None) => break, // pas de client en attente
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||||
|
|
@ -305,6 +332,63 @@ impl WaylandFrontend {
|
||||||
Ok(())
|
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`
|
/// Traite les requêtes en attente côté serveur. Met à jour `self.registry`
|
||||||
/// au passage (les Dispatch handlers ont accès à `self`).
|
/// au passage (les Dispatch handlers ont accès à `self`).
|
||||||
pub fn dispatch_clients(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn dispatch_clients(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
@ -425,13 +509,29 @@ impl WaylandFrontend {
|
||||||
(self.cursor_x, self.cursor_y)
|
(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
|
/// 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) {
|
pub fn forward_input(&mut self, ev: &RedoxInputEvent) {
|
||||||
match ev {
|
match ev {
|
||||||
RedoxInputEvent::Key {
|
RedoxInputEvent::Key {
|
||||||
scancode, pressed, ..
|
scancode, pressed, ..
|
||||||
} => {
|
} => {
|
||||||
|
let Some(focus_cid) = self.focused_client_id() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let time = self.alloc_input_time();
|
let time = self.alloc_input_time();
|
||||||
let serial = self.alloc_input_serial();
|
let serial = self.alloc_input_serial();
|
||||||
// Wayland keycodes = scancode evdev = scancode +8 on linux
|
// Wayland keycodes = scancode evdev = scancode +8 on linux
|
||||||
|
|
@ -446,7 +546,9 @@ impl WaylandFrontend {
|
||||||
wl_keyboard::KeyState::Released
|
wl_keyboard::KeyState::Released
|
||||||
};
|
};
|
||||||
for kb in &self.keyboards {
|
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 } => {
|
RedoxInputEvent::PointerMotion { x, y } => {
|
||||||
|
|
@ -455,11 +557,14 @@ impl WaylandFrontend {
|
||||||
self.cursor_visible = true;
|
self.cursor_visible = true;
|
||||||
let time = self.alloc_input_time();
|
let time = self.alloc_input_time();
|
||||||
if let Some(focus) = self.focused_surface.clone() {
|
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);
|
let (sx, sy) = self.surface_local_cursor(&focus);
|
||||||
for ptr in &self.pointers {
|
for ptr in &self.pointers {
|
||||||
ptr.motion(time, fixed_from_int(sx), fixed_from_int(sy));
|
if ptr.client().map(|c| c.id()) == focus_cid {
|
||||||
if SEAT_VERSION >= 5 {
|
ptr.motion(time, fixed_from_int(sx), fixed_from_int(sy));
|
||||||
ptr.frame();
|
if SEAT_VERSION >= 5 {
|
||||||
|
ptr.frame();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -470,11 +575,14 @@ impl WaylandFrontend {
|
||||||
self.cursor_visible = true;
|
self.cursor_visible = true;
|
||||||
let time = self.alloc_input_time();
|
let time = self.alloc_input_time();
|
||||||
if let Some(focus) = self.focused_surface.clone() {
|
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);
|
let (sx, sy) = self.surface_local_cursor(&focus);
|
||||||
for ptr in &self.pointers {
|
for ptr in &self.pointers {
|
||||||
ptr.motion(time, fixed_from_int(sx), fixed_from_int(sy));
|
if ptr.client().map(|c| c.id()) == focus_cid {
|
||||||
if SEAT_VERSION >= 5 {
|
ptr.motion(time, fixed_from_int(sx), fixed_from_int(sy));
|
||||||
ptr.frame();
|
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();
|
let time = self.alloc_input_time();
|
||||||
// Code BTN_LEFT/MIDDLE/RIGHT linux/input-event-codes.h
|
// Code BTN_LEFT/MIDDLE/RIGHT linux/input-event-codes.h
|
||||||
const BTN_LEFT: u32 = 0x110;
|
const BTN_LEFT: u32 = 0x110;
|
||||||
|
|
@ -523,7 +637,7 @@ impl WaylandFrontend {
|
||||||
// Note : orbclient envoie l'état complet des 3 boutons à chaque
|
// Note : orbclient envoie l'état complet des 3 boutons à chaque
|
||||||
// event. Côté Wayland on devrait envoyer un event par changement
|
// 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,
|
// 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 {
|
for (btn, pressed) in buttons {
|
||||||
let serial = self.alloc_input_serial();
|
let serial = self.alloc_input_serial();
|
||||||
let state = if pressed {
|
let state = if pressed {
|
||||||
|
|
@ -532,18 +646,28 @@ impl WaylandFrontend {
|
||||||
wl_pointer::ButtonState::Released
|
wl_pointer::ButtonState::Released
|
||||||
};
|
};
|
||||||
for ptr in &self.pointers {
|
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 {
|
if SEAT_VERSION >= 5 {
|
||||||
for ptr in &self.pointers {
|
for ptr in &self.pointers {
|
||||||
ptr.frame();
|
if ptr.client().map(|c| c.id()) == Some(focus_cid.clone()) {
|
||||||
|
ptr.frame();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RedoxInputEvent::PointerScroll { dx, dy } => {
|
RedoxInputEvent::PointerScroll { dx, dy } => {
|
||||||
|
let Some(focus_cid) = self.focused_client_id() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let time = self.alloc_input_time();
|
let time = self.alloc_input_time();
|
||||||
for ptr in &self.pointers {
|
for ptr in &self.pointers {
|
||||||
|
if ptr.client().map(|c| c.id()) != Some(focus_cid.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if *dy != 0 {
|
if *dy != 0 {
|
||||||
ptr.axis(
|
ptr.axis(
|
||||||
time,
|
time,
|
||||||
|
|
@ -784,6 +908,10 @@ impl wayland_server::Dispatch<wl_compositor::WlCompositor, ()> for WaylandFronte
|
||||||
pending_frame_callbacks: Mutex::new(Vec::new()),
|
pending_frame_callbacks: Mutex::new(Vec::new()),
|
||||||
xdg_pending_initial_configure: Mutex::new(false),
|
xdg_pending_initial_configure: Mutex::new(false),
|
||||||
is_cursor: AtomicBool::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));
|
let surf = data_init.init(id, Arc::new(data));
|
||||||
// Phase 7.4 : enregistrer le mapping SurfaceId → WlSurface
|
// 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
|
// x/y sont le hint de placement par rapport à l'ancien buffer
|
||||||
// (Wayland-spec) ; pour 6.4 on ignore et on garde la position
|
// (Wayland-spec) ; pour 6.4 on ignore et on garde la position
|
||||||
// courante de la surface.
|
// courante de la surface.
|
||||||
let bd = match buffer {
|
*data.pending_buffer.lock().unwrap() = buffer;
|
||||||
Some(buf) => match buf.data::<BufferData>() {
|
|
||||||
Some(d) => Some(d.clone()),
|
|
||||||
None => None,
|
|
||||||
},
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
*data.pending_buffer.lock().unwrap() = bd;
|
|
||||||
}
|
}
|
||||||
wl_surface::Request::Damage { .. } | wl_surface::Request::DamageBuffer { .. } => {
|
wl_surface::Request::Damage { .. } | wl_surface::Request::DamageBuffer { .. } => {
|
||||||
// Damage tracking minimal pour 6.4 : on recompose tout. À
|
// 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);
|
let is_cursor = data.is_cursor.load(Ordering::Relaxed);
|
||||||
|
|
||||||
// Lire le buffer attaché (s'il y en a un)
|
// Lire le buffer attaché (s'il y en a un). On `take()` pour
|
||||||
let bd_opt = data.pending_buffer.lock().unwrap().clone();
|
// que le pending_buffer soit vidé après chaque commit
|
||||||
if let Some(bd) = bd_opt {
|
// (sémantique Wayland : le buffer attaché n'est consommé
|
||||||
if !bd.valid {
|
// qu'au commit ; le client peut le réutiliser ensuite).
|
||||||
// Phase 7.5 : buffer marqué invalide à la création
|
let buf_opt = data.pending_buffer.lock().unwrap().take();
|
||||||
// (dimensions ou offset incohérents avec le pool).
|
if let Some(buf) = buf_opt {
|
||||||
// On ignore plutôt que de lire des octets hors-pool.
|
// Phase 7.6 : récupérer les params BufferData via le
|
||||||
} else {
|
// UserData du wl_buffer Resource.
|
||||||
// Lire les pixels et créer un SurfaceBuffer compositor-core
|
if let Some(bd) = buf.data::<BufferData>().cloned() {
|
||||||
let pool = bd.pool.lock().unwrap();
|
if !bd.valid {
|
||||||
let pixels_opt = unsafe {
|
// Phase 7.5 : buffer marqué invalide à la création
|
||||||
pool.read_argb(bd.offset as usize, bd.width, bd.height, bd.stride)
|
// (dimensions ou offset incohérents avec le pool).
|
||||||
};
|
// On ignore plutôt que de lire des octets hors-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 {
|
} else {
|
||||||
println!(
|
let pool = bd.pool.lock().unwrap();
|
||||||
"[frontend] commit: read_argb refused buffer (overrun guard)"
|
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);
|
state.registry.commit(id);
|
||||||
if !is_cursor {
|
if !is_cursor {
|
||||||
|
|
|
||||||
BIN
docs/phase7-6-cleanup-no-ghost.png
Normal file
BIN
docs/phase7-6-cleanup-no-ghost.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 658 B |
273
docs/phase7-6-multi-clients.md
Normal file
273
docs/phase7-6-multi-clients.md
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
# Phase 7.6 — Multi-clients paquet B
|
||||||
|
|
||||||
|
> Document produit le 2026-05-13 dans le cadre du plan directeur
|
||||||
|
> `REDOX_COSMIC_XWAYLAND_RS_PLAN.md`.
|
||||||
|
>
|
||||||
|
> **Scope strict (3 livrables)** :
|
||||||
|
> 1. Cleanup post-disconnect : nettoyer surfaces / pointers / keyboards
|
||||||
|
> d'un client mort, même si exit brutal sans destroy (corrige le
|
||||||
|
> sub-bug 7.5 surface fantôme).
|
||||||
|
> 2. `wl_buffer.release` envoyé par le serveur après chaque
|
||||||
|
> commit-copy (sémantique Wayland correcte : sans ça les toolkits
|
||||||
|
> sérieux finissent par bloquer faute de buffer libre).
|
||||||
|
> 3. Filtrage events pointer / keyboard par client focused (au lieu
|
||||||
|
> du broadcast 7.2 qui envoyait à tous les clients).
|
||||||
|
>
|
||||||
|
> **Hors scope 7.6** : move/resize interactifs (7.7), focus
|
||||||
|
> follows-mouse, support multi-seat, frame callbacks throttled.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**✅ 3 livrables validés runtime.**
|
||||||
|
|
||||||
|
### Capture 1 — cleanup post-disconnect
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
État du framebuffer 30 s après la fin du fuzz protocol binary. Le
|
||||||
|
rectangle noir 320×240 qui persistait en 7.5 (surface fantôme du
|
||||||
|
fuzz1 qui a commit + exit brutal sans destroy) a **complètement
|
||||||
|
disparu**. Curseur software toujours actif au centre, fond
|
||||||
|
compositor propre. Logs frontend :
|
||||||
|
|
||||||
|
```
|
||||||
|
[frontend] focus change: None → Some(SurfaceId(0))
|
||||||
|
[frontend] garbage_collect: client InnerClientId { id: 0, serial: 1 } → destroyed 1 surfaces
|
||||||
|
[frontend] xdg_surface.ack_configure: serial 99999 > last_sent 2, ignoring
|
||||||
|
[frontend] garbage_collect: client InnerClientId { id: 0, serial: 2 } → destroyed 0 surfaces
|
||||||
|
[frontend] wl_shm_pool.create_buffer rejected: ...
|
||||||
|
[frontend] garbage_collect: client InnerClientId { id: 0, serial: 3 } → destroyed 0 surfaces
|
||||||
|
[frontend] wl_shm_pool.create_buffer rejected: ...
|
||||||
|
[frontend] garbage_collect: client InnerClientId { id: 0, serial: 4 } → destroyed 0 surfaces
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `garbage_collect` a destroyed 1 surface pour le client du fuzz1
|
||||||
|
(brutal exit sans destroy = surface orpheline), et 0 pour les
|
||||||
|
fuzz2-4 (cleanup propre via `surface.destroy` avant exit, donc
|
||||||
|
registry déjà vidée). Compositor `surfaces=0` stable pendant 30 s+.
|
||||||
|
|
||||||
|
### Capture 2 — 2 clients en parallèle
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Reprise du test `redox-wl-test-client-shm-two` (parent + fork)
|
||||||
|
sous le compositor 7.6 : les 2 fenêtres A (vert, pyramide) et B
|
||||||
|
(magenta, double cercle) s'affichent correctement. `surfaces=2`
|
||||||
|
stable. Un `mouse_button 1` au curseur central est reçu par le
|
||||||
|
compositor (`[comp] 1 input events from inputd`) sans crash : hit
|
||||||
|
test ne trouve aucune surface à (640, 400), donc pas de
|
||||||
|
raise/focus change, et le button event est ignoré côté filtrage
|
||||||
|
(aucun pointer du `focused_surface` n'existe car `focused_surface`
|
||||||
|
est `None` à ce moment).
|
||||||
|
|
||||||
|
## Modifications apportées
|
||||||
|
|
||||||
|
### `redox-wl-wayland-frontend`
|
||||||
|
|
||||||
|
**`SurfaceData`** : nouveau champ
|
||||||
|
```rust
|
||||||
|
client_id: Mutex<Option<ClientId>>,
|
||||||
|
```
|
||||||
|
peuplé au `wl_compositor.create_surface` avec `Some(_client.id())`
|
||||||
|
**pendant que `_client` est encore vivant**. À la déconnexion,
|
||||||
|
`surf.client()` retourne déjà `None`, donc on ne pourrait plus
|
||||||
|
déduire le ClientId rétroactivement. C'est le piège n°1 de l'API
|
||||||
|
wayland-server (cf §pièges).
|
||||||
|
|
||||||
|
**`DumbClientData`** : nouveau champ
|
||||||
|
```rust
|
||||||
|
dead_clients: Arc<Mutex<Vec<ClientId>>>,
|
||||||
|
```
|
||||||
|
partagé entre tous les clients (cloné à `accept_pending_clients`).
|
||||||
|
La méthode `ClientData::disconnected(client_id, _reason)` push le
|
||||||
|
client_id dans cette queue (synchrone, sans tenter de modifier
|
||||||
|
l'état du frontend — wayland-server ne nous donne pas accès au
|
||||||
|
`&mut WaylandFrontend` ici).
|
||||||
|
|
||||||
|
**`WaylandFrontend::garbage_collect_dead_clients`** (nouvelle
|
||||||
|
méthode publique) : drain la queue `dead_clients` et nettoie pour
|
||||||
|
chaque ClientId mort :
|
||||||
|
- les `SurfaceId` dont `client_id` matche : `registry.destroy(sid)`
|
||||||
|
+ `surfaces_by_id.remove(sid)` + clear `focused_surface` et
|
||||||
|
`cursor_surface_id` si applicable
|
||||||
|
- les `wl_pointer` et `wl_keyboard` orphelins (filtrés via
|
||||||
|
`p.client().is_some()` — les Resources d'un client mort
|
||||||
|
retournent `None`)
|
||||||
|
|
||||||
|
Appelée à chaque tick du compositor binaire après `dispatch_clients`.
|
||||||
|
|
||||||
|
**`wl_surface.commit`** : envoie `buf.release()` après la
|
||||||
|
copy-on-commit (ou même si le buffer est invalide). Sémantique
|
||||||
|
Wayland : le serveur signale au client qu'il peut réutiliser le
|
||||||
|
buffer. Sans ça, des toolkits comme GTK4 finissent par bloquer en
|
||||||
|
attendant un buffer libre.
|
||||||
|
|
||||||
|
Refactor associé : `SurfaceData.pending_buffer` est passé de
|
||||||
|
`Option<BufferData>` à `Option<wl_buffer::WlBuffer>` (le Resource
|
||||||
|
proxy au lieu d'une copie de ses params). On lit les params via
|
||||||
|
`buf.data::<BufferData>().cloned()` au moment du commit.
|
||||||
|
|
||||||
|
**`forward_input`** : filtrage par client focused (fin du broadcast
|
||||||
|
7.2). Pour chaque type d'event, on calcule
|
||||||
|
```rust
|
||||||
|
let focused_cid = self.focused_surface
|
||||||
|
.as_ref().and_then(|s| s.client().map(|c| c.id()));
|
||||||
|
```
|
||||||
|
et on n'envoie l'event qu'aux `wl_pointer` / `wl_keyboard` dont le
|
||||||
|
`client().map(|c| c.id())` matche. Si pas de focus, aucun event
|
||||||
|
n'est envoyé aux clients (le hit_test au clic gauche peut quand
|
||||||
|
même focaliser quelqu'un avant l'envoi du button).
|
||||||
|
|
||||||
|
Pour `PointerButton`, on recalcule `focused_cid` **après** le bloc
|
||||||
|
`hit_test → raise → set_focus`, parce que le clic a pu changer
|
||||||
|
le focus (et donc le client cible).
|
||||||
|
|
||||||
|
### `redox-wl-compositor`
|
||||||
|
|
||||||
|
Une ligne ajoutée dans la boucle main :
|
||||||
|
```rust
|
||||||
|
frontend.garbage_collect_dead_clients();
|
||||||
|
```
|
||||||
|
juste après `dispatch_clients()`. Coût négligeable (1 lock + check
|
||||||
|
empty + drain si non-vide).
|
||||||
|
|
||||||
|
## Pièges trouvés
|
||||||
|
|
||||||
|
### `Resource::client_id()` n'existe pas — il faut `Resource::client().map(|c| c.id())`
|
||||||
|
|
||||||
|
Wayland-server n'a pas de méthode `client_id()` directe sur les
|
||||||
|
Resource. L'API est :
|
||||||
|
- `Resource::client(&self) -> Option<Client>` — retourne le client
|
||||||
|
propriétaire **s'il est encore alive**
|
||||||
|
- `Client::id() -> ClientId`
|
||||||
|
|
||||||
|
Donc `surf.client_id()` → erreur de compile. Correction :
|
||||||
|
`surf.client().map(|c| c.id())`.
|
||||||
|
|
||||||
|
### `surf.client()` retourne `None` dans `disconnected`
|
||||||
|
|
||||||
|
À l'instant où `ClientData::disconnected(client_id, reason)` est
|
||||||
|
appelé, wayland-server a **déjà** marqué les Resources de ce client
|
||||||
|
comme dead. Tous les `surf.client()` retournent `None`. Donc on ne
|
||||||
|
peut PAS faire le mapping inverse "ClientId → SurfaceIds" à ce
|
||||||
|
moment-là.
|
||||||
|
|
||||||
|
**Solution adoptée** : capturer le `ClientId` au moment du
|
||||||
|
`wl_compositor.create_surface` (où `_client: &Client` est passé
|
||||||
|
en paramètre, et est vivant). Stocker dans
|
||||||
|
`SurfaceData.client_id`. Le `garbage_collect` itère
|
||||||
|
`surfaces_by_id` et compare au `client_id` stocké, pas au
|
||||||
|
`surf.client()` courant.
|
||||||
|
|
||||||
|
### `nowait sh -c "..."` ne marche pas dans l'init Redox
|
||||||
|
|
||||||
|
Rappel 7.4 + 7.5 : l'init parse incorrectement les guillemets dans
|
||||||
|
une directive `nowait sh -c "..."`. Toujours utiliser un script
|
||||||
|
wrapper sur disque (chmod +x) puis `nowait /usr/bin/launch_X.sh`.
|
||||||
|
|
||||||
|
## Validation runtime
|
||||||
|
|
||||||
|
### Test 1 : cleanup fuzz1 brutal exit
|
||||||
|
|
||||||
|
```
|
||||||
|
[boot Redox + service phase76_fuzz lance compositor + fuzz binary]
|
||||||
|
[fuzz1 : create_surface + xdg_toplevel + commit + buffer + commit + process::exit(0)]
|
||||||
|
|
||||||
|
Avant 7.6 : surfaces=1 persiste pour toujours après exit du fuzz1
|
||||||
|
Après 7.6 : [frontend] garbage_collect: client X → destroyed 1 surfaces
|
||||||
|
surfaces=0 stable post-fuzz
|
||||||
|
```
|
||||||
|
|
||||||
|
Capture `phase7-6-cleanup-no-ghost.png` confirme visuellement.
|
||||||
|
|
||||||
|
### Test 2 : 2 clients en parallèle
|
||||||
|
|
||||||
|
```
|
||||||
|
[redox-wl-test-client-shm-two : parent + fork de redox-wl-test-client-shm-two]
|
||||||
|
[A : compositor surface vert pyramide à (60, 60)]
|
||||||
|
[B : compositor surface magenta cercle à (120, 120)]
|
||||||
|
[mouse_button 1 au centre → hit_test=None → button event ignoré
|
||||||
|
par le filtrage (focused_surface est None à ce moment)]
|
||||||
|
|
||||||
|
Compositor surfaces=2 stable, ticks continus, aucun crash.
|
||||||
|
```
|
||||||
|
|
||||||
|
Capture `phase7-6-two-clients.png` confirme.
|
||||||
|
|
||||||
|
### Logs frontend extraits
|
||||||
|
|
||||||
|
```
|
||||||
|
[frontend] xdg_surface.ack_configure: serial 99999 > last_sent 2, ignoring # 7.5 garde
|
||||||
|
[frontend] wl_shm_pool.create_buffer rejected: offset=0 width=0 height=0 # 7.5 garde
|
||||||
|
[frontend] wl_shm_pool.create_buffer rejected: offset=0 width=100 ... stride=10
|
||||||
|
[frontend] garbage_collect: client InnerClientId { ..., serial: 1 } → destroyed 1 surfaces
|
||||||
|
[frontend] garbage_collect: client InnerClientId { ..., serial: 2 } → destroyed 0 surfaces
|
||||||
|
[frontend] garbage_collect: client InnerClientId { ..., serial: 3 } → destroyed 0 surfaces
|
||||||
|
[frontend] garbage_collect: client InnerClientId { ..., serial: 4 } → destroyed 0 surfaces
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations connues (à traiter en sous-tickets ultérieurs)
|
||||||
|
|
||||||
|
- **wl_buffer.release : pas de mécanisme pour ne pas envoyer plusieurs
|
||||||
|
fois sur le même buffer** : si le client attach le même wl_buffer
|
||||||
|
+ commit deux fois consécutivement sans réutiliser, on enverra
|
||||||
|
release deux fois. Wayland-server gère probablement ça via le
|
||||||
|
refcount du Resource — à confirmer en 7.7.
|
||||||
|
- **Pas de protection contre buffer attaché à plusieurs surfaces en
|
||||||
|
même temps** : la spec dit "un buffer ne doit être attaché qu'à
|
||||||
|
une seule surface à la fois". On ne vérifie pas. À durcir si on
|
||||||
|
observe des artefacts.
|
||||||
|
- **Filtrage pointer.enter/leave non concerné** : le `set_focus`
|
||||||
|
envoie enter/leave aux `wl_pointer` et `wl_keyboard` du client de
|
||||||
|
la nouvelle / ancienne focused_surface, ce qui est déjà
|
||||||
|
semi-filtré (les leave/enter visent une surface précise et donc
|
||||||
|
un client précis). Le filtrage 7.6 concerne uniquement les events
|
||||||
|
*de mouvement et de touche* hors enter/leave.
|
||||||
|
- **Frame callbacks restent globaux** : `notify_frame_done` signale
|
||||||
|
tous les callbacks en queue, peu importe le client. Reportable
|
||||||
|
7.7 si on veut throttler par client.
|
||||||
|
- **Pas de test "kill -9 du client"** : on a testé l'exit brutal
|
||||||
|
via `process::exit(0)`, mais pas via SIGKILL. Le comportement
|
||||||
|
devrait être identique (le kernel ferme les fds, wayland-server
|
||||||
|
détecte la fin du socket). À confirmer si jamais.
|
||||||
|
|
||||||
|
## Critère de fin 7.6
|
||||||
|
|
||||||
|
> Le compositor (1) nettoie automatiquement les surfaces des
|
||||||
|
> clients déconnectés (y compris brutal exit), (2) envoie
|
||||||
|
> wl_buffer.release après chaque commit-copy, (3) ne broadcast plus
|
||||||
|
> les events pointer/keyboard mais les filtre par client focused.
|
||||||
|
|
||||||
|
**✅ Validé.** Le sub-bug 7.5 (surface fantôme) est résolu, le
|
||||||
|
compositor tourne stable avec 2 clients en parallèle, les logs
|
||||||
|
frontend confirment les 3 chemins.
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/redox-wl-wayland-frontend/ # ~+90 lignes
|
||||||
|
crates/redox-wl-compositor/ # +1 ligne (gc dans boucle main)
|
||||||
|
docs/phase7-6-multi-clients.md
|
||||||
|
docs/phase7-6-cleanup-no-ghost.png
|
||||||
|
docs/phase7-6-two-clients.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Suite phase 7.7
|
||||||
|
|
||||||
|
Move / resize interactifs via `xdg_toplevel`. À l'envoi de
|
||||||
|
`xdg_toplevel.Move { seat, serial }` ou
|
||||||
|
`xdg_toplevel.Resize { seat, serial, edges }` par un client en
|
||||||
|
réponse à un événement pointer (typiquement clic sur la titlebar
|
||||||
|
côté toolkit), le compositor doit :
|
||||||
|
1. Vérifier la validité du serial (correspond à un button récent)
|
||||||
|
2. Entrer en mode "interactive_move" ou "interactive_resize"
|
||||||
|
3. Sur les `PointerMotion` suivants, déplacer ou redimensionner la
|
||||||
|
surface (et envoyer `configure` avec la nouvelle taille pour
|
||||||
|
resize)
|
||||||
|
4. Sortir du mode au release du bouton
|
||||||
|
|
||||||
|
Estimé : 1-2 sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Fin du document de phase 7.6.*
|
||||||
BIN
docs/phase7-6-two-clients.png
Normal file
BIN
docs/phase7-6-two-clients.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Loading…
Add table
Add a link
Reference in a new issue