feat(session-v2): save and restore Sway scratchpad with guarded recovery
This commit is contained in:
parent
5aeff41d2c
commit
d16aa69f9a
28 changed files with 2063 additions and 61 deletions
|
|
@ -270,7 +270,71 @@ Voir le [diagnostic ciblé, les preuves et la commande hôte](session-v2-path-st
|
|||
|
||||
- Contenu et processus terminal non restaurés.
|
||||
- Ratios exacts non restaurés.
|
||||
- Fullscreen et scratchpad non pris en charge actuellement.
|
||||
- Fullscreen non pris en charge. Scratchpad pris en charge par le snapshot version 2 pour les feuilles flottantes individuelles ; les groupes restent refusés.
|
||||
- Multi-output physique non validé.
|
||||
- Provider Firefox candidat actuel pour le profil supporté ; plusieurs profils
|
||||
simultanés ne sont pas pris en charge.
|
||||
|
||||
|
||||
## Snapshot version 2 — scratchpad (22 octobre 2026)
|
||||
|
||||
Les nouvelles captures conservent `schema=labfy.sway.session-v2` et écrivent
|
||||
`version=2`. Le contrat V1 et `schema-v1.json` restent inchangés : les anciennes
|
||||
sessions sont lues, validées et restaurées directement, sans conversion ni
|
||||
invention d’appartenance scratchpad. `schema-v2.json` décrit la nouvelle forme ;
|
||||
`schema.py` ajoute les contraintes référentielles (unicité, cardinalité, chemins,
|
||||
focus et association workspace/output) aux contraintes locales du schéma JSON.
|
||||
Le bundle de développement contient les deux schémas.
|
||||
|
||||
Dans V2, `state.scratchpad` vaut `none`, `hidden` ou `shown`. Une feuille
|
||||
scratchpad est nécessairement flottante, avec son rectangle IPC entier
|
||||
`x/y/width/height`. Une feuille cachée a `workspace=null` et `tree_path=[]` ;
|
||||
`__i3_scratch` n’entre jamais dans les workspaces, sorties ou ancres persistés.
|
||||
Une feuille montrée garde le workspace/output de visibilité. `focus` reste une
|
||||
référence de slot ou null, jamais une feuille déduite d’un split. Le champ V2
|
||||
`focus_workspace` conserve le workspace effectivement focalisé, y compris un
|
||||
workspace vide lorsque toutes les fenêtres sont cachées. Ce seul workspace vide
|
||||
est admissible sans slot ; son output doit être disponible. Une feuille cachée
|
||||
ne peut être le focus persistant.
|
||||
|
||||
La capture inclut les scratchpads cachés sous l’output interne Sway et les
|
||||
scratchpads montrés parmi les flottants normaux. Les relectures comparent aussi
|
||||
app_id, PID, appartenance scratchpad et focus ; un changement donne
|
||||
`SNAPSHOT_CHANGED`. Les groupes flottants/scratchpad ne sont pas aplatis en
|
||||
feuilles indépendantes : ils sont refusés précisément. Les fenêtres de même
|
||||
application sans fournisseur exact restent explicitement interchangeables.
|
||||
Le mapping privilégie la visibilité déjà observée pour ces slots ; une feuille
|
||||
scratchpad existante n’est pas exposée pour remplir arbitrairement un slot
|
||||
ordinaire. Les preuves UUID/singleton restent prioritaires, puis les identités
|
||||
runtime admises comprennent con_id/PID/starttime/session/placement.
|
||||
|
||||
La phase de placement (`plan.placements`) est distincte de la compilation des
|
||||
ancres de splits. Les scratchpads existants évitent staging et `floating disable`.
|
||||
Les commandes sont ciblées par l’identité admise ; `scratchpad show` n’est émis
|
||||
que pour une feuille prouvée cachée et un slot explicitement montré. L’ajout
|
||||
initial au scratchpad sélectionne le workspace source, même si la cible était
|
||||
inactive : cette transition est prédite avant IPC. Le journal précède chaque
|
||||
commande. Les transitions `floating enable` et `scratchpad show` ont une attente
|
||||
bornée de 300 ms pour laisser passer la configuration Wayland initiale ; cette
|
||||
attente n’est pas une preuve de stabilité. Toute divergence encore observée
|
||||
reste un échec. Le rectangle intermédiaire d’un `show` peut être converti par
|
||||
Sway selon l’output ; le rectangle sauvegardé est ensuite commandé et vérifié
|
||||
exactement, avec appartenance, visibilité, output, layout, identité et focus.
|
||||
|
||||
Les nouveaux journaux Recovery sont en version 2 et suivent l’appartenance
|
||||
scratchpad. Les journaux V1 restent lisibles ; une observation scratchpad absente
|
||||
de leurs preuves est refusée, jamais reconstruite implicitement. Après incident,
|
||||
un scratchpad déjà exactement à son origine peut être reconnu sans IPC. Si son
|
||||
placement diffère, la reprise conserve les données et donne
|
||||
`SCRATCHPAD_RECOVERY_REQUIRES_REVIEW` ; elle n’affiche aucune feuille pour tenter
|
||||
un rollback spéculatif. Les intentions pending et les conflits de focus restent
|
||||
bloquants. L’ancien journal utilisateur `RECOVERY_REQUIRED` n’est ni modifié ni
|
||||
acquitté par ce patch.
|
||||
|
||||
Le dialogue d’échec de checkpoint montre maintenant le `reason` JSON exact,
|
||||
avec une explication pour les cas connus, notamment `CAPABILITY_UNSUPPORTED`.
|
||||
L’erreur conserve l’action en attente : annulation ou choix explicite de quitter
|
||||
sans sauvegarder restent nécessaires. Aucun code d’erreur n’autorise le logout.
|
||||
|
||||
Validation et limites détaillées :
|
||||
[rapport scratchpad](session-v2-scratchpad-save-and-restore-v1.md).
|
||||
|
|
|
|||
219
docs/session-v2-focus-recovery-fix-v1.md
Normal file
219
docs/session-v2-focus-recovery-fix-v1.md
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
# Session V2 — focus et récupération, 22 octobre 2026
|
||||
|
||||
## Portée et état
|
||||
|
||||
Patch isolé sur `fix/session-v2-focus-recovery-20261022`, worktree
|
||||
`/home/fy59/Documents/Development/labfy-session-v2-focus-recovery-20261022`,
|
||||
base `5aeff41d2ce8c91966ce65fd2fa3261f34f3f954`. Le dépôt principal était propre
|
||||
à cette base. Aucun commit, déploiement, redémarrage ou IPC mutant sur le bureau.
|
||||
Les tests utilisent des arbres inertes ou des compositeurs enfants headless privés.
|
||||
Les trois journaux réels ont été lus et leurs SHA-256 vérifiés inchangés.
|
||||
Le choix réel `startup.json=new` ne donne aucune autorisation de reprise.
|
||||
|
||||
La fausse classification d'un changement de chemin est reproduite et corrigée
|
||||
pour les nouveaux journaux disposant d'une preuve de structure. L'ordre de fin
|
||||
du helper d'ancres est corrigé et les frontières du watchdog sont journalisées.
|
||||
**La cause historique exacte du focus Firefox → Kitty reste non démontrée.**
|
||||
Ce patch ne constitue donc pas une preuve de correction définitive du login réel.
|
||||
Aucune divergence n'est réinterprétée comme un succès.
|
||||
|
||||
## Chronologie établie de l'incident
|
||||
|
||||
Le rapport réel indique trois applications observées, trois slots remplis,
|
||||
trois ancres créées/permutées/nettoyées, un helper suspendu/repris et l'arbre
|
||||
vérifié. `final_tree_verified=true` est une étape validée, pas la preuve que
|
||||
l'arbre est resté identique après reprise.
|
||||
|
||||
Le journal conserve `operations=18`, une dernière intention `[con_id=8] focus`,
|
||||
`focus_expected={con_id:8,workspace:"1"}` et `focus_pending=false`. Dans le code
|
||||
exécuté, ces deux derniers champs ne sont publiés qu'après le retour réussi de
|
||||
RUN_COMMAND et une observation de focus conforme. Cela prouve une observation
|
||||
de Firefox après la commande ; cela ne prouve pas l'absence d'input concurrent.
|
||||
|
||||
La séquence source était :
|
||||
|
||||
1. Verrou V2 et refus d'une récupération active/ambiguë ; prewarm des applications,
|
||||
identités/PID/starttime, puis preuve de focus stable sur deux arbres.
|
||||
2. Création de Recovery et baseline, lancement du helper d'ancres sans surface,
|
||||
découverte et suspension vérifiée d'autotiling via pidfd.
|
||||
3. Réadmission stricte du lot, déplacements au stage, construction des ancres,
|
||||
résolution Firefox, permutations et retrait des seules ancres possédées.
|
||||
4. Vérifications de structure, identités, outputs et flottants ; choix du focus
|
||||
final depuis le checkpoint ou l'origine prouvée ; commande vers Firefox8.
|
||||
5. `guard.resume()` envoie SIGCONT et attend seulement l'état non stoppé du helper.
|
||||
6. `_verify()` refait notamment l'appel provider et `validate_final()`. Sa lecture
|
||||
`final-observation` constate Kitty7, enregistre la première divergence et échoue.
|
||||
7. L'appel **normal** suivant à `guard.finish()` n'a donc pas encore été atteint.
|
||||
Le finally appelle ensuite finish sur erreur, puis la récupération conservatrice.
|
||||
|
||||
Le premier conflit `after-command/final-observation` désigne une validation,
|
||||
**pas une commande focus qui aurait échoué**. La fenêtre causale est postérieure
|
||||
à l'observation conforme de Firefox et antérieure à cette lecture. Elle contient
|
||||
la reprise des helpers et l'attente provider. L'incident ne permet pas de choisir
|
||||
entre un helper, un autre client IPC, une activation Wayland/layer ou un input
|
||||
utilisateur. Il ne démontre pas un défaut du déplacement initial.
|
||||
|
||||
## Autotiling, Sway et interface
|
||||
|
||||
L'autotiling installé est lancé par `exec autotiling` dans l'autostart Sway.
|
||||
Son handler `switch_splitting` traite WINDOW/MODE, relit le focus puis envoie
|
||||
`splith`/`splitv` sans critère con_id. SIGCONT peut donc libérer un backlog et une
|
||||
commande déjà préparée ; l'ACK actuel ne prouve aucun drainage. Le code ne demande
|
||||
pas directement de focus. Les splits peuvent insérer un parent unaire ; le code
|
||||
Sway de `container_split` conserve le focus de son enfant lorsqu'il était focalisé.
|
||||
La seule présence d'autotiling ne démontre donc pas la transition vers Kitty.
|
||||
Une restauration avec le véritable autotiling dans un Lab privé réussit avant
|
||||
et après patch.
|
||||
|
||||
Les erreurs réelles `'NoneType' object has no attribute 'type'` sont visibles
|
||||
à 06:11:58.214 et 06:11:58.390, mais aussi avant à 06:11:53.835/.836. Le handler
|
||||
appelle `output_name(con)` avant de vérifier `con` : un `find_focused()` nul peut
|
||||
produire précisément cette erreur. Il n'existe ni traceback ni trace de commandes
|
||||
ou d'input permettant d'en déduire une commande de focus causale.
|
||||
|
||||
Les règles Sway du dépôt ne refocalisent pas Firefox/Kitty. Le script historique
|
||||
`layout-workspace1.sh` contient des commandes focus sur d'autres identités, sans
|
||||
invocation dans l'autostart examiné. L'abonné d'opacité commande l'opacité et les
|
||||
bordures. Le provider Firefox change `titlePreface`, sans `focused:true` ; un
|
||||
événement titre peut toutefois réveiller autotiling. L'UI retire sa layer avant
|
||||
le backend et vérifie le retrait sur deux ticks ; cela ne constitue pas une
|
||||
barrière atomique sur tous les clients Wayland. `startup.py` n'effectue aucun IPC
|
||||
mutant et le choix `new` ne déclenche aucune reprise. Ces composants restent inchangés.
|
||||
|
||||
Références primaires : [IPC Sway 1.12](https://github.com/swaywm/sway/blob/1.12/sway/sway-ipc.7.scd),
|
||||
[autotiling](https://github.com/nwg-piotr/autotiling/blob/master/autotiling/main.py).
|
||||
Les sources locales SwayFX `sway/tree/container.c`, `sway/input/seatop_default.c`
|
||||
et `sway/ipc-server.c` ont aussi été inspectées. RUN_COMMAND/GET_TREE séquentiels
|
||||
ne sont pas un compare-and-swap du focus et ne fournissent pas sa provenance.
|
||||
|
||||
## Protocole corrigé
|
||||
|
||||
Après le nettoyage/vérification du layout, l'executor observe le focus,
|
||||
puis appelle `guard.prepare()`. Le watchdog refuse s'il reste une ancre et
|
||||
quitte son helper ; il acquitte seulement après preuve de sortie sur le pidfd.
|
||||
Cela se produit pendant la suspension possédée, avant le focus final et SIGCONT.
|
||||
La vérification `Anchors.owned` commence par PID/app_id : les fenêtres applicatives
|
||||
peuvent être vérifiées après la sortie/récolte du helper, sans dépendre d'un zombie.
|
||||
Une candidate possédée exige toujours son pidfd exact.
|
||||
|
||||
Le moteur contrôle le focus après prepare, émet la commande finale sous les
|
||||
contrôles Recovery existants, observe avant/après resume, puis avant/après finish.
|
||||
La clôture préparée ne ferme plus d'ancre et n'écrit plus de quit tardif.
|
||||
Les chemins d'erreur conservent le nettoyage des seules ancres possédées et la
|
||||
reprise de tous les helpers dont le watchdog possède la suspension. Un helper
|
||||
initialement stoppé n'est jamais repris.
|
||||
|
||||
Les six observations `focus_boundaries` sont durables et bornées à huit ; elles
|
||||
localisent un premier constat, sans attribuer une cause. L'intention conserve
|
||||
désormais explicitement `focus_after` après réponse et observation conformes.
|
||||
Toute divergence refuse la suite ; aucun rattrapage/refocus automatique n'est
|
||||
ajouté. Les contrôles finaux après reprise et après finish restent présents.
|
||||
|
||||
**Limite :** prepare est une barrière sur le processus possédé, pas un ACK
|
||||
coopératif d'autotiling. Un helper repris peut toujours commander Sway. Une
|
||||
nouvelle instance non découverte, un autre client ou l'input restent concurrents.
|
||||
Le patch contrôle ces divergences observables et les refuse ; il ne prétend pas
|
||||
établir une causalité atomique ou empêcher tout changement après la dernière lecture.
|
||||
|
||||
## Récupération canonique
|
||||
|
||||
L'ancien `_recover` exigeait `current == expected`, chemins bruts inclus.
|
||||
Il pouvait donc annoncer `IDENTITY_OR_MEMBERSHIP_CHANGED` pour `[0] → [0,0]`
|
||||
ou `[1] → [1,0]`, alors que la validation finale ignorait déjà ces chemins et
|
||||
contrôlait ordre/placement/layout. La reproduction inerte échoue avant patch
|
||||
avec exactement deux rejets Firefox/Kitty, puis passe après correction.
|
||||
|
||||
Les nouveaux journaux V1 contiennent une extension facultative
|
||||
`structure_expected`, capturée sur l'arbre confirmé après commande. Elle utilise
|
||||
la même normalisation qu'Anchors : seuls les splits unaires splith/splitv/none
|
||||
sont contractés. Les feuilles sont les vrais con_id ; branches ordonnées,
|
||||
layouts tabbed/stacked et appartenance aux workspaces restent significatifs.
|
||||
La preuve est bornée en profondeur, nombre de branches et workspaces et soumise
|
||||
à la limite durable JSON existante. Aucune version de Project DB n'est concernée.
|
||||
|
||||
Avant de réconcilier des chemins, Recovery exige :
|
||||
|
||||
- Même session et aucune intention fenêtre/focus pending.
|
||||
- Ensemble exact des fenêtres gérées et étrangères, sans admission par app_id.
|
||||
- Égalité complète con_id/PID/starttime/app_id, workspace/output, flottement et
|
||||
géométrie flottante pertinente pour tout le lot.
|
||||
- Égalité de la structure canonique à la preuve durable antérieure.
|
||||
|
||||
Un changement de branche/layout/ordre reste refusé même si les chemins bruts
|
||||
n'ont pas changé ; chaque commande de récupération recontrôle la structure.
|
||||
Seuls les chemins `expected` sont réconciliés. Origin, before, intention et
|
||||
premier conflit restent conservés ; `path_reconciliations` garde les chemins
|
||||
avant/après (quatre lots maximum, puis refus plutôt qu'écrasement).
|
||||
Le contrôle strict avant chaque commande relit toujours l'état exact.
|
||||
|
||||
Les journaux historiques restent lisibles mais **sans fabrication rétroactive de
|
||||
preuve structurelle**. Une différence de chemin sans preuve devient
|
||||
`PATH_EQUIVALENCE_NOT_PROVEN`, une vraie différence de placement/identité reste
|
||||
`IDENTITY_OR_MEMBERSHIP_CHANGED`, une structure différente devient
|
||||
`STRUCTURE_OR_ORDER_CHANGED`. Aucun journal réel n'a été réécrit.
|
||||
|
||||
Reconnaître sans IPC une fenêtre déjà à son origine n'efface pas le conflit de
|
||||
focus : le reçu reste `RECOVERY_REQUIRED` avec `FOCUS_RECOVERY_NOT_PROVEN`.
|
||||
Ce patch ne débloque donc pas automatiquement la transaction réelle déjà refusée.
|
||||
|
||||
## Validation et preuves
|
||||
|
||||
Les commandes, résultats détaillés et empreintes sont conservés dans
|
||||
`build/focus-recovery/`, `build/real-focus-env/` et `build/session-v2-tests/` du
|
||||
worktree. Ces artefacts sont locaux, non déployés. Commande de validation composée :
|
||||
|
||||
```sh
|
||||
LABFY_TEST_RENDERER=pixman python -B tests/session_v2/validate_login_focus.py
|
||||
```
|
||||
|
||||
Le runner impose HOME/XDG privés et retire les sockets/bus de la session réelle.
|
||||
Chaque Lab lance SwayFX 0.6 / Sway 1.12 sur un socket enfant distinct avec Pixman.
|
||||
Les applications et UUID Firefox sont synthétiques. Les injections « helper »
|
||||
utilisent une commande déterministe du contrôleur après reprise ; elles ne
|
||||
prétendent pas reproduire une commande focus du véritable autotiling.
|
||||
|
||||
Les scénarios positifs exigent success, slots/permutations complets, arbre exact,
|
||||
focus hors stage, recovery complete et toutes les postconditions. Les négatifs
|
||||
exigent un refus, le maintien des clients et de l'incident, et l'absence de
|
||||
refocalisation. Les tests d'interruption conservent les intentions pending.
|
||||
`test_bootstrap.py` utilise désormais un temporaire court pour ne pas dépasser
|
||||
la limite AF_UNIX dans un worktree profond ; aucun test n'a été supprimé.
|
||||
|
||||
Résultats du dernier contrôle composé : **29 tests focus/recovery, 71 tests
|
||||
backend, 13 tests QML Python, 5 contrôles QtTest et 15 scénarios de login headless**.
|
||||
Les cinq variantes de `path_stability_runtime.py` et les dix variantes de
|
||||
`crashes.py` passent également. Les scénarios de succès exigent toutes les
|
||||
postconditions ; les injections tardives exigent RECOVERY_FOCUS_MISMATCH après
|
||||
les trois swaps, sans refocalisation.
|
||||
|
||||
Un élargissement à `runtime.py` a validé les 28 permutations de layouts ainsi
|
||||
que les cas de performance/multi-workspace précédant le flottant. **La suite
|
||||
complète ne passe pas** : sa fixture GTK flottante repasse de 400×240 à 122×82
|
||||
après le resize, d'où un refus de postcondition/état. La sonde privée identique
|
||||
échoue aussi contre la baseline non modifiée (`floating-baseline.log`) ; le
|
||||
patch échoue avec le même retour spontané de taille (`floating-patched.log`).
|
||||
Aucune tolérance géométrique ni suppression de ce test n'a été ajoutée. Ce
|
||||
résultat ne permet pas de revendiquer une validation complète des flottants ;
|
||||
les contrôles continuent de refuser leur géométrie divergente. Les deux cas
|
||||
d'identité après le flottant dans cette suite n'ont pas été atteints.
|
||||
|
||||
## Validation proposée au prochain login — autorisation humaine requise
|
||||
|
||||
1. Faire relire le diff ; autoriser séparément son intégration/déploiement et un
|
||||
essai réel au prochain login. Rien de cela n'a été exécuté par cette mission.
|
||||
2. Conserver les journaux/empreintes de l'incident actuel. Un traitement de son
|
||||
`RECOVERY_REQUIRED` nécessite un plan nominatif et une autorisation distincte ;
|
||||
ne pas l'effacer ni utiliser le choix `new` pour le reprendre.
|
||||
3. Au login autorisé, relever versions, checkpoint voulu, session/socket, outputs
|
||||
et helpers ; utiliser uniquement le choix Restaurer explicite, une seule fois.
|
||||
Pour le cas nominal, laisser les interactions se terminer avant la transaction.
|
||||
4. Conserver tentative/journal/reçu, six frontières focus et arbres avant/après.
|
||||
Vérifier identité runtime de chaque fenêtre, slots/ordre/layout/outputs,
|
||||
ancres absentes, helpers repris, focus convenu hors stage et recovery complete.
|
||||
5. En cas d'écart, arrêter l'essai et conserver son premier constat. Ne pas
|
||||
cliquer Réessayer en boucle, refocaliser via script, relancer apply ou supprimer
|
||||
RECOVERY_REQUIRED. Comparer les frontières et compléter par une trace IPC/input
|
||||
explicitement autorisée pour discriminer les causes restantes.
|
||||
6. Un essai séparé avec interaction volontaire doit conserver le focus extérieur
|
||||
et refuser toute mutation après le conflit observable ; il demande lui aussi
|
||||
une autorisation explicite. Un résultat headless ne valide pas le login physique.
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
# Premier login V2 : chemins de feuilles après Guard
|
||||
|
||||
Suite du diagnostic : [focus et récupération du 22 octobre](session-v2-focus-recovery-fix-v1.md).
|
||||
Cette page conserve la portée et les résultats de la correction précédente.
|
||||
|
||||
## Résultat et portée
|
||||
|
||||
Correction minimale dans la seule copie
|
||||
|
|
|
|||
180
docs/session-v2-scratchpad-save-and-restore-v1.md
Normal file
180
docs/session-v2-scratchpad-save-and-restore-v1.md
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
# SESSION_V2_SCRATCHPAD_SAVE_AND_RESTORE_V1
|
||||
|
||||
Travail local du 22 octobre 2026 dans
|
||||
`labfy-session-v2-scratchpad-20261022`, branche
|
||||
`feat/session-v2-scratchpad-20261022`, base `5aeff41d`.
|
||||
Les correctifs focus/recovery préexistants restent présents. Aucun commit,
|
||||
push, déploiement, reload QuickShell ou changement de configuration utilisateur.
|
||||
|
||||
## Résultat et fichiers
|
||||
|
||||
- `session_v2/snapshot.py`, `schema.py`, `schema-v2.json`, `model.py` :
|
||||
capture version 2, scratchpad `none/hidden/shown`, rectangle IPC, focus de slot
|
||||
et workspace focalisé explicite. Une fenêtre cachée a `workspace=null` ;
|
||||
l’espace interne `__i3_scratch` n’est jamais persisté comme workspace cible.
|
||||
Les groupes de fenêtres scratchpad restent refusés. Aucun titre ni argv
|
||||
n’est capturé. `schema-v1.json` reste inchangé.
|
||||
- `layout.py`, `slots.py`, `executor.py`, `cli.py` : phase de placements
|
||||
descriptive, mapping conservant la visibilité des slots interchangeables,
|
||||
staging évité pour les scratchpads existants, commandes con_id ciblées,
|
||||
géométrie et état final vérifiés. Les nouvelles fenêtres arrivent par le
|
||||
prewarm existant, depuis le catalogue statique de confiance.
|
||||
- `recovery.py` : nouveaux journaux version 2 avec appartenance scratchpad,
|
||||
lecture conservatrice des journaux V1, prédiction de focus avant chaque
|
||||
commande, refus d’une feuille dont la commande affecterait un ancêtre
|
||||
scratchpad non admis. Aucun conflit ni pending n’est effacé pour progresser.
|
||||
- `controlcenter/SessionExitGate.qml` : `reason` JSON visible, explications,
|
||||
action de sortie toujours bloquée après échec jusqu’à un choix humain.
|
||||
Le ControlCenter fournit déjà ce JSON ; le diagnostic se perdait dans Gate
|
||||
qui privilégiait le stderr générique.
|
||||
- `tools/build-session-v2.py` : inclusion des deux schémas dans le bundle.
|
||||
- Tests : `test_scratchpad.py`, `scratchpad_runtime.py`,
|
||||
`validate_scratchpad.py`, extension de `qml/tst_exit_v2.qml`.
|
||||
|
||||
Les fonctions existantes `Guard.prepare`, les frontières de vérification finale,
|
||||
la normalisation canonique des chemins et les tests focus/recovery initiaux
|
||||
sont conservés. Le présent delta ne change pas la politique d’autotiling.
|
||||
|
||||
## Preuves et couverture
|
||||
|
||||
Le SwayFX installé échoue au démarrage headless dans ce sandbox faute de DRM.
|
||||
Un paquet Sway 1.12 Arch a été extrait sous `/tmp/labfy-scratchpad-tool`, sans
|
||||
installation système ni substitution de bibliothèque. `ldd` résout son
|
||||
`libwlroots-0.20.so` installé sans dépendance manquante. Son renderer Pixman a
|
||||
permis la campagne privée. Provenance :
|
||||
[paquet Arch](https://archlinux.org/packages/extra/x86_64/sway/).
|
||||
La prédiction du nouvel ajout scratchpad est étayée par le
|
||||
[code Sway 1.12 root.c](https://github.com/swaywm/sway/blob/1.12/sway/tree/root.c)
|
||||
et les observations privées : l’ajout initial refocalise le workspace source,
|
||||
même si la cible était inactive. Le
|
||||
[code scratchpad.c](https://github.com/swaywm/sway/blob/1.12/sway/commands/scratchpad.c)
|
||||
explique également pourquoi `scratchpad show` est un toggle à ne jamais
|
||||
émettre sans preuve de visibilité préalable.
|
||||
|
||||
La suite backend comprend 83 tests, dont 12 nouveaux tests scratchpad :
|
||||
|
||||
- capture/relecture déterministes, Store nommé et checkpoint automatique
|
||||
temporaires, compatibilité des snapshots V1 ;
|
||||
- Kitty caché + autre Kitty visible, montré et focalisé, plusieurs cachés,
|
||||
workspace vide focalisé, splits et flottants ;
|
||||
- références, types et versions stricts, JSON Schema V2, focus caché interdit ;
|
||||
- scratchpad modifié entre lectures, fausse identité PID, output déplacé,
|
||||
focus ambigu, intention interrompue conservée et groupe non admis ;
|
||||
- mapping sans exposition d’un pair caché et prédiction de focus avant IPC.
|
||||
|
||||
Autres suites : 29 tests focus Python, 13 tests QML via PySide6, 6 résultats
|
||||
QtTest (4 tests métier et initialisation/nettoyage). Les tests QML vérifient
|
||||
notamment `CAPABILITY_UNSUPPORTED`, `SNAPSHOT_CHANGED`, `RECOVERY_REQUIRED` et
|
||||
un code inconnu, avec zéro commande de sortie après échec.
|
||||
|
||||
La campagne `scratchpad_runtime.py` utilise des Kitty réels uniquement sous
|
||||
le socket enfant privé. Elle conserve arbres JSON et PNG avant/après :
|
||||
|
||||
1. deux Kitty du même app_id (un caché et un en split), un scratchpad montré,
|
||||
un flottant ordinaire et un autre split ;
|
||||
2. capture, Store temporaire, perturbation puis restauration ; égalité exacte
|
||||
des géométries et états, focus final vérifié ;
|
||||
3. show/hide explicite après restauration, sans perte d’appartenance ;
|
||||
4. reconstitution de l’appartenance depuis des fenêtres ordinaires ;
|
||||
5. toutes les fenêtres cachées et workspace vide focalisé ;
|
||||
6. disparition puis relancement réel des deux Kitty par le catalogue statique
|
||||
TEST_ONLY, restauration cachée et second apply sans double lancement.
|
||||
|
||||
Les PNG avant/après de la campagne privée ont été inspectés. Ils montrent le
|
||||
même arrangement ; ce sont des terminaux synthétiques noirs exécutant `sleep`,
|
||||
pas une preuve de contenu applicatif ou d’une session utilisateur physique.
|
||||
La trace de commandes vérifie qu’aucun move vers un workspace ni `scratchpad
|
||||
show` ne cible la feuille destinée à rester cachée pendant le premier apply.
|
||||
|
||||
Régressions privées supplémentaires retenues sous
|
||||
`/tmp/labfy-scratchpad-regressions-mivwhgzo/` : scénario Limusic V1 et refus
|
||||
absent/dupliqué ; quatre scénarios de stabilité des chemins ; huit scénarios
|
||||
login/focus (normal, workspace vide, fenêtre tierce, deux focus tardifs,
|
||||
map dans le gap IPC, placement provider, crash en milieu d’intention).
|
||||
Tous ont leurs succès ou refus attendus. Seul le nettoyage du harness a été
|
||||
adapté dans un wrapper temporaire pour ne jamais envoyer SIGCONT.
|
||||
|
||||
Aucun scénario envoyant réellement SIGSTOP/SIGCONT à autotiling n’a été
|
||||
exécuté. Son protocole est testé par simulation ; les tests headless réels
|
||||
utilisent Guard sans autotiling. Aucun socket utilisateur ni fichier sous
|
||||
`/run/user/1000/labfy-sway` n’est utilisé par les validations.
|
||||
|
||||
## Limites assumées
|
||||
|
||||
- **Enregistrer Kitty ne ressuscite pas btop.** Le processus interne, la commande,
|
||||
le titre et le contenu du terminal ne font pas partie du snapshot. Le lancement
|
||||
de production reste `/usr/bin/kitty` issu du catalogue. Le `sleep` du test vient
|
||||
exclusivement d’un catalogue statique TEST_ONLY, jamais des données capturées.
|
||||
- Les slots de même application sans provider exact restent interchangeables,
|
||||
sans promesse de retrouver le rôle sémantique d’un terminal particulier.
|
||||
- Les groupes scratchpad, fullscreen et géométries refusées par le client ou
|
||||
le compositeur produisent un échec précis, pas un succès partiel.
|
||||
- Pour une feuille cachée, le rectangle sauvegardé est le rectangle IPC caché.
|
||||
L’ancienne transformation d’output interne à Sway n’est pas persistée. Un
|
||||
futur `show` manuel peut adapter ses coordonnées à l’output alors actif.
|
||||
- Le `show` intermédiaire d’une feuille explicitement montrée laisse Sway
|
||||
convertir le rectangle à l’output ; la géométrie sauvegardée est ensuite
|
||||
appliquée et vérifiée exactement. Le délai de 300 ms des transitions Wayland
|
||||
ne remplace aucune preuve. Une modification tardive reste refusée.
|
||||
- Une reprise après incident ne reconstruit pas automatiquement un scratchpad
|
||||
dont le placement diffère de l’origine prouvée : reçu
|
||||
`SCRATCHPAD_RECOVERY_REQUIRES_REVIEW`, fenêtres et journal conservés.
|
||||
- Pas de validation de login physique, de multi-output physique, de SwayFX avec
|
||||
DRM, ni de contenu Firefox/btop. Les contrôles IPC ne constituent pas un CAS.
|
||||
- Aucun nouveau checkpoint réel n’a été écrit. L’ancien état utilisateur
|
||||
`RECOVERY_REQUIRED` demeure bloquant ; ce patch ne l’acquitte pas.
|
||||
|
||||
## Reproduction sûre et future validation
|
||||
|
||||
Depuis ce worktree, avec un binaire Sway compatible disponible :
|
||||
|
||||
```sh
|
||||
python -B tests/session_v2/validate_scratchpad.py \
|
||||
--sway /tmp/labfy-scratchpad-tool/usr/bin/sway --renderer pixman
|
||||
```
|
||||
|
||||
Le validateur crée ses HOME/XDG sous `/tmp`, retire les variables graphiques
|
||||
héritées et lance exclusivement un compositeur headless avec configuration
|
||||
TEST_ONLY. Il conserve les logs et le chemin des arbres/PNG dans son répertoire
|
||||
de résultats. Aucun accès privilégié n’est requis. Les clients et compositeurs
|
||||
enfants sont arrêtés par le nettoyage du laboratoire ; aucun signal de
|
||||
suspension/reprise n’est envoyé.
|
||||
|
||||
Pour la future recette humaine : examiner d’abord le diff et les preuves
|
||||
privées ; reproduire avec plusieurs fenêtres de terminal identifiables et un
|
||||
scratchpad montré/caché dans un compositeur privé. Toute recette sur le bureau
|
||||
réel exigera une autorisation séparée et la résolution humaine préalable du
|
||||
journal ancien. Ne pas effacer ce journal pour débloquer apply, ne pas tester
|
||||
via logout/reboot et ne pas recharger QuickShell pour cette vérification.
|
||||
Vérifier séparément la sauvegarde d’une fenêtre Kitty et l’absence de promesse
|
||||
sur le redémarrage de btop.
|
||||
|
||||
|
||||
## Résultats finaux conservés
|
||||
|
||||
- Validateur reproductible : `/tmp/labfy-scratchpad-validation-imopv2no/` ;
|
||||
tous les codes retour sont zéro (backend, focus, QML Python/Qt, runtime,
|
||||
diff-check).
|
||||
- Campagne réelle finale : `/tmp/labfy-session-v2-TEST_ONLY-1gtxan7m/` ;
|
||||
snapshots, arbres, commandes, résultats, PNG avant/après/show/hide,
|
||||
nouvelles appartenances, toutes cachées et lancement Kitty à froid.
|
||||
- Bundle local candidat : `/tmp/labfy-scratchpad-final-evidence/bundle/` ;
|
||||
compilation C17 stricte, schémas V1/V2 embarqués, non installé/non signé.
|
||||
- Relecture locale du delta et vérification des patches initiaux : les diffs
|
||||
initiaux hors executor/recovery restent identiques octet pour octet ; dans
|
||||
ces deux fichiers, les mécanismes initiaux sont conservés et les suites de
|
||||
non-régression passent. Cela ne remplace pas la revue humaine avant commit.
|
||||
- `qmllint` du composant Gate, parsing AST Python, contrôle des deux JSON
|
||||
Schemas et `git diff --check` : succès.
|
||||
- Paquet Sway de test, SHA-256 :
|
||||
`5704cfc9eba9804116c665cb4450352bf5a240c1a8300b36aca3218116648443`.
|
||||
|
||||
|
||||
### Revue complémentaire du focus de layer-shell
|
||||
|
||||
Le dialogue de sortie QuickShell peut rendre le focus de GET_TREE non
|
||||
observable. Dans ce cas, le snapshot V2 garde explicitement
|
||||
focus=null et focus_workspace=null, sans inférer de fenêtre depuis les
|
||||
piles internes. Plusieurs noeuds simultanément marqués focused restent
|
||||
refusés avec SNAPSHOT_FOCUS_AMBIGUOUS. Les tests vérifient cette
|
||||
distinction et le round-trip du Store temporaire.
|
||||
|
|
@ -59,7 +59,20 @@ QtObject {
|
|||
return;
|
||||
}
|
||||
pendingAction = completedAction;
|
||||
errorMessage = stderrText.trim() || "Le backend de sauvegarde a échoué.";
|
||||
// CONTRACT: backend reason is diagnostic data, never exit authority.
|
||||
// Keep the action gated until saved or an explicit human bypass.
|
||||
const reason = response && typeof response.reason === "string"
|
||||
&& /^[A-Z][A-Z0-9_]{0,95}$/.test(response.reason) ? response.reason : "";
|
||||
const explanations = {
|
||||
CAPABILITY_UNSUPPORTED: "Un état de fenêtre n’est pas pris en charge par le backend installé (par exemple fullscreen ou scratchpad avec un ancien backend).",
|
||||
SNAPSHOT_CHANGED: "Les fenêtres ont changé pendant la capture. Réessayez une fois leur état stabilisé.",
|
||||
SNAPSHOT_FOCUS_AMBIGUOUS: "Sway ne fournit pas un focus unique pour cette capture.",
|
||||
FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE: "Le fournisseur d’identité Firefox nécessaire à la sauvegarde est indisponible.",
|
||||
RECOVERY_REQUIRED: "Une ancienne restauration exige une inspection humaine. Elle ne sera pas reprise automatiquement.",
|
||||
SCRATCHPAD_CONTAINER_UNSUPPORTED: "Ce scratchpad contient un groupe de fenêtres dont la restauration sûre n’est pas prise en charge."
|
||||
};
|
||||
errorMessage = reason ? reason + " — " + (explanations[reason] || "Le backend a refusé la sauvegarde. Conservez la session ouverte et examinez ce code.")
|
||||
: stderrText.trim() || "Le backend de sauvegarde a échoué sans code de diagnostic.";
|
||||
checkpointFailed();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,12 @@ class Anchors:
|
|||
def identity(self, slot): return "labfy-v2-anchor-" + self.tx + "-" + slot
|
||||
|
||||
def owned(self, node):
|
||||
# WHY : prepare a terminé/reapé le helper avant les vérifications
|
||||
# finales. CONTRACT : une fenêtre étrangère n'exige pas un PID vivant
|
||||
# du helper ; toute candidate possédée exige encore son pidfd exact.
|
||||
if node.get("pid") != self.process.pid or node.get("app_id") not in self.allowed: return False
|
||||
signal.pidfd_send_signal(self.pidfd, 0)
|
||||
return node.get("pid") == self.process.pid and node.get("app_id") in self.allowed
|
||||
return True
|
||||
|
||||
def anchor(self, slot):
|
||||
require(self.active, "TRANSACTION_INACTIVE")
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from .ipc import Sway
|
|||
from .applications import Catalog
|
||||
from .firefox_identity import FirefoxProvider
|
||||
from .snapshot import capture
|
||||
from .layout import compile_plan
|
||||
from .layout import compile_plan, compile_placements
|
||||
from .executor import Executor
|
||||
from .observability import validate as validate_report
|
||||
from .startup import acknowledge, status as startup_status
|
||||
|
|
@ -83,7 +83,10 @@ def main(argv=None):
|
|||
try: result = read(runtime / "session-v2-restore-attempt.json", validate_report)
|
||||
except FileNotFoundError: result = {"status": "idle", "reason": "NO_RESTORE_ATTEMPT"}
|
||||
elif command == "analyze-v1-migration": result = migration(args.name)
|
||||
elif command == "plan": result = {"operations": [asdict(x) for x in compile_plan(store.load(args.name), "0" * 32)]}
|
||||
elif command == "plan":
|
||||
data = store.load(args.name)
|
||||
result = {"operations": [asdict(x) for x in compile_plan(data, "0" * 32)],
|
||||
"placements": compile_placements(data)}
|
||||
else:
|
||||
sway = Sway(args.socket)
|
||||
catalog = Catalog()
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ from .guard import Guard
|
|||
from .observability import Attempt
|
||||
from .startup import record_deferred
|
||||
from .slots import assign
|
||||
from .recovery import Recovery, RecordedSway, resume_pending, process_start, focus_proof, placement, observe, recovery_status
|
||||
from .layout import compile_placements
|
||||
from .recovery import Recovery, RecordedSway, resume_pending, process_start, focus_proof, placement, observe, recovery_status, scratchpad_state
|
||||
|
||||
|
||||
def process_owner(pid):
|
||||
|
|
@ -217,7 +218,9 @@ class Executor:
|
|||
recovery.admit(n)
|
||||
self.sway.command(f'[con_id={n["id"]}] move container to workspace ' + json.dumps(stage))
|
||||
for n in targets.values():
|
||||
stage_node(n)
|
||||
# Hidden scratchpads stay detached throughout staging; neither
|
||||
# workspace moves nor floating-disable may expose them.
|
||||
if prepared[n["id"]]["scratchpad"] == "none": stage_node(n)
|
||||
def progress(phase, count):
|
||||
values = {"phase": phase}
|
||||
if phase == "anchor": values["anchors_created"] = count
|
||||
|
|
@ -264,7 +267,7 @@ class Executor:
|
|||
identity = self.provider.resolve(expected, groups[app["application_id"]]) if expected else self.provider.capture(groups[app["application_id"]])
|
||||
attempt.data["providers"] = [PROVIDER]
|
||||
attempt.data["identity_resolutions"] = [{"uuid": k, "con_id": v} for k, v in identity.items()]
|
||||
mapping.update(assign(members, groups[app["application_id"]], identity))
|
||||
mapping.update(assign(members, groups[app["application_id"]], identity, pinned))
|
||||
# CONTRACT : le provider ne peut introduire une admission hors du
|
||||
# lot phase0, même si un remplacement conserve la cardinalité.
|
||||
check_batch()
|
||||
|
|
@ -283,6 +286,7 @@ class Executor:
|
|||
for n in captured.values():
|
||||
recovery.admit(n, pinned[n["id"]])
|
||||
for n in captured.values():
|
||||
if recovery.snapshot(n["id"])["scratchpad"] != "none": continue
|
||||
if location(self.sway.tree(), n["id"])[0] != stage: stage_node(n)
|
||||
if n.get("type") == "floating_con" or n.get("floating") in ("user_on", "auto_on"):
|
||||
self.sway.command(f'[con_id={n["id"]}] floating disable')
|
||||
|
|
@ -290,6 +294,7 @@ class Executor:
|
|||
for slot in data["window_slots"]:
|
||||
if slot["state"]["floating"]: continue
|
||||
target = captured[mapping[slot["slot_id"]]]
|
||||
require(recovery.snapshot(target["id"])["scratchpad"] == "none", "SCRATCHPAD_ASSIGNMENT_UNPROVEN")
|
||||
anchors.swap(slot["slot_id"], target, slot["workspace"], stage, specs[slot["application_id"]].app_ids)
|
||||
attempt.update(phase="swap", anchors_swapped=attempt.data["anchors_swapped"] + 1)
|
||||
self.progress("swap", attempt.data["anchors_swapped"])
|
||||
|
|
@ -303,6 +308,13 @@ class Executor:
|
|||
self._verify(data, mapping, specs, anchors)
|
||||
attempt.data["timings_ms"]["verification"] = (time.monotonic() - start) * 1000
|
||||
attempt.update(final_tree_verified=True, slots_filled=len(mapping), phase="focus")
|
||||
# WHY : quitter le helper après SIGCONT ajoute une transition
|
||||
# Wayland pendant la validation finale. CONTRACT : finir sa vie
|
||||
# possédée sous suspension, contrôler le focus avant de le choisir.
|
||||
# INVARIANT : aucune divergence ne donne droit à refocaliser.
|
||||
recovery.boundary("before-guard-prepare")
|
||||
guard.prepare()
|
||||
recovery.boundary("after-guard-prepare")
|
||||
# WHY : restaurer le checkpoint ne possède pas le focus d'un client
|
||||
# tiers déjà actif. CONTRACT : son identité/emplacement de baseline
|
||||
# reste inchangé ; le focus du snapshot ne remplace que celui du lot.
|
||||
|
|
@ -312,7 +324,7 @@ class Executor:
|
|||
if foreign_focus is not None:
|
||||
require(placement(recovery.snapshot(origin_focus)) == placement(foreign_focus), "FOREIGN_WINDOW_CHANGED")
|
||||
target_focus = origin_focus
|
||||
if target_focus is None and origin_focus is not None:
|
||||
if target_focus is None and origin_focus is not None and data.get("focus_workspace") is None:
|
||||
# focus=null préserve la feuille réellement prouvée après mapping.
|
||||
# L'identité complète reste celle de Recovery, jamais celle d'un
|
||||
# app_id ou de la pile de focus d'un split arbitraire.
|
||||
|
|
@ -322,20 +334,25 @@ class Executor:
|
|||
"RECOVERY_FOCUS_IDENTITY_CHANGED")
|
||||
target_focus = origin_focus
|
||||
if target_focus is not None:
|
||||
require(recovery.snapshot(target_focus)["scratchpad"] != "hidden", "RESTORE_FOCUS_UNAVAILABLE")
|
||||
self.sway.command(f'[con_id={target_focus}] focus')
|
||||
else:
|
||||
origin_workspace = recovery.data["focus_origin"]["workspace"]
|
||||
require(origin_focus is None and origin_workspace and origin_workspace != stage, "RESTORE_FOCUS_UNAVAILABLE")
|
||||
origin_workspace = data.get("focus_workspace") or recovery.data["focus_origin"]["workspace"]
|
||||
require((origin_focus is None or data.get("focus_workspace")) and origin_workspace and origin_workspace != stage, "RESTORE_FOCUS_UNAVAILABLE")
|
||||
self.sway.command("workspace " + json.dumps(origin_workspace))
|
||||
final_focus = recovery.data["focus_expected"]
|
||||
require(final_focus["workspace"] != stage, "FINAL_FOCUS_IN_STAGE")
|
||||
recovery.boundary("before-helper-resume")
|
||||
answer = guard.resume()
|
||||
attempt.update(helpers_resumed=answer["helpers_resumed"], phase="final-verification")
|
||||
recovery.boundary("after-helper-resume")
|
||||
self.progress("after-resume", len(mapping))
|
||||
self._verify(data, mapping, specs, anchors)
|
||||
recovery.check_command_focus(self.sway.tree(), final_focus, "final-verification")
|
||||
recovery.boundary("before-guard-finish")
|
||||
guard.finish()
|
||||
guard = None
|
||||
recovery.boundary("after-guard-finish")
|
||||
self._verify(data, mapping, specs, anchors)
|
||||
recovery.complete(lambda tree: self._verify_structure(data, mapping, specs, anchors, tree))
|
||||
attempt.update(final_focus_verified=True, status="success", reason="SUCCESS", phase="complete")
|
||||
|
|
@ -370,11 +387,30 @@ class Executor:
|
|||
pass
|
||||
|
||||
def _floating(self, data, mapping, captured):
|
||||
for s in data["window_slots"]:
|
||||
if not s["state"]["floating"]: continue
|
||||
slots = {s["slot_id"]: s for s in data["window_slots"]}
|
||||
for placement_op in compile_placements(data):
|
||||
s = slots[placement_op["slot_id"]]
|
||||
target = mapping[s["slot_id"]]
|
||||
n = next((n for n in self.sway.windows() if n["id"] == target), None)
|
||||
require(n and n.get("pid") == captured[target].get("pid"), "TARGET_IDENTITY_CHANGED")
|
||||
scratch = s["state"]["scratchpad"]
|
||||
current = self.sway.recovery.snapshot(target)
|
||||
if scratch in ("hidden", "shown"):
|
||||
prefix = f'[con_id={target}] '
|
||||
if not current["floating"]: self.sway.command(prefix + "floating enable")
|
||||
if current["scratchpad"] != "hidden": self.sway.command(prefix + "move scratchpad")
|
||||
if scratch == "shown":
|
||||
self.sway.command("workspace " + json.dumps(s["workspace"]))
|
||||
output = next(w["output"] for w in data["workspaces"] if w["workspace"] == s["workspace"])
|
||||
self.sway.command("move workspace to output " + json.dumps(output))
|
||||
self.sway.command(prefix + "scratchpad show")
|
||||
# Hidden geometry can be set without showing the leaf. IPC rect
|
||||
# is the contract, not the transform of a future user show.
|
||||
g = s["state"]["geometry"]
|
||||
self.sway.command(prefix + f'resize set width {g["width"]} px height {g["height"]} px')
|
||||
self.sway.command(prefix + f'move absolute position {g["x"]} px {g["y"]} px')
|
||||
continue
|
||||
require(current["scratchpad"] == "none", "SCRATCHPAD_ASSIGNMENT_UNPROVEN")
|
||||
self.sway.command(f'[con_id={target}] move container to workspace ' + json.dumps(s["workspace"]))
|
||||
# Sway rattache une fenêtre déplacée en coordonnées à l'espace
|
||||
# visible de l'output : rendre la cible visible avant sa géométrie.
|
||||
|
|
@ -386,6 +422,14 @@ class Executor:
|
|||
self.sway.command(f'[con_id={target}] resize set width {g["width"]} px height {g["height"]} px')
|
||||
self.sway.command(f'[con_id={target}] move absolute position {g["x"]} px {g["y"]} px')
|
||||
|
||||
# V2 can capture an empty focused workspace alongside hidden leaves.
|
||||
# WHY : after launch/staging Sway may destroy it. CONTRACT : recreate
|
||||
# only that explicitly persisted workspace/output, never __i3_scratch.
|
||||
for w in data["workspaces"]:
|
||||
if w["root"] is None and not any(s["workspace"] == w["workspace"] for s in data["window_slots"]):
|
||||
self.sway.command("workspace " + json.dumps(w["workspace"]))
|
||||
self.sway.command("move workspace to output " + json.dumps(w["output"]))
|
||||
|
||||
def _verify(self, data, mapping, specs, anchors):
|
||||
# WHY : capture est une frontière externe potentiellement lente.
|
||||
# CONTRACT : UUID exact conservé, puis nouvelle observation complète ;
|
||||
|
|
@ -424,7 +468,9 @@ class Executor:
|
|||
for s in data["window_slots"]:
|
||||
n = next((n for n in nodes if n["id"] == mapping[s["slot_id"]]), None)
|
||||
require(n and n.get("app_id") in specs[s["application_id"]].app_ids, "TARGET_IDENTITY_CHANGED")
|
||||
scratch = s["state"]["scratchpad"]
|
||||
require(scratchpad_state(n, location(tree, n["id"])[0]) == ("none" if scratch is False else scratch), "FINAL_SCRATCHPAD_MISMATCH")
|
||||
if s["state"]["floating"]:
|
||||
require(location(tree, n["id"])[0] == s["workspace"] and n.get("floating") in ("auto_on", "user_on") and
|
||||
require(location(tree, n["id"])[0] == ("__i3_scratch" if scratch == "hidden" else s["workspace"]) and n.get("floating") in ("auto_on", "user_on") and
|
||||
all(n["rect"][k] == v for k, v in s["state"]["geometry"].items()), "FLOATING_GEOMETRY_MISMATCH")
|
||||
return tree
|
||||
|
|
|
|||
|
|
@ -97,6 +97,18 @@ class Guard:
|
|||
self.process.wait(timeout=15)
|
||||
raise
|
||||
|
||||
def prepare(self):
|
||||
"""Le helper d'ancres doit être sorti avant focus final et SIGCONT.
|
||||
|
||||
CONTRACT : ACK après disparition prouvée par pidfd, pendant notre
|
||||
suspension ; finish ne provoque plus cette sortie après la reprise.
|
||||
"""
|
||||
self.parent.sendall(b"prepare")
|
||||
self.parent.settimeout(15)
|
||||
answer = loads(self.parent.recv(4096), 4096)
|
||||
require(answer.get("status") == "prepared", "GUARD_RECOVERY_FAILED")
|
||||
return answer
|
||||
|
||||
def resume(self):
|
||||
self.parent.sendall(b"resume")
|
||||
self.parent.settimeout(15)
|
||||
|
|
@ -140,6 +152,7 @@ def run(config):
|
|||
stopped = []
|
||||
counts = {"helpers_suspended": 0, "helpers_resumed": 0}
|
||||
normal = False
|
||||
prepared = False
|
||||
sway = Sway(config["socket"])
|
||||
def resume():
|
||||
return resume_helpers(stopped, config["socket"], counts)
|
||||
|
|
@ -160,7 +173,19 @@ def run(config):
|
|||
if config["engine_fd"] in readable: break
|
||||
if control in readable:
|
||||
message = control.recv(16)
|
||||
if message == b"prepare":
|
||||
require(not prepared and not any(n.get("pid") == config["anchor_pid"] and
|
||||
n.get("app_id") in config["allowed"] for n in sway.windows()), "ANCHOR_CLEANUP_FAILED")
|
||||
os.write(config["writer_fd"], b"quit\n")
|
||||
# WHY : la réponse à kill et l'écriture quit ne prouvent pas
|
||||
# la fin Wayland du processus. CONTRACT : pidfd hérité exact,
|
||||
# attente bornée, aucun SIGCONT avant cette barrière.
|
||||
until(lambda: bool(select.select([config["anchor_fd"]], [], [], 0)[0]), 3)
|
||||
prepared = True
|
||||
control.sendall(b'{"status":"prepared"}')
|
||||
continue
|
||||
if message == b"resume":
|
||||
require(prepared, "GUARD_PROTOCOL_INVALID")
|
||||
require(resume(), "GUARD_RECOVERY_FAILED")
|
||||
control.sendall(json.dumps({"status": "resumed", **counts}).encode())
|
||||
continue
|
||||
|
|
@ -171,10 +196,13 @@ def run(config):
|
|||
until(lambda: bool(select.select([config["engine_fd"]], [], [], 0)[0]), 3)
|
||||
break
|
||||
def owned(n):
|
||||
if n.get("pid") != config["anchor_pid"] or n.get("app_id") not in config["allowed"]: return False
|
||||
signal.pidfd_send_signal(config["anchor_fd"], 0)
|
||||
return n.get("pid") == config["anchor_pid"] and n.get("app_id") in config["allowed"]
|
||||
return True
|
||||
cleaned = 0
|
||||
for old in sway.windows():
|
||||
# CONTRACT : après prepare, aucune fermeture ni écriture quit tardive.
|
||||
# Le pidfd a déjà prouvé la sortie ; ne pas dépendre d'un zombie non reapé.
|
||||
for old in ([] if prepared else sway.windows()):
|
||||
if not owned(old): continue
|
||||
fresh = next((n for n in sway.windows() if n["id"] == old["id"]), None)
|
||||
if fresh and owned(fresh):
|
||||
|
|
@ -186,8 +214,9 @@ def run(config):
|
|||
else:
|
||||
sway.command(f'[con_id={fresh["id"]}] kill')
|
||||
cleaned += 1
|
||||
until(lambda: not any(owned(n) for n in sway.windows()))
|
||||
os.write(config["writer_fd"], b"quit\n")
|
||||
if not prepared:
|
||||
until(lambda: not any(owned(n) for n in sway.windows()))
|
||||
os.write(config["writer_fd"], b"quit\n")
|
||||
# WHY : autotiling repris peut restructurer le stage avant la reprise
|
||||
# et rendre son appartenance ambiguë. Finir nos obligations de fenêtres
|
||||
# pendant la suspension possédée, puis toujours reprendre les helpers.
|
||||
|
|
|
|||
|
|
@ -44,3 +44,18 @@ def compile_plan(data, tx):
|
|||
emit("create", leaves(t.root)[0])
|
||||
build(t.root, root=True)
|
||||
return tuple(plan)
|
||||
|
||||
|
||||
def compile_placements(data):
|
||||
"""Deterministic placement phase after tiling anchors, including scratchpad.
|
||||
|
||||
CONTRACT : this is descriptive data, not executable launch authority. Hidden
|
||||
leaves have no workspace destination. V1 false means only ordinary floating;
|
||||
the historical document itself is never rewritten or silently downgraded.
|
||||
"""
|
||||
from .schema import validate
|
||||
validate(data)
|
||||
return tuple({"slot_id": s["slot_id"], "workspace": s["workspace"],
|
||||
"scratchpad": s["state"]["scratchpad"] or "none",
|
||||
"geometry": dict(s["state"]["geometry"])}
|
||||
for s in data["window_slots"] if s["state"]["floating"])
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class Application:
|
|||
class WindowSlot:
|
||||
slot_id: str
|
||||
application_id: str
|
||||
workspace: str
|
||||
workspace: str | None
|
||||
tree_path: tuple
|
||||
state: dict
|
||||
identity_requirement: str
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ INVARIANT : aucun kill client, aucune propriété déduite du nom du workspace.
|
|||
import copy
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from .errors import require
|
||||
from .ipc import walk, location, workspace, until
|
||||
from .ipc import walk, location, workspace, until, canonical
|
||||
from .storage import atomic, read
|
||||
|
||||
JOURNAL = "session-v2-recovery.json"
|
||||
|
|
@ -22,6 +23,13 @@ def process_start(pid):
|
|||
|
||||
|
||||
def observe(tree, node, start):
|
||||
# Commands addressed to a leaf can act on its floating/scratchpad ancestor
|
||||
# in Sway. Such a group is outside the single-surface ownership contract.
|
||||
parents = {c["id"]: n for n in walk(tree) for c in n.get("nodes", []) + n.get("floating_nodes", [])}
|
||||
ancestor = parents.get(node["id"])
|
||||
while ancestor and ancestor.get("type") not in ("workspace", "output", "root"):
|
||||
require(ancestor.get("scratchpad_state", "none") == "none", "SCRATCHPAD_CONTAINER_UNSUPPORTED")
|
||||
ancestor = parents.get(ancestor["id"])
|
||||
where = location(tree, node["id"])
|
||||
require(where and where[0], "RECOVERY_LOCATION_MISSING")
|
||||
output = next((o["name"] for o in tree.get("nodes", [])
|
||||
|
|
@ -30,18 +38,51 @@ def observe(tree, node, start):
|
|||
floating = node.get("type") == "floating_con" or node.get("floating") in ("user_on", "auto_on")
|
||||
return {"con_id": node["id"], "app_id": node.get("app_id"), "pid": node.get("pid"),
|
||||
"starttime": start(node["pid"]), "workspace": where[0], "output": output,
|
||||
"path": list(where[1]), "floating": floating, "geometry": dict(node["rect"]) if floating else None}
|
||||
"path": list(where[1]), "scratchpad": scratchpad_state(node, where[0]), "floating": floating, "geometry": dict(node["rect"]) if floating else None}
|
||||
|
||||
|
||||
def scratchpad_state(node, workspace_name):
|
||||
"""CONTRACT : appartenance IPC et visibilité sont deux preuves distinctes."""
|
||||
raw = node.get("scratchpad_state", "none")
|
||||
require(raw in ("none", "fresh", "changed"), "SCRATCHPAD_STATE_UNSUPPORTED")
|
||||
return "none" if raw == "none" else ("hidden" if workspace_name == "__i3_scratch" else "shown")
|
||||
|
||||
|
||||
def placement(state):
|
||||
return {k: v for k, v in state.items() if k != "path"} if state is not None else None
|
||||
|
||||
|
||||
def structure(tree):
|
||||
"""Preuve de topologie, avec feuilles con_id et branches ordonnées.
|
||||
|
||||
CONTRACT : seule la contraction des splits unaires h/v/none déjà utilisée
|
||||
par Anchors est équivalente. Tabbed/stacked, branches, ordre et appartenance
|
||||
restent significatifs. Les flottants sont comparés par placement exact.
|
||||
WHY : un chemin brut ne permet pas de distinguer compaction et réordonnancement.
|
||||
"""
|
||||
identities = {n["id"]: n["id"] for n in walk(tree) if n.get("app_id") or n.get("window")}
|
||||
return {n["name"]: canonical(tree, n["name"], identities) for n in walk(tree)
|
||||
if n.get("type") == "workspace" and n.get("nodes")}
|
||||
|
||||
|
||||
def command_postcondition(state, target, operation, tree):
|
||||
expected = copy.deepcopy(state)
|
||||
fields = ["con_id", "app_id", "pid", "starttime", "workspace", "output", "floating", "geometry"]
|
||||
if "scratchpad" in expected: fields.append("scratchpad")
|
||||
if expected["con_id"] == target:
|
||||
if operation.startswith("move container to workspace "):
|
||||
if operation == "move scratchpad":
|
||||
require(state["floating"], "SCRATCHPAD_FLOATING_REQUIRED")
|
||||
expected.update(workspace="__i3_scratch", output="__i3", scratchpad="hidden")
|
||||
elif operation == "scratchpad show":
|
||||
require(state.get("scratchpad") == "hidden", "SCRATCHPAD_SHOW_UNPROVEN")
|
||||
name = focused(tree)["workspace"]
|
||||
require(name and name != "__i3_scratch", "RESTORE_FOCUS_UNAVAILABLE")
|
||||
expected.update(workspace=name, output=next(o["name"] for o in tree["nodes"]
|
||||
if any(w.get("type") == "workspace" and w.get("name") == name for w in walk(o))), scratchpad="shown")
|
||||
# Output-coordinate conversion is compositor-owned. The exact saved
|
||||
# geometry is set and checked afterwards; identity/focus stay strict.
|
||||
fields.remove("geometry")
|
||||
elif operation.startswith("move container to workspace "):
|
||||
expected["workspace"] = json.loads(operation.removeprefix("move container to workspace "))
|
||||
destination = workspace(tree, expected["workspace"])
|
||||
if destination:
|
||||
|
|
@ -57,7 +98,9 @@ def command_postcondition(state, target, operation, tree):
|
|||
expected["floating"] = operation == "floating enable"
|
||||
if expected["floating"]:
|
||||
if not state["floating"]: fields.remove("geometry")
|
||||
else: expected["geometry"] = None
|
||||
else:
|
||||
expected["geometry"] = None
|
||||
if "scratchpad" in expected: expected["scratchpad"] = "none"
|
||||
elif operation.startswith("resize set "):
|
||||
values = [int(v) for v in re.findall(r"-?\d+", operation)]
|
||||
g = expected["geometry"]
|
||||
|
|
@ -162,7 +205,26 @@ def command_focus(tree, target, operation):
|
|||
ws = workspace(tree, before["workspace"])
|
||||
if leaves == [target] and ws and all(n["id"] == target for n in walk(ws) if n.get("app_id") or n.get("window")):
|
||||
return {"con_id": None, "workspace": before["workspace"]}
|
||||
if operation == "focus": return at(target)
|
||||
if operation == "scratchpad show":
|
||||
require(target in nodes and scratchpad_state(nodes[target], location(tree, target)[0]) == "hidden", "SCRATCHPAD_SHOW_UNPROVEN")
|
||||
return {"con_id": target, "workspace": before["workspace"]}
|
||||
if operation == "move scratchpad":
|
||||
require(target in nodes and (nodes[target].get("app_id") or nodes[target].get("window")), "SCRATCHPAD_CONTAINER_UNSUPPORTED")
|
||||
source = location(tree, target)[0]
|
||||
if source == "__i3_scratch": return before
|
||||
if nodes[target].get("scratchpad_state", "none") == "none":
|
||||
# root_scratchpad_add_container always selects the inactive node of
|
||||
# the source workspace, even when an unrelated workspace had focus.
|
||||
# With floating leaf ownership there is no tiled parent fallback.
|
||||
require(nodes[target].get("floating") in ("auto_on", "user_on"), "SCRATCHPAD_FLOATING_REQUIRED")
|
||||
ws = workspace(tree, source)
|
||||
return inactive(ws, target) or {"con_id": None, "workspace": source}
|
||||
# Floating leaves only: the removal uses the same inactive focus stack
|
||||
# as move-to-workspace. No split ancestor is implicitly admitted.
|
||||
return command_focus(tree, target, 'move container to workspace "__i3_scratch"')
|
||||
if operation == "focus":
|
||||
require(target in nodes and location(tree, target)[0] != "__i3_scratch", "FOCUS_HIDDEN_SCRATCHPAD")
|
||||
return at(target)
|
||||
if operation.startswith("workspace "):
|
||||
name = json.loads(operation.removeprefix("workspace "))
|
||||
ws = workspace(tree, name)
|
||||
|
|
@ -185,7 +247,7 @@ def command_focus(tree, target, operation):
|
|||
|
||||
|
||||
def validate(value):
|
||||
require(type(value) is dict and value.get("schema") == "labfy.session-v2-recovery" and value.get("version") == 1,
|
||||
require(type(value) is dict and value.get("schema") == "labfy.session-v2-recovery" and type(value.get("version")) is int and value.get("version") in (1, 2),
|
||||
"RECOVERY_SCHEMA")
|
||||
require(value.get("status") in ("active", "complete", "recovered", "required"), "RECOVERY_SCHEMA")
|
||||
require(type(value.get("windows")) is list and len(value["windows"]) <= 256, "RECOVERY_SCHEMA")
|
||||
|
|
@ -196,12 +258,16 @@ def validate(value):
|
|||
require(value.get("stage") == "labfy-v2-stage-" + value["transaction_id"], "RECOVERY_SCHEMA")
|
||||
def text(v): return type(v) is str and 0 < len(v) <= 256 and not any(ord(c) < 32 for c in v)
|
||||
def state(v):
|
||||
require(type(v) is dict and set(v) == {"con_id", "app_id", "pid", "starttime", "workspace", "output", "floating", "geometry", "path"}, "RECOVERY_SCHEMA")
|
||||
require(type(v) is dict and set(v) == ({"con_id", "app_id", "pid", "starttime", "workspace", "output", "floating", "geometry", "path"} | ({"scratchpad"} if value["version"] == 2 else set())), "RECOVERY_SCHEMA")
|
||||
for k in ("con_id", "pid", "starttime"):
|
||||
require(type(v[k]) is int and 0 < v[k] < 2**63, "RECOVERY_SCHEMA")
|
||||
require(v["app_id"] is None or text(v["app_id"]), "RECOVERY_SCHEMA")
|
||||
require(text(v["workspace"]) and text(v["output"]) and type(v["floating"]) is bool, "RECOVERY_SCHEMA")
|
||||
require(type(v["path"]) is list and len(v["path"]) <= 64 and all(type(i) is int and 0 <= i <= 256 for i in v["path"]), "RECOVERY_SCHEMA")
|
||||
if value["version"] == 2:
|
||||
require(v["scratchpad"] in ("none", "hidden", "shown") and
|
||||
(v["scratchpad"] == "none" or v["floating"]) and
|
||||
((v["workspace"] == "__i3_scratch") == (v["scratchpad"] == "hidden")), "RECOVERY_SCHEMA")
|
||||
g = v["geometry"]
|
||||
require((not v["floating"] and g is None) or (type(g) is dict and set(g) == {"x", "y", "width", "height"}
|
||||
and all(type(x) is int and abs(x) < 2**31 for x in g.values())), "RECOVERY_SCHEMA")
|
||||
|
|
@ -219,7 +285,48 @@ def validate(value):
|
|||
require(type(f) is dict and set(f) == {"con_id", "workspace"}, "RECOVERY_SCHEMA")
|
||||
require(f["con_id"] is None or (type(f["con_id"]) is int and 0 < f["con_id"] < 2**63), "RECOVERY_SCHEMA")
|
||||
require(f["workspace"] is None or text(f["workspace"]), "RECOVERY_SCHEMA")
|
||||
if "focus_boundaries" in value:
|
||||
rows = value["focus_boundaries"]
|
||||
require(type(rows) is list and len(rows) <= 8, "RECOVERY_SCHEMA")
|
||||
for row in rows:
|
||||
require(type(row) is dict and set(row) == {"boundary", "expected", "observed"} and
|
||||
text(row["boundary"]), "RECOVERY_SCHEMA")
|
||||
for key in ("expected", "observed"):
|
||||
f = row[key]
|
||||
require(type(f) is dict and set(f) == {"con_id", "workspace"}, "RECOVERY_SCHEMA")
|
||||
require(f["con_id"] is None or (type(f["con_id"]) is int and 0 < f["con_id"] < 2**63), "RECOVERY_SCHEMA")
|
||||
require(f["workspace"] is None or text(f["workspace"]), "RECOVERY_SCHEMA")
|
||||
for key in ("focus_pending", "focus_conflict"): require(type(value.get(key)) is bool, "RECOVERY_SCHEMA")
|
||||
if "path_reconciliations" in value:
|
||||
rows = value["path_reconciliations"]
|
||||
require(type(rows) is list and len(rows) <= 4, "RECOVERY_SCHEMA")
|
||||
for batch in rows:
|
||||
require(type(batch) is list and len(batch) <= 256, "RECOVERY_SCHEMA")
|
||||
for row in batch:
|
||||
require(type(row) is dict and set(row) == {"con_id", "before", "after"} and
|
||||
type(row["con_id"]) is int and 0 < row["con_id"] < 2**63, "RECOVERY_SCHEMA")
|
||||
for key in ("before", "after"):
|
||||
path = row[key]
|
||||
require(type(path) is list and len(path) <= 64 and
|
||||
all(type(i) is int and 0 <= i <= 256 for i in path), "RECOVERY_SCHEMA")
|
||||
# CONTRACT : extension facultative V1 ; l'absence dans un ancien journal
|
||||
# n'autorise jamais à reconstruire une preuve depuis l'arbre post-incident.
|
||||
if "structure_expected" in value:
|
||||
shapes = value["structure_expected"]
|
||||
require(type(shapes) is dict and len(shapes) <= 256 and all(text(k) for k in shapes), "RECOVERY_SCHEMA")
|
||||
budget = [4096]
|
||||
def shape(node, depth=0):
|
||||
budget[0] -= 1
|
||||
require(budget[0] >= 0 and depth <= 64, "RECOVERY_SCHEMA")
|
||||
if type(node) is int:
|
||||
require(0 < node < 2**63, "RECOVERY_SCHEMA")
|
||||
return
|
||||
require(type(node) is dict and len(node) == 1, "RECOVERY_SCHEMA")
|
||||
layout, children = next(iter(node.items()))
|
||||
require(layout in ("splith", "splitv", "none", "tabbed", "stacked") and
|
||||
type(children) is list and len(children) <= 256, "RECOVERY_SCHEMA")
|
||||
for child in children: shape(child, depth + 1)
|
||||
for node in shapes.values(): shape(node)
|
||||
return value
|
||||
|
||||
|
||||
|
|
@ -236,7 +343,7 @@ class Recovery:
|
|||
tree = sway.tree()
|
||||
if proof is not None:
|
||||
require(sway.session == proof[0] and focused(tree) == proof[1][0], "RESTORE_FOCUS_UNSTABLE")
|
||||
self.data = {"schema": "labfy.session-v2-recovery", "version": 1,
|
||||
self.data = {"schema": "labfy.session-v2-recovery", "version": 2,
|
||||
"session": sway.session, "transaction_id": tx, "stage": stage,
|
||||
"status": "active", "windows": [], "foreign": [], "operations": 0,
|
||||
"focus_origin": focused(tree), "focus_expected": focused(tree),
|
||||
|
|
@ -257,7 +364,12 @@ class Recovery:
|
|||
def snapshot(self, identity, tree=None):
|
||||
tree = self.sway.tree() if tree is None else tree
|
||||
node = next((n for n in walk(tree) if n["id"] == identity and (n.get("app_id") or n.get("window"))), None)
|
||||
return observe(tree, node, self.start) if node else None
|
||||
state = observe(tree, node, self.start) if node else None
|
||||
if state and self.data["version"] == 1:
|
||||
# Historical journals never proved scratchpad membership. Refuse an
|
||||
# observed scratchpad rather than synthesizing absent evidence.
|
||||
require(state.pop("scratchpad") == "none", "LEGACY_SCRATCHPAD_UNPROVEN")
|
||||
return state
|
||||
|
||||
def pin(self, node):
|
||||
"""Observation runtime avant une attente externe, sans autorité de focus.
|
||||
|
|
@ -347,6 +459,14 @@ class Recovery:
|
|||
self.data["operations"] += 1
|
||||
self.publish()
|
||||
answer = self.sway.command(command)
|
||||
if operation in ("floating enable", "scratchpad show"):
|
||||
# WHY : IPC reports pending rect before Wayland configure/commit;
|
||||
# a late initial configure can otherwise undo the next resize.
|
||||
# CONTRACT : bounded drain only where geometry is explicitly
|
||||
# compositor-owned; all predicted fields and focus are still checked
|
||||
# below, before publishing the confirmed observation. No retries of
|
||||
# a divergent command and no relaxation of final geometry equality.
|
||||
time.sleep(.3)
|
||||
if operation == "kill" and target is not None:
|
||||
# Une réponse à kill n'est pas le retrait Wayland. Attendre l'anchor
|
||||
# possédée évite de journaliser un chemin encore transitoire.
|
||||
|
|
@ -365,10 +485,28 @@ class Recovery:
|
|||
require(all(after[k] == v for k, v in expected_after[str(after["con_id"])].items()), "RECOVERY_POSTCONDITION_FAILED")
|
||||
entry.update(expected=after, pending=False)
|
||||
self.check_command_focus(tree, expected_focus, command)
|
||||
self.data.update(focus_expected=focused(tree), focus_pending=False)
|
||||
# CONTRACT : cette preuve est post-réponse RUN_COMMAND ; elle décrit
|
||||
# l'observation, pas une exclusion de l'input concurrent.
|
||||
self.data["intention"]["focus_after"] = focused(tree)
|
||||
self.data.update(focus_expected=focused(tree), focus_pending=False, structure_expected=structure(tree))
|
||||
self.publish()
|
||||
return answer
|
||||
|
||||
def boundary(self, name):
|
||||
"""Observation durable bornée aux transitions du watchdog.
|
||||
|
||||
WHY : SIGCONT acquitté ne prouve pas le drainage des événements d'un
|
||||
client IPC. CONTRACT : comparer avant toute nouvelle mutation ; aucune
|
||||
attribution helper/utilisateur ni nouvelle autorité de focus.
|
||||
"""
|
||||
tree = self.sway.tree()
|
||||
rows = self.data.setdefault("focus_boundaries", [])
|
||||
require(len(rows) < 8, "RECOVERY_OPERATION_LIMIT")
|
||||
rows.append({"boundary": name, "expected": copy.deepcopy(self.data["focus_expected"]),
|
||||
"observed": focused(tree)})
|
||||
self.publish()
|
||||
self.check_command_focus(tree, self.data["focus_expected"], name)
|
||||
|
||||
def verify(self):
|
||||
tree = self.sway.tree()
|
||||
ws = workspace(tree, self.data["stage"])
|
||||
|
|
@ -442,6 +580,8 @@ class Recovery:
|
|||
expected = command_postcondition(observed, target, operation, tree) if observed else None
|
||||
expected_focus = command_focus(tree, target, operation)
|
||||
self.check_focus_preservation(tree, expected_focus, text)
|
||||
require("structure_expected" not in self.data or structure(tree) == self.data["structure_expected"],
|
||||
"RECOVERY_STRUCTURE_CHANGED")
|
||||
require(self.data["operations"] < 4096 and len(text) <= 2048, "RECOVERY_OPERATION_LIMIT")
|
||||
entry = next((e for e in self.data["windows"] if observed and e["origin"]["con_id"] == observed["con_id"]), None)
|
||||
if entry is not None:
|
||||
|
|
@ -467,7 +607,7 @@ class Recovery:
|
|||
self.data["intention"]["observed_after"] = after
|
||||
self.check_command_focus(tree, expected_focus, text)
|
||||
self.data["intention"]["focus_after"] = focused(tree)
|
||||
self.data.update(focus_expected=focused(tree), focus_pending=False)
|
||||
self.data.update(focus_expected=focused(tree), focus_pending=False, structure_expected=structure(tree))
|
||||
self.publish()
|
||||
|
||||
def check_focus_preservation(self, tree, expected, command):
|
||||
|
|
@ -545,6 +685,35 @@ class Recovery:
|
|||
"reason": "RECOVERY_OBSERVATION_UNAVAILABLE"} for e in durable["windows"] if not e["recovered"]]
|
||||
return self.receipt("RECOVERY_REQUIRED", windows, ["RECOVERY_OBSERVATION_UNAVAILABLE"])
|
||||
|
||||
def equivalent_paths(self, tree):
|
||||
"""Réconcilier seulement une équivalence structurelle déjà prouvée.
|
||||
|
||||
INVARIANT : lot entier, identités, placement et branches doivent rester
|
||||
exacts ; aucune intention pending n'est réinterprétée. L'origine et les
|
||||
preuves d'incident restent immuables, même pour une reprise sans IPC.
|
||||
"""
|
||||
if (self.data["focus_pending"] or any(e["pending"] for e in self.data["windows"]) or
|
||||
"structure_expected" not in self.data or structure(tree) != self.data["structure_expected"]):
|
||||
return
|
||||
expected = [e["expected"] for e in self.data["windows"]] + self.data["foreign"]
|
||||
nodes = [n for n in walk(tree) if n.get("app_id") or n.get("window")]
|
||||
if {n["id"] for n in nodes} != {s["con_id"] for s in expected}: return
|
||||
current = {n["id"]: observe(tree, n, self.start) for n in nodes}
|
||||
if any(placement(current[s["con_id"]]) != placement(s) for s in expected): return
|
||||
changed = [{"con_id": e["origin"]["con_id"], "before": list(e["expected"]["path"]),
|
||||
"after": list(current[e["origin"]["con_id"]]["path"])} for e in self.data["windows"]
|
||||
if e["expected"]["path"] != current[e["origin"]["con_id"]]["path"]]
|
||||
if not changed: return
|
||||
# WHY : réconcilier expected ne doit pas effacer la preuve exacte de la
|
||||
# commande confirmée. CONTRACT : historique avant/après durable et borné ;
|
||||
# à saturation refuser la normalisation, jamais écraser un ancien lot.
|
||||
history = self.data.setdefault("path_reconciliations", [])
|
||||
if len(history) >= 4: return
|
||||
history.append(changed)
|
||||
for entry in self.data["windows"]:
|
||||
entry["expected"] = current[entry["origin"]["con_id"]]
|
||||
self.publish()
|
||||
|
||||
def _recover(self):
|
||||
if self.data["status"] in ("complete", "recovered"):
|
||||
return {"status": self.data["status"], "windows": [], "issues": []}
|
||||
|
|
@ -555,15 +724,23 @@ class Recovery:
|
|||
"workspace": origin["workspace"], "output": origin["output"], "reason": reason,
|
||||
"inspect": "swaymsg -t get_tree -r", "stage": self.data["stage"],
|
||||
"instruction": "Vérifier identité et emplacement avec get_tree ; après autorisation humaine seulement, "
|
||||
+ f'[con_id={origin["con_id"]}] move container to workspace ' + json.dumps(origin["workspace"])})
|
||||
+ ("Scratchpad : examiner le journal avant toute commande." if origin.get("scratchpad", "none") != "none" else
|
||||
f'[con_id={origin["con_id"]}] move container to workspace ' + json.dumps(origin["workspace"]))})
|
||||
if self.sway.session != self.data["session"]:
|
||||
for e in self.data["windows"]: issue(e, "SESSION_CHANGED")
|
||||
issues.append("SESSION_CHANGED")
|
||||
else:
|
||||
recovery_focus = focused(self.sway.tree())
|
||||
tree = self.sway.tree()
|
||||
recovery_focus = focused(tree)
|
||||
if recovery_focus != self.data["focus_expected"]:
|
||||
self.record_focus_conflict({"phase": "recovery-entry", "command": None,
|
||||
"before": self.data["focus_expected"], "observed": recovery_focus})
|
||||
# WHY : un changement de layout peut garder tous les chemins bruts
|
||||
# identiques. CONTRACT : la preuve canonique, lorsqu'elle existe,
|
||||
# doit aussi précéder une reprise, même sans normalisation de chemin.
|
||||
structure_changed = ("structure_expected" in self.data and
|
||||
structure(tree) != self.data["structure_expected"])
|
||||
self.equivalent_paths(tree)
|
||||
for e in self.data["windows"]:
|
||||
if e["recovered"]: continue
|
||||
try:
|
||||
|
|
@ -573,8 +750,22 @@ class Recovery:
|
|||
self.publish()
|
||||
continue
|
||||
if e["pending"]: issue(e, "AMBIGUOUS_INTENTION"); continue
|
||||
if current != e["expected"]: issue(e, "IDENTITY_OR_MEMBERSHIP_CHANGED"); continue
|
||||
if structure_changed: issue(e, "STRUCTURE_OR_ORDER_CHANGED"); continue
|
||||
if current != e["expected"]:
|
||||
issue(e, "PATH_EQUIVALENCE_NOT_PROVEN" if placement(current) == placement(e["expected"])
|
||||
else "IDENTITY_OR_MEMBERSHIP_CHANGED")
|
||||
continue
|
||||
origin = e["origin"]
|
||||
if origin.get("scratchpad", "none") != "none" or current.get("scratchpad", "none") != "none":
|
||||
if placement(current) == placement(origin):
|
||||
e.update(recovered=True, pending=False)
|
||||
self.publish()
|
||||
else:
|
||||
# WHY : no original output transform is persisted for
|
||||
# a hidden leaf. CONTRACT : keep the client and exact
|
||||
# journal; never show it as a speculative rollback.
|
||||
issue(e, "SCRATCHPAD_RECOVERY_REQUIRES_REVIEW")
|
||||
continue
|
||||
active = {o["name"] for o in self.sway.outputs() if o.get("active")}
|
||||
if origin["output"] not in active: issue(e, "OUTPUT_UNAVAILABLE"); continue
|
||||
existing = workspace(self.sway.tree(), origin["workspace"])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,495 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:labfy:sway:session-v2:2",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema",
|
||||
"version",
|
||||
"metadata",
|
||||
"outputs",
|
||||
"applications",
|
||||
"window_slots",
|
||||
"workspaces",
|
||||
"focus",
|
||||
"focus_workspace"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "labfy.sway.session-v2"
|
||||
},
|
||||
"version": {
|
||||
"const": 2,
|
||||
"type": "integer"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"created_ns",
|
||||
"sway_session",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"created_ns": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9223372036854775807
|
||||
},
|
||||
"sway_session": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"manual",
|
||||
"logout",
|
||||
"reboot",
|
||||
"poweroff"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"$ref": "#/$defs/place"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"applications": {
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"items": {
|
||||
"$ref": "#/$defs/application"
|
||||
}
|
||||
},
|
||||
"window_slots": {
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"items": {
|
||||
"$ref": "#/$defs/slot"
|
||||
}
|
||||
},
|
||||
"workspaces": {
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"workspace",
|
||||
"output",
|
||||
"root"
|
||||
],
|
||||
"properties": {
|
||||
"workspace": {
|
||||
"$ref": "#/$defs/place"
|
||||
},
|
||||
"output": {
|
||||
"$ref": "#/$defs/place"
|
||||
},
|
||||
"root": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/tree"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"focus": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"focus_workspace": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/place"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$"
|
||||
},
|
||||
"place": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9 _:-]{1,64}$",
|
||||
"not": {
|
||||
"const": "__i3_scratch"
|
||||
}
|
||||
},
|
||||
"application": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"application_id",
|
||||
"desktop_entry",
|
||||
"strategy",
|
||||
"expected_windows",
|
||||
"managed_by",
|
||||
"identity_provider"
|
||||
],
|
||||
"properties": {
|
||||
"application_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"desktop_entry": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"pattern": "\\.desktop$"
|
||||
}
|
||||
]
|
||||
},
|
||||
"strategy": {
|
||||
"enum": [
|
||||
"managed-autostart",
|
||||
"single-window",
|
||||
"multi-instance",
|
||||
"terminal",
|
||||
"browser-self-restore",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"expected_windows": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 256
|
||||
},
|
||||
"managed_by": {
|
||||
"enum": [
|
||||
null,
|
||||
"autostart",
|
||||
"sway-autostart"
|
||||
]
|
||||
},
|
||||
"identity_provider": {
|
||||
"enum": [
|
||||
null,
|
||||
"firefox-session-window-uuid"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"geometry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"width",
|
||||
"height"
|
||||
],
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "integer",
|
||||
"minimum": -100000,
|
||||
"maximum": 100000
|
||||
},
|
||||
"y": {
|
||||
"type": "integer",
|
||||
"minimum": -100000,
|
||||
"maximum": 100000
|
||||
},
|
||||
"width": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 32768
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 32768
|
||||
}
|
||||
}
|
||||
},
|
||||
"evidence": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"type",
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "firefox-session-window-uuid"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"type",
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "application-singleton"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "#/$defs/id"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"slot": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"slot_id",
|
||||
"application_id",
|
||||
"workspace",
|
||||
"tree_path",
|
||||
"state",
|
||||
"identity_requirement",
|
||||
"identity_evidence"
|
||||
],
|
||||
"properties": {
|
||||
"slot_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"application_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"workspace": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/place"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tree_path": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 31
|
||||
}
|
||||
},
|
||||
"identity_requirement": {
|
||||
"enum": [
|
||||
"exact",
|
||||
"best-effort",
|
||||
"interchangeable"
|
||||
]
|
||||
},
|
||||
"identity_evidence": {
|
||||
"$ref": "#/$defs/evidence"
|
||||
},
|
||||
"state": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"floating",
|
||||
"geometry",
|
||||
"fullscreen",
|
||||
"scratchpad"
|
||||
],
|
||||
"properties": {
|
||||
"floating": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"geometry": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/geometry"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fullscreen": {
|
||||
"const": false
|
||||
},
|
||||
"scratchpad": {
|
||||
"enum": [
|
||||
"none",
|
||||
"hidden",
|
||||
"shown"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"properties": {
|
||||
"scratchpad": {
|
||||
"const": "hidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"workspace": {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"else": {
|
||||
"properties": {
|
||||
"workspace": {
|
||||
"$ref": "#/$defs/place"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"properties": {
|
||||
"scratchpad": {
|
||||
"enum": [
|
||||
"hidden",
|
||||
"shown"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"properties": {
|
||||
"floating": {
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"properties": {
|
||||
"floating": {
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"tree_path": {
|
||||
"maxItems": 0
|
||||
},
|
||||
"state": {
|
||||
"properties": {
|
||||
"geometry": {
|
||||
"$ref": "#/$defs/geometry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"else": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"properties": {
|
||||
"geometry": {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"tree": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"slot_id"
|
||||
],
|
||||
"properties": {
|
||||
"slot_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"layout",
|
||||
"children"
|
||||
],
|
||||
"properties": {
|
||||
"layout": {
|
||||
"enum": [
|
||||
"splith",
|
||||
"splitv",
|
||||
"tabbed",
|
||||
"stacked"
|
||||
]
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/$defs/tree"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,8 +32,8 @@ def array(value, maximum=256):
|
|||
|
||||
|
||||
def validate(data):
|
||||
fields(data, "schema version metadata outputs applications window_slots workspaces focus")
|
||||
require(data["schema"] == SCHEMA and type(data["version"]) is int and data["version"] == 1, "SCHEMA_VERSION")
|
||||
fields(data, "schema version metadata outputs applications window_slots workspaces focus" + (" focus_workspace" if type(data) is dict and data.get("version") == 2 else ""))
|
||||
require(data["schema"] == SCHEMA and type(data["version"]) is int and data["version"] in (1, 2), "SCHEMA_VERSION")
|
||||
fields(data["metadata"], "created_ns sway_session reason")
|
||||
integer(data["metadata"]["created_ns"], 0, 2**63 - 1)
|
||||
text(data["metadata"]["sway_session"])
|
||||
|
|
@ -43,6 +43,7 @@ def validate(data):
|
|||
for o in data["outputs"]:
|
||||
fields(o, "name")
|
||||
text(o["name"], PLACE)
|
||||
require(data["version"] == 1 or o["name"] != "__i3_scratch", "SCRATCHPAD_WORKSPACE")
|
||||
require(o["name"] not in outputs, "DUPLICATE_OUTPUT")
|
||||
outputs[o["name"]] = o
|
||||
for a in data["applications"]:
|
||||
|
|
@ -60,7 +61,7 @@ def validate(data):
|
|||
fields(s, "slot_id application_id workspace tree_path state identity_requirement identity_evidence")
|
||||
text(s["slot_id"])
|
||||
text(s["application_id"])
|
||||
text(s["workspace"], PLACE)
|
||||
if s["workspace"] is not None: text(s["workspace"], PLACE)
|
||||
array(s["tree_path"], 32)
|
||||
for i in s["tree_path"]: integer(i, 0, 31)
|
||||
require(s["application_id"] in apps and s["slot_id"] not in slots, "SLOT_REFERENCE")
|
||||
|
|
@ -68,8 +69,18 @@ def validate(data):
|
|||
state = s["state"]
|
||||
fields(state, "floating geometry fullscreen scratchpad")
|
||||
require(type(state["floating"]) is bool, "SCHEMA_BOOLEAN")
|
||||
require(type(state["fullscreen"]) is bool and state["fullscreen"] is False and state["scratchpad"] is False,
|
||||
require(type(state["fullscreen"]) is bool and state["fullscreen"] is False,
|
||||
"CAPABILITY_UNSUPPORTED")
|
||||
# CONTRACT : V1 reste immuable. V2 distingue appartenance et visibilité ;
|
||||
# le workspace interne Sway n'est jamais une destination persistante.
|
||||
if data["version"] == 1:
|
||||
require(state["scratchpad"] is False, "CAPABILITY_UNSUPPORTED")
|
||||
else:
|
||||
require(type(state["scratchpad"]) is str and state["scratchpad"] in ("none", "hidden", "shown"), "SCHEMA_SCRATCHPAD")
|
||||
require(state["scratchpad"] == "none" or state["floating"], "SCRATCHPAD_FLOATING_REQUIRED")
|
||||
hidden = state["scratchpad"] == "hidden"
|
||||
require((s["workspace"] is None) == hidden, "SCRATCHPAD_WORKSPACE")
|
||||
require(s["workspace"] != "__i3_scratch", "SCRATCHPAD_WORKSPACE")
|
||||
if state["floating"]:
|
||||
fields(state["geometry"], "x y width height")
|
||||
for k in ("x", "y"): integer(state["geometry"][k], -100000, 100000)
|
||||
|
|
@ -108,18 +119,24 @@ def validate(data):
|
|||
for w in data["workspaces"]:
|
||||
fields(w, "workspace output root")
|
||||
text(w["workspace"], PLACE)
|
||||
require(w["workspace"] != "__i3_scratch", "SCRATCHPAD_WORKSPACE")
|
||||
text(w["output"], PLACE)
|
||||
require(w["workspace"] not in workspaces and w["output"] in outputs, "WORKSPACE_REFERENCE")
|
||||
workspaces[w["workspace"]] = w
|
||||
tree(w["root"], w["workspace"])
|
||||
for s in slots.values():
|
||||
require(s["workspace"] in workspaces and (s["state"]["floating"] or s["slot_id"] in seen), "UNPLACED_SLOT")
|
||||
require(s["state"]["scratchpad"] == "hidden" or s["workspace"] in workspaces and (s["state"]["floating"] or s["slot_id"] in seen), "UNPLACED_SLOT")
|
||||
for name in workspaces:
|
||||
require(any(s["workspace"] == name for s in slots.values()), "EMPTY_WORKSPACE_UNSUPPORTED")
|
||||
require(any(s["workspace"] == name for s in slots.values()) or data.get("focus_workspace") == name, "EMPTY_WORKSPACE_UNSUPPORTED")
|
||||
for a in apps.values():
|
||||
members = [s for s in slots.values() if s["application_id"] == a["application_id"]]
|
||||
require(len(members) == a["expected_windows"], "APPLICATION_CARDINALITY")
|
||||
uuids = [s["identity_evidence"]["id"] for s in members if s["identity_evidence"] and s["identity_evidence"]["type"] == PROVIDER]
|
||||
require(len(uuids) == len(set(uuids)), "EXACT_WINDOW_IDENTITY_COLLISION")
|
||||
require(data["focus"] is None or type(data["focus"]) is str and data["focus"] in slots, "FOCUS_REFERENCE")
|
||||
require(data["focus"] is None or slots[data["focus"]]["state"]["scratchpad"] != "hidden", "FOCUS_HIDDEN_SCRATCHPAD")
|
||||
if data["version"] == 2:
|
||||
name = data["focus_workspace"]
|
||||
require(name is None or type(name) is str and name in workspaces, "FOCUS_WORKSPACE_REFERENCE")
|
||||
require(data["focus"] is None or name is not None and slots[data["focus"]]["workspace"] == name, "FOCUS_WORKSPACE_REFERENCE")
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from .errors import require
|
|||
from .schema import PROVIDER
|
||||
|
||||
|
||||
def assign(slots, nodes, identities=None):
|
||||
def assign(slots, nodes, identities=None, states=None):
|
||||
require(len(nodes) >= len(slots), "APPLICATION_WINDOWS_INCOMPLETE")
|
||||
require(len(nodes) <= len(slots), "UNEXPECTED_EXTRA_WINDOW")
|
||||
result, remaining = {}, {n["id"] for n in nodes}
|
||||
|
|
@ -21,7 +21,19 @@ def assign(slots, nodes, identities=None):
|
|||
require(target in remaining, "EXACT_WINDOW_IDENTITY_COLLISION")
|
||||
result[s["slot_id"]] = target
|
||||
remaining.remove(target)
|
||||
# Ordre numérique runtime déterministe, jamais une preuve sémantique.
|
||||
for s, target in zip(sorted((s for s in slots if s["slot_id"] not in result), key=lambda x: x["slot_id"]), sorted(remaining)):
|
||||
# WHY : interchangeable is an explicit loss of semantic identity, never a
|
||||
# permission to expose an arbitrary hidden peer of the same application.
|
||||
# CONTRACT : preserve matching visibility first, deterministic runtime tie
|
||||
# break; exact UUID/singleton assignments above remain authoritative.
|
||||
states = states or {}
|
||||
pending = sorted((s for s in slots if s["slot_id"] not in result),
|
||||
key=lambda s: (s.get("state", {}).get("scratchpad", "none") != "hidden", s["slot_id"]))
|
||||
for s in pending:
|
||||
desired = s.get("state", {}).get("scratchpad", "none") or "none"
|
||||
matching = [i for i in remaining if states.get(i, {}).get("scratchpad", "none") == desired]
|
||||
candidates = matching or [i for i in remaining if states.get(i, {}).get("scratchpad", "none") == "none"]
|
||||
require(candidates, "SCRATCHPAD_ASSIGNMENT_UNPROVEN")
|
||||
target = min(candidates)
|
||||
result[s["slot_id"]] = target
|
||||
remaining.remove(target)
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import time
|
||||
from .errors import require
|
||||
from .schema import validate, PROVIDER
|
||||
from .ipc import walk
|
||||
from .ipc import walk, location
|
||||
|
||||
|
||||
def capture(sway, catalog, provider, reason="manual"):
|
||||
|
|
@ -11,13 +11,14 @@ def capture(sway, catalog, provider, reason="manual"):
|
|||
# Les titres, tokens et métadonnées applicatives ne participent pas à
|
||||
# cette empreinte éphémère. Détecter aussi déplacement/focus durant capture.
|
||||
return (node.get("id"), node.get("layout"), node.get("name") if node.get("type") in ("workspace", "output") else None,
|
||||
node.get("focused"), node.get("fullscreen_mode"), node.get("floating"), node.get("rect"),
|
||||
node.get("app_id"), node.get("window"), node.get("pid"), node.get("scratchpad_state", "none"), node.get("focused"), node.get("fullscreen_mode"), node.get("floating"), node.get("rect"),
|
||||
tuple(topology(c) for c in node.get("nodes", [])), tuple(topology(c) for c in node.get("floating_nodes", [])))
|
||||
nodes = [n for n in walk(tree) if n.get("app_id") or n.get("window")]
|
||||
require(len(nodes) <= 256, "WINDOW_LIMIT")
|
||||
specs = {}
|
||||
for n in nodes:
|
||||
require(not n.get("fullscreen_mode") and n.get("scratchpad_state", "none") == "none", "CAPABILITY_UNSUPPORTED")
|
||||
require(not n.get("fullscreen_mode"), "CAPABILITY_UNSUPPORTED")
|
||||
require(n.get("scratchpad_state", "none") in ("none", "fresh", "changed"), "SCRATCHPAD_STATE_UNSUPPORTED")
|
||||
specs[n["id"]] = catalog.classify(n)
|
||||
apps = {}
|
||||
identities = {}
|
||||
|
|
@ -32,6 +33,16 @@ def capture(sway, catalog, provider, reason="manual"):
|
|||
identities.update({v: k for k, v in provider.capture(members).items()})
|
||||
slots, workspaces = [], []
|
||||
focused = None
|
||||
selected = [n for n in walk(tree) if n.get("focused")]
|
||||
# Une layer-shell (notamment le dialogue de logout) peut prendre le focus
|
||||
# et ne laisser aucune feuille/workspace marqué focused dans GET_TREE.
|
||||
# Capturer explicitement l'inconnu, jamais déduire une cible d'une pile IPC.
|
||||
require(len(selected) <= 1, "SNAPSHOT_FOCUS_AMBIGUOUS")
|
||||
focus_workspace = None
|
||||
if selected:
|
||||
focus_location = location(tree, selected[0]["id"])
|
||||
require(focus_location and focus_location[0] and focus_location[0] != "__i3_scratch", "SNAPSHOT_FOCUS_AMBIGUOUS")
|
||||
focus_workspace = focus_location[0]
|
||||
def leaf(n, workspace, path, floating):
|
||||
nonlocal focused
|
||||
app = specs[n["id"]]
|
||||
|
|
@ -43,9 +54,14 @@ def capture(sway, catalog, provider, reason="manual"):
|
|||
requirement, evidence = "exact", {"type": "application-singleton", "id": app.application_id}
|
||||
else: requirement, evidence = "interchangeable", None
|
||||
if n.get("focused"): focused = slot_id
|
||||
scratchpad = "none" if n.get("scratchpad_state", "none") == "none" else ("hidden" if workspace is None else "shown")
|
||||
require(workspace is not None or scratchpad == "hidden", "SCRATCHPAD_STATE_UNSUPPORTED")
|
||||
require(scratchpad == "none" or floating, "SCRATCHPAD_FLOATING_REQUIRED")
|
||||
if floating:
|
||||
require(type(n.get("rect")) is dict and all(k in n["rect"] for k in ("x", "y", "width", "height")), "SNAPSHOT_GEOMETRY_MISSING")
|
||||
geometry = {k: n["rect"][k] for k in ("x", "y", "width", "height")} if floating else None
|
||||
slots.append({"slot_id": slot_id, "application_id": app.application_id, "workspace": workspace,
|
||||
"tree_path": list(path), "state": {"floating": floating, "geometry": geometry, "fullscreen": False, "scratchpad": False},
|
||||
"tree_path": list(path), "state": {"floating": floating, "geometry": geometry, "fullscreen": False, "scratchpad": scratchpad},
|
||||
"identity_requirement": requirement, "identity_evidence": evidence})
|
||||
return {"slot_id": slot_id}
|
||||
def convert(n, workspace, path=()):
|
||||
|
|
@ -59,20 +75,27 @@ def capture(sway, catalog, provider, reason="manual"):
|
|||
outputs = []
|
||||
for output in tree.get("nodes", []):
|
||||
for ws in output.get("nodes", []):
|
||||
if ws.get("type") != "workspace" or not (ws.get("nodes") or ws.get("floating_nodes")): continue
|
||||
require(ws["name"] != "__i3_scratch", "CAPABILITY_UNSUPPORTED")
|
||||
if ws.get("type") != "workspace" or not (ws.get("nodes") or ws.get("floating_nodes") or ws.get("name") == focus_workspace): continue
|
||||
if ws["name"] == "__i3_scratch":
|
||||
require(not ws.get("nodes"), "SCRATCHPAD_CONTAINER_UNSUPPORTED")
|
||||
for f in ws.get("floating_nodes", []):
|
||||
require(f.get("app_id") or f.get("window"), "SCRATCHPAD_CONTAINER_UNSUPPORTED")
|
||||
leaf(f, None, (), True)
|
||||
continue
|
||||
root = convert(ws, ws["name"])
|
||||
for f in ws.get("floating_nodes", []):
|
||||
children = [n for n in walk(f) if n.get("app_id") or n.get("window")]
|
||||
require(len(children) == 1, "FLOATING_CONTAINER_UNSUPPORTED")
|
||||
require(children[0] is f, "FLOATING_CONTAINER_UNSUPPORTED")
|
||||
leaf(children[0], ws["name"], (), True)
|
||||
workspaces.append({"workspace": ws["name"], "output": output["name"], "root": root})
|
||||
if output["name"] not in outputs: outputs.append(output["name"])
|
||||
require(len(slots) == len(nodes), "SNAPSHOT_UNPLACED_WINDOW")
|
||||
# Relecture : aucun checkpoint hybride si une fenêtre apparaît/disparaît.
|
||||
require({n["id"] for n in sway.windows()} == set(specs), "SNAPSHOT_CHANGED")
|
||||
require(topology(sway.tree()) == topology(tree), "SNAPSHOT_CHANGED")
|
||||
data = {"schema": "labfy.sway.session-v2", "version": 1,
|
||||
data = {"schema": "labfy.sway.session-v2", "version": 2,
|
||||
"metadata": {"created_ns": time.time_ns(), "sway_session": sway.session, "reason": reason},
|
||||
"outputs": [{"name": n} for n in outputs], "applications": list(apps.values()),
|
||||
"window_slots": slots, "workspaces": workspaces, "focus": focused}
|
||||
"window_slots": slots, "workspaces": workspaces, "focus": focused, "focus_workspace": focus_workspace}
|
||||
return validate(data)
|
||||
|
|
|
|||
|
|
@ -186,6 +186,7 @@ class HelperAnchors:
|
|||
class HelperGuard:
|
||||
ready = dict(helpers_suspended=0)
|
||||
def __init__(self, *args): pass
|
||||
def prepare(self): return dict(status="prepared")
|
||||
def resume(self): return dict(helpers_resumed=0)
|
||||
def finish(self): return dict(helpers_resumed=0, anchors_cleaned=0)
|
||||
|
||||
|
|
@ -220,6 +221,39 @@ class ExtendedTests(unittest.TestCase):
|
|||
self.assertEqual(focused(sway.tree()), dict(con_id=6, workspace='1'))
|
||||
self.assertEqual(recovery_status(tmp)['status'], 'complete')
|
||||
self.assertFalse(status(Path(tmp), sway.session, fixture(), True)['deferred'])
|
||||
def test_watchdog_boundary_focus_changes_refuse_without_refocusing(self):
|
||||
for phase, boundary in (('prepare', 'after-guard-prepare'),
|
||||
('resume', 'after-helper-resume'),
|
||||
('finish', 'after-guard-finish')):
|
||||
with self.subTest(phase=phase), tempfile.TemporaryDirectory() as tmp:
|
||||
sway = MutationSway(6)
|
||||
sway.runtime = Path(tmp)
|
||||
class ChangedGuard(HelperGuard):
|
||||
def change(self):
|
||||
for n in walk(sway.value): n['focused'] = n['id'] == 5
|
||||
def prepare(self):
|
||||
if phase == 'prepare': self.change()
|
||||
return super().prepare()
|
||||
def resume(self):
|
||||
if phase == 'resume': self.change()
|
||||
return super().resume()
|
||||
def finish(self):
|
||||
if phase == 'finish': self.change()
|
||||
return super().finish()
|
||||
with patch('session_v2.executor.Anchors', HelperAnchors), patch('session_v2.executor.Guard', ChangedGuard):
|
||||
with self.assertRaisesRegex(Failure, 'RECOVERY_FOCUS_MISMATCH'):
|
||||
Executor(sway, Catalog(), None, Path(tmp), '/forbidden', process_identity=lambda pid: pid).apply(fixture(), True)
|
||||
report = read(Path(tmp)/'session-v2-restore-attempt.json')
|
||||
journal = read(Path(tmp)/JOURNAL)
|
||||
self.assertTrue(report['final_tree_verified'])
|
||||
self.assertFalse(report['final_focus_verified'])
|
||||
self.assertEqual(journal['focus_conflict_observation']['command'], boundary)
|
||||
self.assertEqual(journal['focus_boundaries'][-1]['boundary'], boundary)
|
||||
self.assertEqual(focused(sway.tree()), dict(con_id=5, workspace='10'))
|
||||
# After the unexpected focus, even recovery emits no refocus.
|
||||
expected = 0 if phase == 'prepare' else 1
|
||||
self.assertEqual(sum(text == '[con_id=6] focus' for text in sway.commands), expected)
|
||||
|
||||
def test_snapshot_focus_retained_when_baseline_belongs_to_batch(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
sway = MutationSway(5)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from unittest.mock import patch
|
|||
from common import *
|
||||
from lab import Lab, Surfaces, build_helper
|
||||
from session_v2.executor import Executor
|
||||
from session_v2.guard import Guard
|
||||
from session_v2.applications import launch as real_launch
|
||||
from dataclasses import replace
|
||||
from session_v2.recovery import JOURNAL, focused, process_start, Recovery, recovery_status
|
||||
|
|
@ -122,7 +123,7 @@ def run_case(case):
|
|||
return value
|
||||
foreign = Surfaces(lab, binary)
|
||||
foreign_node = None
|
||||
if case in ('focus-third-party', 'preserve-foreign'):
|
||||
if case in ('focus-third-party', 'preserve-foreign', 'focus-external-late'):
|
||||
lab.command('workspace "TEST_ONLY_OTHER"')
|
||||
foreign_node = foreign.create('TEST_ONLY_foreign')
|
||||
lab.command('workspace "1"')
|
||||
|
|
@ -189,6 +190,36 @@ def run_case(case):
|
|||
return {UUID: nodes[0]['id']}
|
||||
def capture(self, nodes): return {UUID: nodes[0]['id']}
|
||||
runtime = private_dir(lab.directory / 'runtime')
|
||||
helper = lab.spawn(['/usr/bin/python', '/usr/bin/autotiling', '--debug']) if case == 'autotiling' else None
|
||||
def helpers(path):
|
||||
# Only this private child is eligible. Never discover the host's
|
||||
# /proc helpers, including when running an actual autotiling.
|
||||
assert path == lab.socket
|
||||
return [dict(pid=helper.pid, fd=os.pidfd_open(helper.pid), script='/usr/bin/autotiling')] if helper else []
|
||||
class ObservedGuard(Guard):
|
||||
def prepare(self):
|
||||
answer = super().prepare()
|
||||
assert self.anchors.process.poll() is not None, 'ANCHOR_NOT_EXITED_BEFORE_RESUME'
|
||||
evidence['anchor_exited_before_resume'] = True
|
||||
return answer
|
||||
def __init__(self, sway, anchors, *args, **kwargs):
|
||||
self.anchors = anchors
|
||||
super().__init__(sway, anchors, *args, **kwargs)
|
||||
def resume(self):
|
||||
answer = super().resume()
|
||||
if case in ('focus-helper-late', 'focus-external-late'):
|
||||
kitty = next(n for n in sway.windows() if n['app_id'] == 'kitty')
|
||||
target = foreign_node['id'] if case == 'focus-external-late' else kitty['id']
|
||||
lab.command(f'[con_id={target}] focus')
|
||||
evidence['injected_focus'] = focused(sway.tree())
|
||||
return answer
|
||||
def finish(self):
|
||||
answer = super().finish()
|
||||
if case == 'focus-finish-late':
|
||||
kitty = next(n for n in sway.windows() if n['app_id'] == 'kitty')
|
||||
lab.command(f'[con_id={kitty["id"]}] focus')
|
||||
evidence['injected_focus'] = focused(sway.tree())
|
||||
return answer
|
||||
raw_command = sway.command
|
||||
def command(text):
|
||||
row = {'command': text, 'ns': time.monotonic_ns(), 'before': focused(sway.tree())}
|
||||
|
|
@ -210,7 +241,7 @@ def run_case(case):
|
|||
finally: row['after'] = focused(sway.tree())
|
||||
sway.command = command
|
||||
try:
|
||||
with patch('session_v2.executor.launch', side_effect=launch_surface), patch('session_v2.guard.discover', return_value=[]):
|
||||
with patch('session_v2.executor.launch', side_effect=launch_surface), patch('session_v2.guard.discover', side_effect=helpers), patch('session_v2.executor.Guard', ObservedGuard):
|
||||
if case == 'launch-failure-retry':
|
||||
bad_catalog = Catalog(tuple(replace(spec, argv=(str(lab.directory / 'TEST_ONLY_NO_EXECUTABLE'),))
|
||||
if spec.application_id == 'firefox' else spec
|
||||
|
|
@ -245,7 +276,7 @@ def run_case(case):
|
|||
# Aucun kill d'un client ; les kill du guard sont sur le helper.
|
||||
assert not any(row['command'] == f'[con_id={music["id"]}] kill' for row in evidence['commands'])
|
||||
assert not any(n['app_id'].startswith('labfy-v2-anchor-') for n in sway.windows())
|
||||
if case in ('natural', 'launch-failure-retry', 'preserve-empty-workspace', 'preserve-foreign'):
|
||||
if case in ('natural', 'launch-failure-retry', 'preserve-empty-workspace', 'preserve-foreign', 'autotiling'):
|
||||
# Le contrat de recette exige un vrai succès. Un refus sûr
|
||||
# est conservé comme BLOCKED et retourne un code non nul.
|
||||
evidence['requirement_met'] = (evidence['outcome'] == 'success' and
|
||||
|
|
@ -261,8 +292,18 @@ def run_case(case):
|
|||
assert focused(sway.tree()) == (evidence['preserved_focus'] if case in ('preserve-empty-workspace', 'preserve-foreign') else {'con_id': firefox['id'], 'workspace': '1'})
|
||||
assert not any(location(sway.tree(), n['id'])[0] == journal['stage'] for n in sway.windows())
|
||||
assert focused(sway.tree())['workspace'] != journal['stage']
|
||||
elif case in ('focus-helper-late', 'focus-external-late', 'focus-finish-late'):
|
||||
assert evidence['outcome'] == 'RECOVERY_FOCUS_MISMATCH', evidence['outcome']
|
||||
assert report['anchors_swapped'] == 3 and report['slots_filled'] == 3
|
||||
assert report['final_tree_verified'] and not report['final_focus_verified']
|
||||
assert journal['status'] == 'required' and journal['focus_conflict']
|
||||
assert focused(sway.tree()) == evidence['injected_focus']
|
||||
boundary = 'after-guard-finish' if case == 'focus-finish-late' else 'after-helper-resume'
|
||||
assert journal['focus_conflict_observation']['command'] == boundary
|
||||
assert recovery_status(runtime)['status'] == 'RECOVERY_REQUIRED'
|
||||
evidence['requirement_met'] = True
|
||||
else:
|
||||
expected = {'unowned-map': 'APPLICATION_MEMBERSHIP_CHANGED',
|
||||
expected = {'unowned-map' : 'APPLICATION_MEMBERSHIP_CHANGED',
|
||||
'provider-placement': 'TARGET_OBSERVATION_CHANGED',
|
||||
'pinned-map': 'APPLICATION_MEMBERSHIP_CHANGED',
|
||||
'map-in-ipc-gap': 'RECOVERY_FOCUS_MISMATCH',
|
||||
|
|
@ -300,7 +341,7 @@ def run_case(case):
|
|||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--case', choices=['natural','launch-failure-retry','preserve-empty-workspace','preserve-foreign','focus-third-party','focus-same-client','unowned-map','pinned-map','map-in-ipc-gap','provider-placement','crash-mid-ipc'], default='natural')
|
||||
parser.add_argument('--case', choices=['natural','launch-failure-retry','preserve-empty-workspace','preserve-foreign','focus-third-party','focus-same-client','unowned-map','pinned-map','map-in-ipc-gap','provider-placement','crash-mid-ipc','autotiling','focus-helper-late','focus-external-late','focus-finish-late'], default='natural')
|
||||
args = parser.parse_args()
|
||||
row = run_case(args.case)
|
||||
print(json.dumps({'scenario': args.case, 'requirement_met': row['requirement_met'],
|
||||
|
|
|
|||
|
|
@ -41,6 +41,19 @@ TestCase {
|
|||
compare(spy.count, 0);
|
||||
}
|
||||
}
|
||||
function test_backend_reason_is_visible_and_logout_stays_gated() {
|
||||
for (const reason of ["CAPABILITY_UNSUPPORTED", "SNAPSHOT_CHANGED", "RECOVERY_REQUIRED", "UNKNOWN_BACKEND_REASON"]) {
|
||||
const g = gate();
|
||||
const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"});
|
||||
g.requestAction("logout"); g.confirmAction("logout");
|
||||
g.checkpointFinished(2, 0, JSON.stringify({status: "failed", reason: reason}), "Le checkpoint V2 a échoué.");
|
||||
verify(g.errorMessage.indexOf(reason) >= 0);
|
||||
verify(g.errorMessage.length > reason.length + 10);
|
||||
compare(spy.count, 0);
|
||||
compare(g.pendingAction.id, "logout");
|
||||
g.cancel(); compare(spy.count, 0);
|
||||
}
|
||||
}
|
||||
function test_quit_without_saving_requires_explicit_call() {
|
||||
const g = gate();
|
||||
const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"});
|
||||
|
|
|
|||
133
tests/session_v2/scratchpad_runtime.py
Normal file
133
tests/session_v2/scratchpad_runtime.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""Private real compositor only; no inherited user socket, no helper suspension.
|
||||
|
||||
Retains synthetic trees and screenshots under /tmp. SIGCONT is deliberately not
|
||||
used by this runner, even during cleanup. No installed configuration is read.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
from common import *
|
||||
from lab import Lab as BaseLab, Surfaces, build_helper
|
||||
from session_v2.snapshot import capture
|
||||
from session_v2.ipc import Sway, location
|
||||
from session_v2.executor import Executor
|
||||
from session_v2.checkpoint import Store
|
||||
from session_v2.storage import private_dir
|
||||
from session_v2.recovery import Recovery
|
||||
|
||||
|
||||
class Lab(BaseLab):
|
||||
def __exit__(self, *_):
|
||||
for proc in reversed(self.children):
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try: proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill(); proc.wait(timeout=3)
|
||||
for log in self.logs: log.close()
|
||||
|
||||
|
||||
def main():
|
||||
with Lab() as lab:
|
||||
print(lab.directory, flush=True)
|
||||
binary = build(lab.directory)
|
||||
sway = Sway(lab.socket)
|
||||
commands = []
|
||||
raw_command = sway.command
|
||||
def recorded(text):
|
||||
commands.append(text)
|
||||
return raw_command(text)
|
||||
sway.command = recorded
|
||||
# Production catalog structure, synthetic IDs. No arbitrary argv launches.
|
||||
static_kitty = ('/usr/bin/kitty', '--config', 'NONE', '--class', 'TEST_ONLY_kitty',
|
||||
'--title', 'TEST_ONLY', '-o', 'linux_display_server=wayland',
|
||||
'-o', 'confirm_os_window_close=0', '/usr/bin/sleep', '180')
|
||||
cat = Catalog((Spec('kitty', 'kitty.desktop', ('TEST_ONLY_kitty',), 'terminal', static_kitty),
|
||||
*(Spec(n, n+'.desktop', ('TEST_ONLY_'+n,), 'managed-autostart', ())
|
||||
for n in ('shown', 'float', 'split'))))
|
||||
|
||||
lab.command('workspace "TEST_ONLY_TARGET"')
|
||||
ids = [lab.kitty('TEST_ONLY_'+n) for n in ('kitty','kitty','shown','float','split')]
|
||||
def command(i, operation): lab.command(f'[con_id={i}] '+operation)
|
||||
for i in ids[:1]+ids[2:4]:
|
||||
command(i, 'floating enable')
|
||||
command(ids[0], 'move scratchpad')
|
||||
command(ids[2], 'move scratchpad')
|
||||
command(ids[2], 'scratchpad show')
|
||||
# Allow GTK's real configure/ack cycle before the measured snapshot.
|
||||
time.sleep(.3)
|
||||
for i in ids[:1]+ids[2:4]:
|
||||
command(i, 'resize set width 400 px height 240 px')
|
||||
command(i, 'move absolute position 30 px 40 px')
|
||||
command(ids[1], 'focus')
|
||||
time.sleep(.3)
|
||||
def evidence(name):
|
||||
(lab.directory/(name+'.json')).write_text(json.dumps(sway.tree(),indent=2))
|
||||
subprocess.run(['grim','-o','HEADLESS-1',str(lab.directory/(name+'.png'))],env=lab.env,check=True)
|
||||
evidence('before')
|
||||
data = capture(sway, cat, None)
|
||||
store = Store(private_dir(lab.directory/'store'))
|
||||
store.save('scratch',data); store.checkpoint(data)
|
||||
assert store.load('scratch') == store.last() == data
|
||||
(lab.directory/'snapshot.json').write_text(json.dumps(data,indent=2))
|
||||
command(ids[2], 'move scratchpad')
|
||||
command(ids[3], 'floating disable')
|
||||
evidence('disturbed')
|
||||
result = Executor(sway,cat,None,private_dir(lab.directory/'runtime'),binary,lab.env).apply(store.last(),True)
|
||||
evidence('after')
|
||||
(lab.directory/'result.json').write_text(json.dumps(result,indent=2))
|
||||
(lab.directory/'commands.json').write_text(json.dumps(commands,indent=2))
|
||||
assert not any(c.startswith(f'[con_id={ids[0]}] ') and ('workspace' in c or 'scratchpad show' in c) for c in commands)
|
||||
assert result['status'] == 'success'
|
||||
assert location(sway.tree(),ids[0])[0] == '__i3_scratch'
|
||||
# User-like explicit show/hide after restore: prove real membership.
|
||||
command(ids[0], 'scratchpad show'); time.sleep(.2); evidence('user-show')
|
||||
command(ids[0], 'move scratchpad'); time.sleep(.2); evidence('user-hide')
|
||||
assert location(sway.tree(),ids[0])[0] == '__i3_scratch'
|
||||
|
||||
# A new-session-like placement: membership must be created from normal
|
||||
# leaves, with the same pinned processes and trusted static catalog.
|
||||
command(ids[0], 'scratchpad show'); command(ids[0], 'floating disable')
|
||||
command(ids[2], 'floating disable')
|
||||
time.sleep(.3)
|
||||
second = Executor(sway,cat,None,private_dir(lab.directory/'runtime-new-membership'),binary,lab.env).apply(data,True)
|
||||
assert second['status'] == 'success'
|
||||
evidence('new-membership')
|
||||
# All hidden, with an explicitly focused empty workspace, remains a
|
||||
# restorable snapshot and never persists the internal scratch workspace.
|
||||
for i in ids: command(i, 'move scratchpad')
|
||||
lab.command('workspace "TEST_ONLY_EMPTY"'); time.sleep(.3)
|
||||
all_hidden = capture(sway,cat,None)
|
||||
assert all_hidden['focus'] is None and all_hidden['focus_workspace'] == 'TEST_ONLY_EMPTY'
|
||||
assert all(s['state']['scratchpad']=='hidden' for s in all_hidden['window_slots'])
|
||||
third = Executor(sway,cat,None,private_dir(lab.directory/'runtime-all-hidden'),binary,lab.env).apply(all_hidden,True)
|
||||
assert third['status'] == 'success'
|
||||
evidence('all-hidden')
|
||||
(lab.directory/'all-hidden-snapshot.json').write_text(json.dumps(all_hidden,indent=2))
|
||||
# Real prewarm launches through a static test catalog, with fresh PIDs.
|
||||
# Track returned children solely for TEST_ONLY cleanup; no launch is
|
||||
# derived from snapshot data, titles or the former terminal process.
|
||||
from session_v2.applications import launch as real_launch
|
||||
old = {n['id'] for n in sway.windows() if n['app_id']=='TEST_ONLY_kitty'}
|
||||
for i in old: command(i, 'kill')
|
||||
lab.wait_absent(old)
|
||||
launched = []
|
||||
def track_launch(spec, count, env):
|
||||
children = real_launch(spec,count,env)
|
||||
lab.children.extend(children);launched.extend(children)
|
||||
return children
|
||||
with patch('session_v2.executor.launch',side_effect=track_launch):
|
||||
engine=Executor(sway,cat,None,private_dir(lab.directory/'runtime-cold-kitty'),binary,lab.env)
|
||||
cold=engine.apply(all_hidden,True)
|
||||
assert cold['status']=='success' and len(launched)==2
|
||||
assert not old.intersection(n['id'] for n in sway.windows())
|
||||
assert all(location(sway.tree(),n['id'])[0]=='__i3_scratch' for n in sway.windows() if n['app_id']=='TEST_ONLY_kitty')
|
||||
# A further apply reuses the admitted surfaces; no duplicate launch.
|
||||
again=engine.apply(all_hidden,True)
|
||||
assert again['status']=='success' and len(launched)==2
|
||||
evidence('cold-kitty')
|
||||
(lab.directory/'cold-result.json').write_text(json.dumps(cold,indent=2))
|
||||
print('PASS save/store/restore/show/hide/new-membership/all-hidden/cold-kitty/no-duplicate', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
|
|
@ -22,9 +22,9 @@ def frame(value):
|
|||
|
||||
class BootstrapTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
base = REPO / "build/bootstrap-fixtures"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
self.tmp = tempfile.TemporaryDirectory(dir=base)
|
||||
# AF_UNIX est borné à 108 octets : un worktree profond ne doit pas
|
||||
# rendre ces sockets TEST_ONLY dépendants du chemin du dépôt.
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="labfy-v2-bootstrap-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
for name in ("home", "state", "runtime"):
|
||||
|
|
|
|||
133
tests/session_v2/test_focus_recovery.py
Normal file
133
tests/session_v2/test_focus_recovery.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""Incidents du 22 octobre : arbres inertes et helper headless privé seulement."""
|
||||
import unittest
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
from common import *
|
||||
from test_prewarm import Sway
|
||||
from session_v2.recovery import Recovery, JOURNAL, focused
|
||||
from session_v2.ipc import walk, workspace
|
||||
from session_v2.storage import read
|
||||
from session_v2.errors import Failure
|
||||
|
||||
|
||||
class FocusRecoveryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.runtime = Path(self.tmp.name)
|
||||
self.sway = Sway()
|
||||
for ws in self.sway.value['nodes'][0]['nodes']: ws['layout'] = 'splith'
|
||||
self.sway.add('firefox', 8, focus=True)
|
||||
self.sway.add('kitty', 7, focus=False)
|
||||
self.recovery = Recovery(self.sway, self.runtime, 'b'*32, 'labfy-v2-stage-'+'b'*32, start=lambda pid: 123)
|
||||
for n in self.sway.windows(): self.recovery.admit(n)
|
||||
self.recovery.baseline({'firefox', 'kitty', 'limusic-app'})
|
||||
# Production command, independently simulated focus effect, no socket.
|
||||
def command(text):
|
||||
self.sway.commands.append(text)
|
||||
self.assertEqual(text, '[con_id=8] focus')
|
||||
for n in walk(self.sway.value): n['focused'] = n['id'] == 8
|
||||
self.sway.command = command
|
||||
self.recovery.command('[con_id=8] focus')
|
||||
|
||||
def unary(self):
|
||||
ws = workspace(self.sway.value, '1')
|
||||
ws['nodes'] = [dict(id=80+i, type='con', layout='splitv', nodes=[n], focused=False)
|
||||
for i, n in enumerate(ws['nodes'])]
|
||||
|
||||
def test_firefox_command_proven_then_kitty_late_conflict_is_preserved(self):
|
||||
self.assertFalse(self.recovery.data['focus_pending'])
|
||||
for n in walk(self.sway.value): n['focused'] = n['id'] == 7
|
||||
with self.assertRaisesRegex(Failure, 'RECOVERY_FOCUS_MISMATCH'):
|
||||
self.recovery.validate_final(self.sway.tree())
|
||||
first = read(self.runtime/JOURNAL)['focus_conflict_observation']
|
||||
self.assertEqual(first['before'], dict(con_id=8, workspace='1'))
|
||||
self.assertEqual(first['observed'], dict(con_id=7, workspace='1'))
|
||||
self.sway.commands.clear()
|
||||
receipt = Recovery(self.sway, self.runtime, start=lambda pid: 123).recover()
|
||||
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
|
||||
self.assertIn('FOCUS_RECOVERY_NOT_PROVEN', receipt['issues'])
|
||||
self.assertEqual(self.sway.commands, [])
|
||||
self.assertEqual(read(self.runtime/JOURNAL)['focus_conflict_observation'], first)
|
||||
|
||||
def test_equivalent_unary_paths_are_not_identity_changes(self):
|
||||
self.unary()
|
||||
# A conflict forbids IPC, but exact already-at-origin windows can be
|
||||
# recognized without stealing the user's new focus or clearing incident.
|
||||
self.recovery.record_focus_conflict({'phase': 'TEST_ONLY'})
|
||||
self.sway.commands.clear()
|
||||
receipt = Recovery(self.sway, self.runtime, start=lambda pid: 123).recover()
|
||||
self.assertEqual(receipt['windows'], [])
|
||||
durable = read(self.runtime/JOURNAL)
|
||||
paths = {row['con_id']: row for row in durable['path_reconciliations'][0]}
|
||||
self.assertEqual(paths[8], dict(con_id=8, before=[0], after=[0, 0]))
|
||||
self.assertEqual(paths[7], dict(con_id=7, before=[1], after=[1, 0]))
|
||||
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
|
||||
self.assertEqual(self.sway.commands, [])
|
||||
|
||||
def test_path_evidence_limit_refuses_without_overwriting_history(self):
|
||||
self.recovery.data['path_reconciliations'] = [[] for _ in range(4)]
|
||||
self.unary()
|
||||
self.recovery.record_focus_conflict({'phase': 'TEST_ONLY'})
|
||||
receipt = self.recovery.recover()
|
||||
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
|
||||
self.assertTrue(any(w['reason'] == 'PATH_EQUIVALENCE_NOT_PROVEN' for w in receipt['windows']))
|
||||
self.assertEqual(read(self.runtime/JOURNAL)['path_reconciliations'], [[], [], [], []])
|
||||
|
||||
def test_layout_change_with_identical_raw_paths_still_refuses(self):
|
||||
workspace(self.sway.value, '1')['layout'] = 'splitv'
|
||||
self.sway.commands.clear()
|
||||
receipt = self.recovery.recover()
|
||||
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
|
||||
self.assertTrue(any(e['reason'] == 'STRUCTURE_OR_ORDER_CHANGED' for e in receipt['windows']))
|
||||
self.assertEqual(self.sway.commands, [])
|
||||
|
||||
def test_interrupted_intention_preserves_all_evidence(self):
|
||||
self.unary()
|
||||
self.recovery.data['windows'][1]['pending'] = True
|
||||
self.recovery.data['focus_pending'] = True
|
||||
self.recovery.publish()
|
||||
intention = copy.deepcopy(self.recovery.data['intention'])
|
||||
expected = copy.deepcopy(self.recovery.data['windows'][1]['expected'])
|
||||
self.sway.commands.clear()
|
||||
receipt = Recovery(self.sway, self.runtime, start=lambda pid: 123).recover()
|
||||
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
|
||||
self.assertTrue(any(e['reason'] == 'AMBIGUOUS_INTENTION' for e in receipt['windows']))
|
||||
durable = read(self.runtime/JOURNAL)
|
||||
self.assertEqual(durable['intention'], intention)
|
||||
self.assertEqual(durable['windows'][1]['expected'], expected)
|
||||
self.assertTrue(durable['focus_pending'])
|
||||
self.assertEqual(self.sway.commands, [])
|
||||
|
||||
def test_real_identity_order_workspace_and_branch_changes_still_refuse(self):
|
||||
for mode in ('pid', 'starttime', 'app_id', 'con_id', 'order', 'workspace', 'output', 'branch', 'tabbed', 'geometry', 'floating', 'pending', 'legacy'):
|
||||
with self.subTest(mode=mode):
|
||||
tree = copy.deepcopy(self.sway.value)
|
||||
saved = copy.deepcopy(self.recovery.data)
|
||||
self.unary()
|
||||
nodes = {n['id']: n for n in walk(self.sway.value)}
|
||||
if mode == 'pid': nodes[8]['pid'] += 1
|
||||
if mode == 'starttime': self.recovery.start = lambda pid: 999
|
||||
if mode == 'app_id': nodes[8]['app_id'] = 'kitty'
|
||||
if mode == 'con_id': nodes[8]['id'] = 88
|
||||
if mode == 'order': workspace(self.sway.value, '1')['nodes'].reverse()
|
||||
if mode == 'workspace': workspace(self.sway.value, '1')['name'] = 'OTHER'
|
||||
if mode == 'output': self.sway.value['nodes'][0]['name'] = 'OTHER'
|
||||
if mode == 'branch': workspace(self.sway.value, '1')['layout'] = 'splitv'
|
||||
if mode == 'tabbed': nodes[80]['layout'] = 'tabbed'
|
||||
if mode == 'geometry':
|
||||
nodes[8].update(floating='user_on', rect=dict(x=0, y=0, width=200, height=100))
|
||||
if mode == 'floating': nodes[8]['floating'] = 'user_on'
|
||||
if mode == 'pending': self.recovery.data['windows'][1]['pending'] = True
|
||||
if mode == 'legacy': self.recovery.data.pop('structure_expected', None)
|
||||
self.recovery.record_focus_conflict({'phase': 'TEST_ONLY'})
|
||||
self.sway.commands.clear()
|
||||
receipt = self.recovery.recover()
|
||||
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
|
||||
self.assertTrue(receipt['windows'])
|
||||
self.assertEqual(self.sway.commands, [])
|
||||
self.sway.value, self.recovery.data = tree, saved
|
||||
self.recovery.start = lambda pid: 123
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
74
tests/session_v2/test_guard_protocol.py
Normal file
74
tests/session_v2/test_guard_protocol.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Watchdog réel sur socketpair/pipes possédés ; aucun signal de session."""
|
||||
import threading
|
||||
import socket
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from common import *
|
||||
from session_v2.guard import run
|
||||
from session_v2.storage import atomic, loads
|
||||
|
||||
|
||||
class GuardProtocolTests(unittest.TestCase):
|
||||
def exercise(self, messages):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
report = Path(directory)/'attempt.json'
|
||||
atomic(report, {'status': 'running', 'anchors_cleaned': 0})
|
||||
parent, child = socket.socketpair(type=socket.SOCK_SEQPACKET)
|
||||
parent.settimeout(5)
|
||||
engine_r, engine_w = os.pipe()
|
||||
anchor_r, anchor_w = os.pipe()
|
||||
writer_r, writer_w = os.pipe()
|
||||
rows, replies, errors = [], [], []
|
||||
def helper():
|
||||
# A real pipe models the quit consumed before process exit.
|
||||
rows.append(('quit', os.read(writer_r, 64)))
|
||||
os.close(anchor_w)
|
||||
worker = threading.Thread(target=helper)
|
||||
worker.start()
|
||||
config = dict(control=child.detach(), engine_fd=engine_r, anchor_fd=anchor_r,
|
||||
writer_fd=writer_w, anchor_pid=99999999, allowed=[], helpers=[],
|
||||
socket='/TEST_ONLY', report=str(report), deadline=5)
|
||||
class Sway:
|
||||
def __init__(self, path): pass
|
||||
def windows(self): return []
|
||||
def resume(*args): rows.append(('resume', None)); return True
|
||||
def guarded():
|
||||
try: run(config)
|
||||
except BaseException as exc: errors.append(exc)
|
||||
try:
|
||||
with patch('session_v2.guard.Sway', Sway), \
|
||||
patch('session_v2.guard.resume_helpers', side_effect=resume), \
|
||||
patch('session_v2.guard.signal.pidfd_send_signal'), \
|
||||
patch('session_v2.guard.resume_pending') as recovery:
|
||||
guard = threading.Thread(target=guarded)
|
||||
guard.start()
|
||||
replies.append(loads(parent.recv(4096)))
|
||||
for message in messages:
|
||||
parent.sendall(message)
|
||||
replies.append(loads(parent.recv(4096)))
|
||||
if message == b'prepare':
|
||||
self.assertEqual(replies[-1]['status'], 'prepared')
|
||||
self.assertEqual(rows, [('quit', b'quit\n')])
|
||||
if message == b'resume' and b'prepare' in messages:
|
||||
self.assertEqual(replies[-1]['status'], 'resumed')
|
||||
guard.join(5)
|
||||
self.assertFalse(guard.is_alive())
|
||||
self.assertEqual(errors, [])
|
||||
if messages == [b'prepare', b'resume', b'finish']: recovery.assert_not_called()
|
||||
finally:
|
||||
parent.close()
|
||||
worker.join(5)
|
||||
for fd in (engine_r, engine_w, anchor_r, writer_r): os.close(fd)
|
||||
if b'prepare' in messages: self.assertEqual(rows[0], ('quit', b'quit\n'))
|
||||
return replies
|
||||
|
||||
def test_prepare_quits_before_resume_and_finish_has_no_delayed_exit(self):
|
||||
replies = self.exercise([b'prepare', b'resume', b'finish'])
|
||||
self.assertEqual([r['status'] for r in replies], ['ready', 'prepared', 'resumed', 'finished'])
|
||||
|
||||
def test_resume_without_prepare_refuses_and_still_cleans_owned_helper(self):
|
||||
self.assertEqual(self.exercise([b'resume'])[-1]['status'], 'failed')
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
|
|
@ -71,7 +71,7 @@ class BoundaryTests(unittest.TestCase):
|
|||
parent['nodes'].remove(node)
|
||||
break
|
||||
stage = json.loads(operation.removeprefix('move container to workspace '))
|
||||
destination = dict(id=90, type='workspace', name=stage, nodes=[node])
|
||||
destination = dict(id=90, type='workspace', name=stage, layout='splith', nodes=[node])
|
||||
self.sway.value['nodes'][0]['nodes'].append(destination)
|
||||
for n in walk(self.sway.value):
|
||||
n['focused'] = n['id'] == focus['con_id'] if focus['con_id'] is not None else n.get('name') == focus['workspace']
|
||||
|
|
|
|||
204
tests/session_v2/test_scratchpad.py
Normal file
204
tests/session_v2/test_scratchpad.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""Scratchpad contracts against production capture/store/journal; inert IPC."""
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from common import *
|
||||
from test_prewarm import Sway
|
||||
from session_v2.snapshot import capture
|
||||
from session_v2.checkpoint import Store
|
||||
from session_v2.storage import read, private_dir
|
||||
from session_v2.ipc import walk, workspace
|
||||
from session_v2.errors import Failure
|
||||
from session_v2.recovery import Recovery, JOURNAL, observe, command_focus, validate as validate_recovery
|
||||
from session_v2.slots import assign
|
||||
from session_v2.layout import compile_plan, compile_placements
|
||||
|
||||
|
||||
def source():
|
||||
sway = Sway()
|
||||
for ws in sway.value['nodes'][0]['nodes']:
|
||||
ws['layout'] = 'splith'; ws['nodes'] = []
|
||||
sway.add('kitty', 7, focus=True)
|
||||
sway.add('kitty', 8, focus=False)
|
||||
sway.add('limusic-app', 9, focus=False)
|
||||
root = sway.value
|
||||
hidden = dict(id=80, type='workspace', name='__i3_scratch', layout='splith', nodes=[], floating_nodes=[], focused=False)
|
||||
root['nodes'].insert(0, dict(id=81, type='output', name='__i3', nodes=[hidden]))
|
||||
ws = workspace(root, '1')
|
||||
n = next(n for n in ws['nodes'] if n['id'] == 8)
|
||||
ws['nodes'].remove(n)
|
||||
n.update(scratchpad_state='fresh', floating='user_on', rect=dict(x=301,y=106,width=800,height=500))
|
||||
hidden['floating_nodes'].append(n)
|
||||
return sway
|
||||
|
||||
|
||||
class ScratchpadTests(unittest.TestCase):
|
||||
def test_capture_store_reload_and_no_internal_workspace(self):
|
||||
sway = source()
|
||||
d = capture(sway, Catalog(), None)
|
||||
self.assertEqual(d['version'], 2)
|
||||
self.assertEqual(len(d['window_slots']), 3)
|
||||
hidden = next(s for s in d['window_slots'] if s['state']['scratchpad'] == 'hidden')
|
||||
self.assertIsNone(hidden['workspace'])
|
||||
self.assertEqual(hidden['state']['geometry'], dict(x=301,y=106,width=800,height=500))
|
||||
self.assertEqual(hidden['identity_requirement'], 'interchangeable')
|
||||
self.assertEqual(d['focus'], next(s['slot_id'] for s in d['window_slots'] if s['application_id']=='kitty' and s['workspace']=='1'))
|
||||
self.assertNotIn('__i3_scratch', [o['name'] for o in d['outputs']])
|
||||
self.assertTrue(all(op.workspace != '__i3_scratch' for op in compile_plan(d,'a'*32)))
|
||||
self.assertEqual(compile_placements(d)[0]['scratchpad'], 'hidden')
|
||||
self.assertIsNone(compile_placements(d)[0]['workspace'])
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = Store(Path(tmp)); store.save('test',d); store.checkpoint(d)
|
||||
self.assertEqual(store.last(),d); self.assertEqual(store.load('test'),d)
|
||||
old = fixture(); store.save('old',old)
|
||||
self.assertEqual(store.load('old'),old)
|
||||
|
||||
def test_capture_is_deterministic_for_a_stable_tree(self):
|
||||
sway=source()
|
||||
with patch('session_v2.snapshot.time.time_ns',return_value=123):
|
||||
first=capture(sway,Catalog(),None)
|
||||
self.assertEqual(first,capture(sway,Catalog(),None))
|
||||
raw=json.dumps(first)
|
||||
self.assertNotIn('con_id',raw);self.assertNotIn('starttime',raw)
|
||||
self.assertNotIn('__i3_scratch',raw)
|
||||
|
||||
def test_shown_and_multiple_hidden(self):
|
||||
sway=source(); hidden=workspace(sway.value,'__i3_scratch'); ws=workspace(sway.value,'1')
|
||||
shown=hidden['floating_nodes'].pop(); ws.setdefault('floating_nodes',[]).append(shown)
|
||||
for n in walk(sway.value): n['focused']=n['id']==8
|
||||
d=capture(sway,Catalog(),None)
|
||||
slot=next(s for s in d['window_slots'] if s['state']['scratchpad']=='shown')
|
||||
self.assertEqual(slot['workspace'],'1'); self.assertEqual(d['focus'],slot['slot_id'])
|
||||
for n in list(ws['nodes']):
|
||||
n.update(floating='user_on',scratchpad_state='fresh',focused=False,rect=dict(x=0,y=0,width=100,height=100))
|
||||
hidden['floating_nodes'].append(n)
|
||||
ws['nodes']=[]
|
||||
d=capture(sway,Catalog(),None)
|
||||
self.assertEqual(sum(s['state']['scratchpad']=='hidden' for s in d['window_slots']),2)
|
||||
|
||||
def test_change_between_reads_and_ambiguous_focus_refuse(self):
|
||||
sway=source(); original=sway.windows
|
||||
def changed():
|
||||
result=original(); next(n for n in walk(sway.value) if n['id']==8)['scratchpad_state']='none'
|
||||
return result
|
||||
sway.windows=changed
|
||||
with self.assertRaisesRegex(Failure,'SNAPSHOT_CHANGED'):capture(sway,Catalog(),None)
|
||||
sway=source(); next(n for n in walk(sway.value) if n['id']==8)['focused']=True
|
||||
with self.assertRaisesRegex(Failure,'SNAPSHOT_FOCUS_AMBIGUOUS'):capture(sway,Catalog(),None)
|
||||
|
||||
def test_strict_versions_and_scratchpad_references(self):
|
||||
d=capture(source(),Catalog(),None)
|
||||
hidden=next(i for i,s in enumerate(d['window_slots']) if s['workspace'] is None)
|
||||
mutations=[lambda v:v.update(version=1), lambda v:v['window_slots'][hidden].update(workspace='__i3_scratch'),
|
||||
lambda v:v.update(focus=v['window_slots'][hidden]['slot_id']),
|
||||
lambda v:v['window_slots'][hidden]['state'].update(scratchpad=True),
|
||||
lambda v:v['window_slots'][hidden]['state'].update(floating=False)]
|
||||
for change in mutations:
|
||||
v=copy.deepcopy(d);change(v)
|
||||
with self.assertRaises(Failure):validate(v)
|
||||
try: import jsonschema
|
||||
except ImportError: self.skipTest('jsonschema unavailable')
|
||||
contract=json.loads((BACKEND/'session_v2/schema-v2.json').read_text())
|
||||
jsonschema.Draft202012Validator(contract).validate(d)
|
||||
|
||||
def test_runtime_visibility_assignment_never_exposes_hidden_peer(self):
|
||||
sway=source(); d=capture(sway,Catalog(),None)
|
||||
slots=[s for s in d['window_slots'] if s['application_id']=='kitty']
|
||||
nodes=[n for n in sway.windows() if n['app_id']=='kitty']
|
||||
states={n['id']:observe(sway.tree(),n,lambda pid:123) for n in nodes}
|
||||
m=assign(slots,nodes,states=states)
|
||||
hidden=next(s for s in slots if s['workspace'] is None)
|
||||
self.assertEqual(m[hidden['slot_id']],8)
|
||||
for s in slots:s['state']['scratchpad']='none'
|
||||
with self.assertRaisesRegex(Failure,'SCRATCHPAD_ASSIGNMENT_UNPROVEN'):assign(slots,nodes,states=states)
|
||||
|
||||
def test_journal_detects_identity_output_scratchpad_and_interruption(self):
|
||||
for change in ('identity','output','scratchpad','pending'):
|
||||
with self.subTest(change=change), tempfile.TemporaryDirectory() as tmp:
|
||||
sway=source(); r=Recovery(sway,tmp,'a'*32,'labfy-v2-stage-'+'a'*32,start=lambda pid:123)
|
||||
for n in sway.windows():r.admit(n)
|
||||
r.baseline({'kitty','limusic-app'})
|
||||
if change=='identity': next(n for n in walk(sway.value) if n['id']==8)['pid']=999
|
||||
elif change=='output':sway.value['nodes'][0]['name']='moved'
|
||||
elif change=='scratchpad': next(n for n in walk(sway.value) if n['id']==8)['scratchpad_state']='none'
|
||||
else:r.data['windows'][0]['pending']=True;r.data['focus_pending']=True;r.publish()
|
||||
receipt=r.recover()
|
||||
self.assertEqual(receipt['status'],'RECOVERY_REQUIRED');self.assertEqual(sway.commands,[])
|
||||
durable=read(Path(tmp)/JOURNAL,validate_recovery)
|
||||
if change=='pending':self.assertTrue(durable['focus_pending'])
|
||||
|
||||
def test_hidden_focus_is_rejected_before_command(self):
|
||||
sway=source()
|
||||
with self.assertRaisesRegex(Failure,'FOCUS_HIDDEN_SCRATCHPAD'):command_focus(sway.tree(),8,'focus')
|
||||
|
||||
def test_initial_scratchpad_add_predicts_source_workspace_focus(self):
|
||||
sway=source(); ws=workspace(sway.value,'__i3_scratch')
|
||||
n=ws['floating_nodes'].pop(); n['scratchpad_state']='none'
|
||||
workspace(sway.value,'10')['floating_nodes']=[n]
|
||||
# Actual root_scratchpad_add_container semantics: even an unfocused
|
||||
# floating leaf causes source-workspace selection. Not focus-neutral.
|
||||
self.assertEqual(command_focus(sway.tree(),8,'move scratchpad'),dict(con_id=None,workspace='10'))
|
||||
self.assertEqual(command_focus(source().tree(),8,'scratchpad show'),dict(con_id=8,workspace='1'))
|
||||
|
||||
def test_group_ancestor_is_not_admitted_by_leaf_identity(self):
|
||||
sway=source(); ws=workspace(sway.value,'__i3_scratch')
|
||||
n=ws['floating_nodes'].pop(); n['scratchpad_state']='none'
|
||||
ws['floating_nodes']=[dict(id=99,type='con',scratchpad_state='fresh',nodes=[n])]
|
||||
with self.assertRaisesRegex(Failure,'SCRATCHPAD_CONTAINER_UNSUPPORTED'):
|
||||
observe(sway.tree(),n,lambda pid:123)
|
||||
with self.assertRaisesRegex(Failure,'SCRATCHPAD_CONTAINER_UNSUPPORTED'):
|
||||
capture(sway,Catalog(),None)
|
||||
|
||||
def test_layer_shell_can_leave_no_sway_focus(self):
|
||||
# Le panneau de déconnexion peut prendre le focus Wayland et ne
|
||||
# laisser aucune feuille Sway marquée focused. L'absence est une
|
||||
# observation légitime, distincte de plusieurs candidats ambigus.
|
||||
sway = source()
|
||||
for n in walk(sway.value): n['focused'] = False
|
||||
result = capture(sway, Catalog(), None)
|
||||
self.assertIsNone(result['focus'])
|
||||
self.assertIsNone(result['focus_workspace'])
|
||||
self.assertTrue(any(s['state']['scratchpad'] == 'hidden' for s in result['window_slots']))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = Store(Path(tmp))
|
||||
store.checkpoint(result)
|
||||
self.assertEqual(store.last(), result)
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
return
|
||||
contract = json.loads((BACKEND/'session_v2/schema-v2.json').read_text())
|
||||
jsonschema.validate(result, contract)
|
||||
|
||||
def test_multiple_focused_nodes_are_still_refused(self):
|
||||
sway = source()
|
||||
for n in walk(sway.value):
|
||||
if n.get('id') == 9: n['focused'] = True
|
||||
with self.assertRaisesRegex(Failure, 'SNAPSHOT_FOCUS_AMBIGUOUS'):
|
||||
capture(sway, Catalog(), None)
|
||||
|
||||
def test_empty_focus_workspace_is_persisted(self):
|
||||
sway=source()
|
||||
for n in walk(sway.value): n['focused']=n['id']==3
|
||||
d=capture(sway,Catalog(),None)
|
||||
self.assertIsNone(d['focus']);self.assertEqual(d['focus_workspace'],'10')
|
||||
self.assertIn('10',[w['workspace'] for w in d['workspaces']])
|
||||
broken=copy.deepcopy(d);broken['focus_workspace']='absent'
|
||||
with self.assertRaises(Failure):validate(broken)
|
||||
|
||||
def test_legacy_journal_not_reinterpreted(self):
|
||||
sway=source()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
r=Recovery(sway,tmp,'a'*32,'labfy-v2-stage-'+'a'*32,start=lambda pid:123)
|
||||
r.admit(next(n for n in sway.windows() if n['id']==7))
|
||||
r.data['version']=1
|
||||
for e in r.data['windows']:
|
||||
for key in ('origin','expected'): e[key].pop('scratchpad',None)
|
||||
r.publish()
|
||||
old=Recovery(sway,tmp,start=lambda pid:123)
|
||||
self.assertNotIn('scratchpad',old.snapshot(7))
|
||||
with self.assertRaisesRegex(Failure,'LEGACY_SCRATCHPAD_UNPROVEN'):old.snapshot(8)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
|
@ -29,7 +29,7 @@ tasks = {
|
|||
'checkpoint-fixture': [sys.executable,'-B','-c',"import sys; sys.path.insert(0, 'tests/session_v2'); from login_focus import checkpoint; print(checkpoint()['focus'])"],
|
||||
'diff-check': ['git','diff','--check'],
|
||||
}
|
||||
for case in ('natural', 'launch-failure-retry', 'preserve-empty-workspace', 'preserve-foreign', 'focus-third-party', 'focus-same-client', 'unowned-map', 'pinned-map', 'map-in-ipc-gap', 'provider-placement', 'crash-mid-ipc'):
|
||||
for case in ('natural', 'launch-failure-retry', 'preserve-empty-workspace', 'preserve-foreign', 'focus-third-party', 'focus-same-client', 'unowned-map', 'pinned-map', 'map-in-ipc-gap', 'provider-placement', 'crash-mid-ipc', 'autotiling', 'focus-helper-late', 'focus-external-late', 'focus-finish-late'):
|
||||
tasks['login-'+case] = [sys.executable,'-B','tests/session_v2/login_focus.py','--case',case]
|
||||
def run(item):
|
||||
name, argv = item
|
||||
|
|
|
|||
55
tests/session_v2/validate_scratchpad.py
Normal file
55
tests/session_v2/validate_scratchpad.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reproducible isolated scratchpad validation; never uses the desktop socket."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--sway', required=True, type=Path, help='absolute headless-capable Sway executable')
|
||||
parser.add_argument('--renderer', default='pixman', choices=('pixman', 'gles2'))
|
||||
args = parser.parse_args()
|
||||
if not args.sway.is_absolute() or not args.sway.is_file() or not os.access(args.sway, os.X_OK):
|
||||
parser.error('--sway must be an existing absolute executable')
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
output = Path(tempfile.mkdtemp(prefix='labfy-scratchpad-validation-', dir='/tmp'))
|
||||
# CONTRACT : positive environment list; no graphical bus/socket, user state,
|
||||
# test override for backend imports or configuration reaches subprocesses.
|
||||
env = {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1',
|
||||
'QT_QPA_PLATFORM': 'offscreen', 'QT_QUICK_BACKEND': 'software',
|
||||
'LABFY_TEST_SWAY': str(args.sway), 'LABFY_TEST_RENDERER': args.renderer,
|
||||
'LABFY_SESSION_V2_EVIDENCE': str(output / 'evidence')}
|
||||
for key in ('HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR'):
|
||||
path = output / key
|
||||
path.mkdir(mode=0o700)
|
||||
env[key] = str(path)
|
||||
checks = {
|
||||
'backend': [sys.executable, '-B', '-m', 'unittest', 'discover', '-s', 'tests/session_v2', '-p', 'test_*.py'],
|
||||
'focus': [sys.executable, '-B', 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_focus.py'],
|
||||
'qml-python': [sys.executable, '-B', 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py'],
|
||||
'qml-qt': ['/usr/lib/qt6/bin/qmltestrunner', '-input', 'tests/session_v2/qml', '-o', '-,txt'],
|
||||
'scratchpad-runtime': [sys.executable, '-B', 'tests/session_v2/scratchpad_runtime.py'],
|
||||
'diff-check': ['git', 'diff', '--check'],
|
||||
}
|
||||
results = {}
|
||||
print(output, flush=True)
|
||||
for name, argv in checks.items():
|
||||
with (output / (name + '.log')).open('w') as log:
|
||||
try:
|
||||
result = subprocess.run(argv, cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=180)
|
||||
results[name] = result.returncode
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
log.write(str(exc) + '\n')
|
||||
results[name] = 'UNAVAILABLE_OR_TIMEOUT'
|
||||
print(name, results[name], flush=True)
|
||||
(output / 'results.json').write_text(json.dumps(results, indent=2) + '\n')
|
||||
return int(any(value != 0 for value in results.values()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
|
@ -47,7 +47,8 @@ def build(output, extension_only=False):
|
|||
for name in ("session-v2.py", "session-v2-native-host.py"):
|
||||
archive.add(SOURCE / name, arcname=name)
|
||||
for p in sorted((SOURCE / "session_v2").glob("*.py")): archive.add(p, arcname="session_v2/" + p.name)
|
||||
archive.add(SOURCE / "session_v2/schema-v1.json", arcname="session_v2/schema-v1.json")
|
||||
for version in (1, 2):
|
||||
archive.add(SOURCE / f"session_v2/schema-v{version}.json", arcname=f"session_v2/schema-v{version}.json")
|
||||
archive.add(binary, arcname="session_v2/libexec/labfy-v2-anchor")
|
||||
archive.add(ROOT / "firefox-extension/session-v2/native-host.json.in", arcname="native-host.json.in")
|
||||
manifest = {"signed": False, "installed": False, "files": {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in (binary, xpi, bundle)}}
|
||||
|
|
|
|||
Loading…
Reference in a new issue