# 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 ![](phase7-6-cleanup-no-ghost.png) É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 ![](phase7-6-two-clients.png) 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>, ``` 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>>, ``` 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` à `Option` (le Resource proxy au lieu d'une copie de ses params). On lit les params via `buf.data::().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` — 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.*