feat(session): execute controlled Sway session restores
This commit is contained in:
parent
46b29e0f22
commit
5a14abe323
5 changed files with 1376 additions and 10 deletions
|
|
@ -1,8 +1,9 @@
|
|||
# Restore Plan V1 — STEP19A
|
||||
# Restore Plan et Executor V1 — STEP19A/STEP19B
|
||||
|
||||
## Statut et architecture
|
||||
|
||||
**STEP19A DOES NOT RESTORE.**
|
||||
**Le planner STEP19A ne restaure jamais.** L'exécution contrôlée appartient
|
||||
exclusivement au module séparé STEP19B décrit à la fin de ce document.
|
||||
|
||||
STEP19A charge un Session Snapshot V1 persistant, observe la session Sway
|
||||
actuelle avec les quatre lectures STEP18, puis produit un plan JSON. Il ne
|
||||
|
|
@ -280,6 +281,173 @@ applications_launched = 0
|
|||
snapshots_written = 0
|
||||
```
|
||||
|
||||
Avant STEP19B, aucune attente de fenêtre, aucune commande Sway mutatrice,
|
||||
aucune restauration d'état interne, aucun fallback multi-output, aucune UI
|
||||
QML et aucun comportement Spatial Canvas ne sont implémentés.
|
||||
Le planner n'effectue aucune attente de fenêtre, aucune commande Sway
|
||||
mutatrice, aucune restauration d'état interne, aucun fallback multi-output,
|
||||
aucune UI QML et aucun comportement Spatial Canvas.
|
||||
|
||||
## Executor V1 — STEP19B
|
||||
|
||||
### Frontière d'effets et CLI
|
||||
|
||||
`session_restore.py` reste le moteur pur. `session_executor.py` est la seule
|
||||
frontière autorisée à ouvrir le lock d'exécution et à lancer des argv directs.
|
||||
|
||||
```text
|
||||
Session Snapshot V1 validé
|
||||
↓
|
||||
Restore Planner pur
|
||||
↓
|
||||
Restore Plan V1 frais
|
||||
↓
|
||||
Session Executor V1
|
||||
↓
|
||||
uwsm app + Sway IPC ciblé
|
||||
```
|
||||
|
||||
La mutation nécessite une intention explicite difficile à déclencher par
|
||||
accident :
|
||||
|
||||
```bash
|
||||
session_snapshot.py apply <name> --execute
|
||||
```
|
||||
|
||||
`apply <name>` sans `--execute` retourne
|
||||
`RESTORE_EXECUTION_REQUIRES_EXPLICIT_EXECUTE` sans charger l'exécuteur. Le CLI
|
||||
n'accepte jamais un fichier Restore Plan comme autorité. Chaque apply exécute
|
||||
obligatoirement :
|
||||
|
||||
```text
|
||||
load snapshot → collect live → build fresh plan → validate → preflight → execute
|
||||
```
|
||||
|
||||
Le snapshot est hashé avant et après l'opération. Une différence produit un
|
||||
échec explicite ; l'exécuteur ne possède aucune API d'écriture de snapshot.
|
||||
|
||||
### Capability gate intégrale
|
||||
|
||||
Avant le premier lancement ou la première commande Sway, l'exécuteur examine
|
||||
toutes les actions. Le sous-ensemble STEP19B est :
|
||||
|
||||
```text
|
||||
reuse-window no-op revalidé
|
||||
launch-application uwsm app + attente/rematching
|
||||
skip-autostart-managed no-op explicite, propriétaire inchangé
|
||||
move-to-workspace workspace numérique seulement
|
||||
restore-floating enable/disable déterministe
|
||||
restore-fullscreen modes 0 et 1 seulement
|
||||
restore-focus toujours dernière mutation
|
||||
```
|
||||
|
||||
Ces actions bloquent l'ensemble du plan avec
|
||||
`RESTORE_BLOCKED_UNSUPPORTED_ACTION` et une liste précise, sans aucune mutation :
|
||||
|
||||
```text
|
||||
restore-tree-position
|
||||
restore-scratchpad-hidden
|
||||
restore-scratchpad-visible
|
||||
manual-required
|
||||
toute action inconnue
|
||||
```
|
||||
|
||||
Un matching runtime `ambiguous`, un workspace non numérique et un mode
|
||||
fullscreen non prouvé bloquent eux aussi le plan. Une fenêtre absente n'a pas
|
||||
encore de position d'arbre observable ; le planner n'émet donc une différence
|
||||
`restore-tree-position` que pour une fenêtre déjà matchée. STEP19C pourra
|
||||
replanifier l'arbre après apparition.
|
||||
|
||||
### Lock privé
|
||||
|
||||
Un apply détient un `flock(LOCK_EX|LOCK_NB)` sur `.restore.lock` pendant toute
|
||||
la séquence load/plan/execute/rehash. Le fichier est régulier, ouvert sans
|
||||
suivre les symlinks et forcé à `0600` dans le répertoire sessions `0700`. Le
|
||||
fichier peut persister ; la fermeture du FD libère le lock. Aucun daemon ou
|
||||
scheduler supplémentaire n'est créé.
|
||||
|
||||
### DesktopEntry et lancement UWSM
|
||||
|
||||
`launch.argv` dans le plan reste purement descriptif et n'est jamais exécuté.
|
||||
Pour chaque lancement, l'exécuteur vérifie :
|
||||
|
||||
```text
|
||||
action = launch-application
|
||||
backend = uwsm-app-desktop-entry
|
||||
classification = user-application
|
||||
restore_identity.confidence = exact
|
||||
DesktopEntry ID syntaxiquement bornée
|
||||
résolution exacte identique dans les sources XDG courantes
|
||||
```
|
||||
|
||||
Il reconstruit ensuite lui-même exactement :
|
||||
|
||||
```python
|
||||
["uwsm", "app", "--", desktop_entry]
|
||||
```
|
||||
|
||||
Il n'utilise ni shell, ni `Sway exec`, ni `gtk-launch`, ni `gio launch`. Comme
|
||||
`uwsm app` peut rester attaché pendant toute la vie de l'application, le
|
||||
processus est lancé avec un argv direct, sorties vers `DEVNULL` et une nouvelle
|
||||
session de processus. Son PID et son exit status ne prouvent jamais le succès.
|
||||
|
||||
### Apparition et matching post-launch
|
||||
|
||||
L'exécuteur conserve l'inventaire des `con_id` avant lancement, puis poll
|
||||
`get_tree` via la collecte Session Snapshot V1 pendant au plus 10 secondes.
|
||||
Seules les nouvelles fenêtres sont candidates. Il réutilise directement
|
||||
`session_restore.match_windows()` ; il ne possède aucun second matcher.
|
||||
|
||||
Zéro candidate compatible conduit à `RESTORE_LAUNCH_TIMEOUT`. Plusieurs
|
||||
candidates indiscernables conduisent à `RESTORE_LAUNCH_AMBIGUOUS`. Le PID du
|
||||
processus lancé n'intervient pas, ce qui couvre aussi une application qui
|
||||
demanderait à une instance existante de créer une nouvelle fenêtre.
|
||||
|
||||
### Revalidation runtime et commandes ciblées
|
||||
|
||||
Avant chaque mutation, une nouvelle collecte doit retrouver le même `con_id`,
|
||||
une identité application compatible et un workspace connu. Cela interdit de
|
||||
cibler silencieusement un `con_id` disparu ou recyclé. Après la commande, une
|
||||
attente bornée à une seconde relit l'arbre jusqu'à observer la postcondition,
|
||||
en revalidant l'identité à chaque lecture. Elle absorbe uniquement le délai de
|
||||
visibilité IPC ; son expiration reste un échec critique.
|
||||
|
||||
Les workspaces exécutables sont des chaînes numériques canoniques positives.
|
||||
Les noms libres restent hors périmètre afin qu'aucun quoting Sway non prouvé
|
||||
ne devienne une surface d'injection. Un déplacement reconstruit uniquement :
|
||||
|
||||
```text
|
||||
[con_id=<entier validé>] move container to workspace number <entier validé>
|
||||
```
|
||||
|
||||
Floating utilise exclusivement `floating enable` ou `floating disable`.
|
||||
L'audit de `sway(5)` 1.12.0 prouve `fullscreen enable|disable [global]` : V1
|
||||
supporte `fullscreen_mode=0` avec `fullscreen disable` et le mode workspace
|
||||
`fullscreen_mode=1` avec `fullscreen enable`. Le mode global et toute autre
|
||||
valeur sont bloqués. Aucun état ne recourt à `toggle`.
|
||||
|
||||
Le focus utilise `[con_id=<id>] focus`, après la même revalidation, et reste la
|
||||
dernière mutation. La disparition de la cible ne focalise jamais une autre
|
||||
fenêtre par défaut.
|
||||
|
||||
### Rapport, arrêt et absence de rollback magique
|
||||
|
||||
Chaque action du rapport possède `pending`, `success`, `skipped`, `failed` ou
|
||||
`blocked`, avec un `reason`. L'exécution s'arrête à la première divergence
|
||||
critique et laisse les actions suivantes `pending`.
|
||||
|
||||
Les lancements et mutations Sway ne sont pas transactionnels. Executor V1 ne
|
||||
promet aucun rollback global : sa politique est un preflight total strict,
|
||||
des commandes ciblées, une vérification après chaque commande, puis un arrêt
|
||||
immédiat au premier écart.
|
||||
|
||||
### Validation contrôlée STEP19B
|
||||
|
||||
La validation réelle utilise exclusivement une Kitty créée pour le test sur
|
||||
un workspace numérique libre. Le scénario A réutilise la fenêtre existante et
|
||||
la replace par `con_id`, sans nouveau lancement. Le scénario B ferme seulement
|
||||
cette fenêtre, lance `kitty.desktop` via UWSM, observe une nouvelle fenêtre,
|
||||
la rematche et la replace. Le cleanup ferme uniquement le nouveau `con_id`,
|
||||
supprime uniquement le snapshot de validation et restaure le focus utilisateur
|
||||
initial lorsqu'il existe encore.
|
||||
|
||||
Les fenêtres non marquées `TEST_ONLY` sont comparées avant/après sur
|
||||
`con_id`, workspace, floating et fullscreen. L'arbre complet, le scratchpad
|
||||
avancé, le Spatial Canvas et toute UI QML restent hors STEP19B.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,642 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Exécuter le sous-ensemble contrôlé STEP19B d'un Restore Plan V1 frais."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import copy
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any, Callable, Iterator, Mapping, Sequence
|
||||
|
||||
|
||||
REPORT_SCHEMA = "labfy.sway.restore-execution"
|
||||
REPORT_VERSION = 1
|
||||
RESTORE_LOCK_FILENAME = ".restore.lock"
|
||||
LAUNCH_BACKEND = "uwsm-app-desktop-entry"
|
||||
DEFAULT_WINDOW_TIMEOUT_SECONDS = 10.0
|
||||
POLL_INTERVAL_SECONDS = 0.1
|
||||
POSTCONDITION_TIMEOUT_SECONDS = 1.0
|
||||
SUPPORTED_ACTIONS = {
|
||||
"reuse-window",
|
||||
"launch-application",
|
||||
"skip-autostart-managed",
|
||||
"move-to-workspace",
|
||||
"restore-floating",
|
||||
"restore-fullscreen",
|
||||
"restore-focus",
|
||||
}
|
||||
UNSUPPORTED_ACTIONS = {
|
||||
"restore-tree-position",
|
||||
"restore-scratchpad-hidden",
|
||||
"restore-scratchpad-visible",
|
||||
"manual-required",
|
||||
}
|
||||
NUMERIC_WORKSPACE = re.compile(r"\A[1-9][0-9]*\Z")
|
||||
DESKTOP_ENTRY_ID = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]*\.desktop\Z")
|
||||
|
||||
|
||||
class RestoreExecutionError(RuntimeError):
|
||||
"""Échec contrôlé portant un code stable et un motif auditables."""
|
||||
|
||||
def __init__(self, code: str, reason: str):
|
||||
super().__init__(f"{code}: {reason}")
|
||||
self.code = code
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def snapshot_digest(snapshot: Mapping[str, Any]) -> str:
|
||||
"""Hasher le document chargé, indépendamment de son formatage JSON sur disque."""
|
||||
payload = json.dumps(snapshot, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _default_runner(argv: Sequence[str], timeout: float) -> Any:
|
||||
"""CONTRACT: exécuter un argv construit localement, sans interpréteur shell."""
|
||||
if argv and argv[0] == "uwsm":
|
||||
# WHY: uwsm app peut rester attaché pendant toute la vie de
|
||||
# l'application. Attendre son exit bloquerait avant même le polling
|
||||
# Sway ; son PID n'est en outre jamais l'identité de la future vue.
|
||||
# CONTRACT: l'enfant direct reçoit uniquement l'argv allowlisté, sans
|
||||
# shell et sans capture bornée à gérer. Le CLI STEP19B est éphémère.
|
||||
# INVARIANT: seul le matching d'une nouvelle fenêtre prouve le succès.
|
||||
return subprocess.Popen( # type: ignore[return-value]
|
||||
list(argv),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
return subprocess.run(
|
||||
list(argv),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _direct_uwsm_command(desktop_entry: str) -> list[str]:
|
||||
"""Reconstruire l'unique backend autorisé sans consulter launch.argv."""
|
||||
return ["uwsm", "app", "--", desktop_entry]
|
||||
|
||||
|
||||
def _direct_sway_command(con_id: int, operation: str) -> list[str]:
|
||||
"""Cibler une observation runtime validée ; operation est toujours une constante locale."""
|
||||
return ["swaymsg", "-r", f"[con_id={con_id}] {operation}"]
|
||||
|
||||
|
||||
def _source_windows(source: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]:
|
||||
return {
|
||||
str(window.get("window_id")): window
|
||||
for window in source.get("windows", [])
|
||||
if isinstance(window, Mapping)
|
||||
}
|
||||
|
||||
|
||||
def _runtime_con_id(window: Mapping[str, Any]) -> int | None:
|
||||
runtime = window.get("runtime")
|
||||
runtime = runtime if isinstance(runtime, Mapping) else {}
|
||||
value = runtime.get("con_id")
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None
|
||||
|
||||
|
||||
def _find_con_id(live: Mapping[str, Any], con_id: int) -> Mapping[str, Any] | None:
|
||||
return next(
|
||||
(
|
||||
window
|
||||
for window in live.get("windows", [])
|
||||
if isinstance(window, Mapping) and _runtime_con_id(window) == con_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _compatible_match(
|
||||
planner_module: Any,
|
||||
saved: Mapping[str, Any],
|
||||
candidate: Mapping[str, Any],
|
||||
) -> bool:
|
||||
matches, unmatched = planner_module.match_windows([saved], [candidate])
|
||||
saved_id = str(saved.get("window_id"))
|
||||
evidence = matches.get(saved_id)
|
||||
return not unmatched and evidence is not None and evidence.get("confidence") != "ambiguous"
|
||||
|
||||
|
||||
def _revalidate_runtime(
|
||||
source_window: Mapping[str, Any],
|
||||
con_id: int,
|
||||
*,
|
||||
collector: Callable[[], dict[str, Any]],
|
||||
planner_module: Any,
|
||||
) -> tuple[Mapping[str, Any], dict[str, Any]]:
|
||||
"""Refuser disparition, recyclage d'id, identité divergente ou workspace inconnu."""
|
||||
live = collector()
|
||||
candidate = _find_con_id(live, con_id)
|
||||
if candidate is None:
|
||||
raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", f"con_id disparu: {con_id}")
|
||||
if not _compatible_match(planner_module, source_window, candidate):
|
||||
raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", f"identité modifiée pour con_id={con_id}")
|
||||
if not isinstance(candidate.get("workspace"), str) or not candidate.get("workspace"):
|
||||
raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", f"workspace inconnu pour con_id={con_id}")
|
||||
return candidate, live
|
||||
|
||||
|
||||
def _validate_desktop_entry(
|
||||
saved: Mapping[str, Any],
|
||||
action: Mapping[str, Any],
|
||||
*,
|
||||
desktop_entries: Sequence[Mapping[str, str | None]],
|
||||
resolve_desktop_entry: Callable[..., dict[str, Any]],
|
||||
) -> str:
|
||||
"""Recalculer l'identité exacte contre les sources XDG actuelles."""
|
||||
desktop_entry = action.get("desktop_entry")
|
||||
launch = action.get("launch")
|
||||
launch = launch if isinstance(launch, Mapping) else {}
|
||||
classification = saved.get("classification")
|
||||
classification = classification if isinstance(classification, Mapping) else {}
|
||||
identity = saved.get("restore_identity")
|
||||
identity = identity if isinstance(identity, Mapping) else {}
|
||||
if (
|
||||
not isinstance(desktop_entry, str)
|
||||
or DESKTOP_ENTRY_ID.fullmatch(desktop_entry) is None
|
||||
or launch.get("backend") != LAUNCH_BACKEND
|
||||
or classification.get("category") != "user-application"
|
||||
or identity.get("confidence") != "exact"
|
||||
or identity.get("desktop_entry") != desktop_entry
|
||||
):
|
||||
raise RestoreExecutionError(
|
||||
"RESTORE_DESKTOP_ENTRY_REJECTED",
|
||||
f"identité de lancement non exacte: {action.get('source_window')}",
|
||||
)
|
||||
xwayland = saved.get("xwayland")
|
||||
xwayland = xwayland if isinstance(xwayland, Mapping) else {}
|
||||
current = resolve_desktop_entry(
|
||||
saved.get("app_id"),
|
||||
xwayland,
|
||||
saved.get("executable_basename"),
|
||||
desktop_entries,
|
||||
)
|
||||
if current.get("confidence") != "exact" or current.get("desktop_entry") != desktop_entry:
|
||||
raise RestoreExecutionError(
|
||||
"RESTORE_DESKTOP_ENTRY_REJECTED",
|
||||
f"DesktopEntry absente, ambiguë ou devenue incompatible: {desktop_entry}",
|
||||
)
|
||||
return desktop_entry
|
||||
|
||||
|
||||
def _blocking_finding(action: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
action_type = action.get("action")
|
||||
action_id = action.get("action_id")
|
||||
if action_type not in SUPPORTED_ACTIONS:
|
||||
return {
|
||||
"action_id": action_id,
|
||||
"action": action_type,
|
||||
"reason": action.get("reason") or "unsupported-action-step19b",
|
||||
}
|
||||
if action_type == "reuse-window" and action.get("confidence") == "ambiguous":
|
||||
return {
|
||||
"action_id": action_id,
|
||||
"action": action_type,
|
||||
"reason": "ambiguous-runtime-match",
|
||||
}
|
||||
if action_type == "move-to-workspace":
|
||||
workspace = action.get("desired_workspace")
|
||||
if not isinstance(workspace, str) or NUMERIC_WORKSPACE.fullmatch(workspace) is None:
|
||||
return {
|
||||
"action_id": action_id,
|
||||
"action": action_type,
|
||||
"reason": "unsupported-non-numeric-workspace",
|
||||
"desired_workspace": workspace,
|
||||
}
|
||||
if action_type == "restore-fullscreen" and action.get("desired_fullscreen_mode") not in {0, 1}:
|
||||
return {
|
||||
"action_id": action_id,
|
||||
"action": action_type,
|
||||
"reason": "unsupported-fullscreen-mode",
|
||||
"desired_fullscreen_mode": action.get("desired_fullscreen_mode"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def capability_gate(
|
||||
source: Mapping[str, Any],
|
||||
plan: Mapping[str, Any],
|
||||
*,
|
||||
planner_module: Any,
|
||||
desktop_entries: Sequence[Mapping[str, str | None]],
|
||||
resolve_desktop_entry: Callable[..., dict[str, Any]],
|
||||
) -> None:
|
||||
"""Examiner tout le plan avant la première mutation, sans exception partielle."""
|
||||
windows = _source_windows(source)
|
||||
findings: list[dict[str, Any]] = []
|
||||
for action in plan.get("actions", []):
|
||||
if not isinstance(action, Mapping):
|
||||
findings.append({"action_id": None, "action": None, "reason": "invalid-action"})
|
||||
continue
|
||||
finding = _blocking_finding(action)
|
||||
if finding is not None:
|
||||
findings.append(finding)
|
||||
continue
|
||||
saved = windows.get(str(action.get("source_window")))
|
||||
if saved is None:
|
||||
findings.append({
|
||||
"action_id": action.get("action_id"),
|
||||
"action": action.get("action"),
|
||||
"reason": "source-window-missing",
|
||||
})
|
||||
continue
|
||||
if action.get("action") == "launch-application":
|
||||
try:
|
||||
_validate_desktop_entry(
|
||||
saved,
|
||||
action,
|
||||
desktop_entries=desktop_entries,
|
||||
resolve_desktop_entry=resolve_desktop_entry,
|
||||
)
|
||||
except RestoreExecutionError as exc:
|
||||
findings.append({
|
||||
"action_id": action.get("action_id"),
|
||||
"action": action.get("action"),
|
||||
"reason": exc.reason,
|
||||
})
|
||||
if action.get("action") == "reuse-window":
|
||||
live_window = action.get("live_window")
|
||||
if not isinstance(live_window, Mapping) or _runtime_con_id(live_window) is None:
|
||||
findings.append({
|
||||
"action_id": action.get("action_id"),
|
||||
"action": action.get("action"),
|
||||
"reason": "missing-runtime-con-id",
|
||||
})
|
||||
try:
|
||||
planner_module.validate_restore_plan(plan)
|
||||
except Exception as exc:
|
||||
findings.append({"action_id": None, "action": None, "reason": f"invalid-plan: {exc}"})
|
||||
if findings:
|
||||
raise RestoreExecutionError(
|
||||
"RESTORE_BLOCKED_UNSUPPORTED_ACTION",
|
||||
json.dumps(findings, ensure_ascii=False, sort_keys=True),
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def restore_lock(directory: Path) -> Iterator[None]:
|
||||
"""Détenir un flock exclusif non bloquant sur un fichier privé persistant."""
|
||||
directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
directory_flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
directory_fd = os.open(directory, directory_flags)
|
||||
except OSError as exc:
|
||||
raise RestoreExecutionError("RESTORE_LOCK_UNSAFE", f"répertoire sessions non sûr: {exc}") from exc
|
||||
if stat.S_IMODE(os.fstat(directory_fd).st_mode) != 0o700:
|
||||
os.close(directory_fd)
|
||||
raise RestoreExecutionError("RESTORE_LOCK_UNSAFE", "répertoire sessions non privé")
|
||||
flags = os.O_RDWR | os.O_CREAT | os.O_CLOEXEC
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
lock_fd = -1
|
||||
try:
|
||||
try:
|
||||
lock_fd = os.open(RESTORE_LOCK_FILENAME, flags, 0o600, dir_fd=directory_fd)
|
||||
except OSError as exc:
|
||||
raise RestoreExecutionError("RESTORE_LOCK_UNSAFE", f"restore lock non sûr: {exc}") from exc
|
||||
info = os.fstat(lock_fd)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise RestoreExecutionError("RESTORE_LOCK_UNSAFE", "restore lock non régulier")
|
||||
os.fchmod(lock_fd, 0o600)
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
raise RestoreExecutionError("RESTORE_ALREADY_RUNNING", "une restauration est déjà active") from exc
|
||||
yield
|
||||
finally:
|
||||
if lock_fd >= 0:
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(lock_fd)
|
||||
os.close(directory_fd)
|
||||
|
||||
|
||||
def _parse_sway_success(completed: Any) -> None:
|
||||
try:
|
||||
payload = json.loads(completed.stdout)
|
||||
except (AttributeError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise RestoreExecutionError("RESTORE_SWAY_COMMAND_FAILED", "réponse swaymsg JSON invalide") from exc
|
||||
if not isinstance(payload, list) or not payload or not all(
|
||||
isinstance(item, Mapping) and item.get("success") is True for item in payload
|
||||
):
|
||||
raise RestoreExecutionError("RESTORE_SWAY_COMMAND_FAILED", f"commande refusée: {payload!r}")
|
||||
|
||||
|
||||
def _wait_for_launched_window(
|
||||
saved: Mapping[str, Any],
|
||||
before_ids: set[int],
|
||||
*,
|
||||
collector: Callable[[], dict[str, Any]],
|
||||
planner_module: Any,
|
||||
timeout: float,
|
||||
monotonic: Callable[[], float],
|
||||
sleeper: Callable[[float], None],
|
||||
) -> tuple[int, Mapping[str, Any]]:
|
||||
deadline = monotonic() + timeout
|
||||
while True:
|
||||
live = collector()
|
||||
candidates = [
|
||||
window
|
||||
for window in live.get("windows", [])
|
||||
if isinstance(window, Mapping)
|
||||
and _runtime_con_id(window) is not None
|
||||
and _runtime_con_id(window) not in before_ids
|
||||
and _compatible_match(planner_module, saved, window)
|
||||
]
|
||||
if candidates:
|
||||
matches, _unmatched = planner_module.match_windows([saved], candidates)
|
||||
match = matches.get(str(saved.get("window_id")))
|
||||
if match is None:
|
||||
pass
|
||||
elif match.get("confidence") == "ambiguous":
|
||||
raise RestoreExecutionError(
|
||||
"RESTORE_LAUNCH_AMBIGUOUS",
|
||||
f"plusieurs nouvelles fenêtres indiscernables: {saved.get('window_id')}",
|
||||
)
|
||||
else:
|
||||
chosen = candidates[int(match["live_index"])]
|
||||
con_id = _runtime_con_id(chosen)
|
||||
if con_id is not None:
|
||||
return con_id, chosen
|
||||
if monotonic() >= deadline:
|
||||
raise RestoreExecutionError(
|
||||
"RESTORE_LAUNCH_TIMEOUT",
|
||||
f"aucune nouvelle fenêtre compatible: {saved.get('window_id')}",
|
||||
)
|
||||
sleeper(POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def _action_status(action: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"action_id": action.get("action_id"),
|
||||
"action": action.get("action"),
|
||||
"source_window": action.get("source_window"),
|
||||
"status": "pending",
|
||||
"reason": None,
|
||||
}
|
||||
|
||||
|
||||
def _wait_for_postcondition(
|
||||
saved: Mapping[str, Any],
|
||||
con_id: int,
|
||||
predicate: Callable[[Mapping[str, Any]], bool],
|
||||
failure_reason: str,
|
||||
*,
|
||||
collector: Callable[[], dict[str, Any]],
|
||||
planner_module: Any,
|
||||
monotonic: Callable[[], float],
|
||||
sleeper: Callable[[float], None],
|
||||
) -> Mapping[str, Any]:
|
||||
"""Attendre brièvement la visibilité IPC d'une mutation déjà acceptée."""
|
||||
deadline = monotonic() + POSTCONDITION_TIMEOUT_SECONDS
|
||||
while True:
|
||||
candidate, _live = _revalidate_runtime(
|
||||
saved,
|
||||
con_id,
|
||||
collector=collector,
|
||||
planner_module=planner_module,
|
||||
)
|
||||
if predicate(candidate):
|
||||
return candidate
|
||||
if monotonic() >= deadline:
|
||||
raise RestoreExecutionError("RESTORE_POSTCONDITION_FAILED", failure_reason)
|
||||
sleeper(POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def execute_fresh_plan(
|
||||
source: Mapping[str, Any],
|
||||
plan: Mapping[str, Any],
|
||||
*,
|
||||
collector: Callable[[], dict[str, Any]],
|
||||
planner_module: Any,
|
||||
desktop_entries: Sequence[Mapping[str, str | None]],
|
||||
resolve_desktop_entry: Callable[..., dict[str, Any]],
|
||||
runner: Callable[[Sequence[str], float], Any] = _default_runner,
|
||||
timeout: float = DEFAULT_WINDOW_TIMEOUT_SECONDS,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
sleeper: Callable[[float], None] = time.sleep,
|
||||
) -> dict[str, Any]:
|
||||
"""Appliquer séquentiellement un plan déjà préflighté et arrêter au premier écart."""
|
||||
capability_gate(
|
||||
source,
|
||||
plan,
|
||||
planner_module=planner_module,
|
||||
desktop_entries=desktop_entries,
|
||||
resolve_desktop_entry=resolve_desktop_entry,
|
||||
)
|
||||
windows = _source_windows(source)
|
||||
statuses = [_action_status(action) for action in plan["actions"]]
|
||||
runtime_by_source: dict[str, int] = {}
|
||||
initial_ids = {
|
||||
con_id
|
||||
for window in plan.get("live", {}).get("windows", [])
|
||||
if isinstance(window, Mapping)
|
||||
for con_id in [_runtime_con_id(window)]
|
||||
if con_id is not None
|
||||
}
|
||||
applications_launched = 0
|
||||
mutating_commands = 0
|
||||
stopped_reason: str | None = None
|
||||
|
||||
for action, status in zip(plan["actions"], statuses):
|
||||
action_type = action["action"]
|
||||
source_id = str(action["source_window"])
|
||||
saved = windows[source_id]
|
||||
try:
|
||||
if action_type == "reuse-window":
|
||||
con_id = _runtime_con_id(action["live_window"])
|
||||
assert con_id is not None
|
||||
_revalidate_runtime(saved, con_id, collector=collector, planner_module=planner_module)
|
||||
runtime_by_source[source_id] = con_id
|
||||
status.update(status="success", reason="fenêtre live réutilisée et revalidée")
|
||||
elif action_type == "skip-autostart-managed":
|
||||
status.update(status="skipped", reason="lancement possédé par sway-autostart")
|
||||
elif action_type == "launch-application":
|
||||
desktop_entry = _validate_desktop_entry(
|
||||
saved,
|
||||
action,
|
||||
desktop_entries=desktop_entries,
|
||||
resolve_desktop_entry=resolve_desktop_entry,
|
||||
)
|
||||
# CONTRACT: l'inventaire de référence est pris immédiatement
|
||||
# avant le lancement, et non réutilisé depuis le plan. Toute
|
||||
# apparition/disparition imprévue depuis le plan force l'arrêt.
|
||||
before_launch = collector()
|
||||
before_launch_ids = {
|
||||
con_id
|
||||
for window in before_launch.get("windows", [])
|
||||
if isinstance(window, Mapping)
|
||||
for con_id in [_runtime_con_id(window)]
|
||||
if con_id is not None
|
||||
}
|
||||
expected_ids = initial_ids | set(runtime_by_source.values())
|
||||
if before_launch_ids != expected_ids:
|
||||
raise RestoreExecutionError(
|
||||
"RESTORE_RUNTIME_DIVERGED",
|
||||
"inventaire con_id modifié avant lancement",
|
||||
)
|
||||
completed = runner(_direct_uwsm_command(desktop_entry), timeout)
|
||||
if getattr(completed, "returncode", 0) not in {None, 0}:
|
||||
raise RestoreExecutionError("RESTORE_LAUNCH_FAILED", f"uwsm a retourné {completed.returncode}")
|
||||
applications_launched += 1
|
||||
con_id, _window = _wait_for_launched_window(
|
||||
saved,
|
||||
before_launch_ids,
|
||||
collector=collector,
|
||||
planner_module=planner_module,
|
||||
timeout=timeout,
|
||||
monotonic=monotonic,
|
||||
sleeper=sleeper,
|
||||
)
|
||||
runtime_by_source[source_id] = con_id
|
||||
status.update(status="success", reason=f"nouvelle fenêtre compatible con_id={con_id}")
|
||||
else:
|
||||
con_id = runtime_by_source.get(source_id)
|
||||
if con_id is None:
|
||||
raise RestoreExecutionError(
|
||||
"RESTORE_RUNTIME_DIVERGED",
|
||||
f"aucun con_id validé pour {source_id}",
|
||||
)
|
||||
_revalidate_runtime(saved, con_id, collector=collector, planner_module=planner_module)
|
||||
if action_type == "move-to-workspace":
|
||||
workspace = str(int(action["desired_workspace"]))
|
||||
operation = f"move container to workspace number {workspace}"
|
||||
elif action_type == "restore-floating":
|
||||
operation = "floating enable" if action["desired_floating"] else "floating disable"
|
||||
elif action_type == "restore-fullscreen":
|
||||
operation = "fullscreen enable" if action["desired_fullscreen_mode"] == 1 else "fullscreen disable"
|
||||
else:
|
||||
operation = "focus"
|
||||
completed = runner(_direct_sway_command(con_id, operation), 5.0)
|
||||
_parse_sway_success(completed)
|
||||
mutating_commands += 1
|
||||
if action_type == "move-to-workspace":
|
||||
predicate = lambda item: item.get("workspace") == workspace
|
||||
failure_reason = "workspace cible non atteint"
|
||||
elif action_type == "restore-floating":
|
||||
predicate = lambda item: bool(item.get("floating")) == bool(action["desired_floating"])
|
||||
failure_reason = "état floating non atteint"
|
||||
elif action_type == "restore-fullscreen":
|
||||
predicate = lambda item: int(item.get("fullscreen_mode") or 0) == action["desired_fullscreen_mode"]
|
||||
failure_reason = "état fullscreen non atteint"
|
||||
else:
|
||||
predicate = lambda item: bool(item.get("focused"))
|
||||
failure_reason = "focus cible non atteint"
|
||||
_wait_for_postcondition(
|
||||
saved,
|
||||
con_id,
|
||||
predicate,
|
||||
failure_reason,
|
||||
collector=collector,
|
||||
planner_module=planner_module,
|
||||
monotonic=monotonic,
|
||||
sleeper=sleeper,
|
||||
)
|
||||
status.update(status="success", reason="postcondition Sway confirmée")
|
||||
except (OSError, subprocess.SubprocessError, RestoreExecutionError, AssertionError) as exc:
|
||||
if isinstance(exc, RestoreExecutionError):
|
||||
stopped_reason = str(exc)
|
||||
else:
|
||||
stopped_reason = f"RESTORE_EXTERNAL_COMMAND_FAILED: {exc}"
|
||||
status.update(status="failed", reason=stopped_reason)
|
||||
break
|
||||
|
||||
return {
|
||||
"actions": statuses,
|
||||
"applications_launched": applications_launched,
|
||||
"mutating_commands_executed": mutating_commands,
|
||||
"status": "failed" if stopped_reason else "success",
|
||||
"reason": stopped_reason,
|
||||
"runtime_windows": dict(sorted(runtime_by_source.items())),
|
||||
}
|
||||
|
||||
|
||||
def apply_session(
|
||||
name: str,
|
||||
autostart_path: Path | None,
|
||||
*,
|
||||
sessions_path: Path,
|
||||
snapshot_loader: Callable[[str], dict[str, Any]],
|
||||
live_collector: Callable[[Path | None], dict[str, Any]],
|
||||
planner_module: Any,
|
||||
desktop_loader: Callable[[], Sequence[Mapping[str, str | None]]],
|
||||
resolve_desktop_entry: Callable[..., dict[str, Any]],
|
||||
runner: Callable[[Sequence[str], float], Any] = _default_runner,
|
||||
timeout: float = DEFAULT_WINDOW_TIMEOUT_SECONDS,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
sleeper: Callable[[float], None] = time.sleep,
|
||||
) -> dict[str, Any]:
|
||||
"""Lock, snapshot frais, live frais, plan frais, gate puis exécution contrôlée."""
|
||||
report: dict[str, Any] = {
|
||||
"schema": REPORT_SCHEMA,
|
||||
"version": REPORT_VERSION,
|
||||
"source_session": name,
|
||||
"status": "blocked",
|
||||
"reason": None,
|
||||
"snapshot_hash_before": None,
|
||||
"snapshot_hash_after": None,
|
||||
"snapshot_unchanged": False,
|
||||
"plan": None,
|
||||
"actions": [],
|
||||
"applications_launched": 0,
|
||||
"mutating_commands_executed": 0,
|
||||
"rollback": "not-transactional-stop-on-first-critical-divergence",
|
||||
}
|
||||
try:
|
||||
with restore_lock(sessions_path):
|
||||
source = snapshot_loader(name)
|
||||
report["snapshot_hash_before"] = snapshot_digest(source)
|
||||
live = live_collector(autostart_path)
|
||||
plan = planner_module.build_restore_plan(source, live)
|
||||
report["plan"] = copy.deepcopy(plan)
|
||||
result = execute_fresh_plan(
|
||||
source,
|
||||
plan,
|
||||
collector=lambda: live_collector(autostart_path),
|
||||
planner_module=planner_module,
|
||||
desktop_entries=desktop_loader(),
|
||||
resolve_desktop_entry=resolve_desktop_entry,
|
||||
runner=runner,
|
||||
timeout=timeout,
|
||||
monotonic=monotonic,
|
||||
sleeper=sleeper,
|
||||
)
|
||||
report.update(result)
|
||||
after = snapshot_loader(name)
|
||||
report["snapshot_hash_after"] = snapshot_digest(after)
|
||||
report["snapshot_unchanged"] = report["snapshot_hash_after"] == report["snapshot_hash_before"]
|
||||
if not report["snapshot_unchanged"]:
|
||||
report["status"] = "failed"
|
||||
report["reason"] = "RESTORE_SNAPSHOT_CHANGED: snapshot source modifié pendant apply"
|
||||
except RestoreExecutionError as exc:
|
||||
report["status"] = "blocked"
|
||||
report["reason"] = str(exc)
|
||||
if isinstance(report.get("plan"), Mapping):
|
||||
report["actions"] = []
|
||||
for action in report["plan"].get("actions", []):
|
||||
status = _action_status(action)
|
||||
finding = _blocking_finding(action)
|
||||
if finding is not None:
|
||||
status.update(status="blocked", reason=finding["reason"])
|
||||
report["actions"].append(status)
|
||||
if report["snapshot_hash_before"] is not None:
|
||||
after = snapshot_loader(name)
|
||||
report["snapshot_hash_after"] = snapshot_digest(after)
|
||||
report["snapshot_unchanged"] = report["snapshot_hash_after"] == report["snapshot_hash_before"]
|
||||
return report
|
||||
|
|
@ -498,7 +498,14 @@ def build_restore_plan(source: Mapping[str, Any], live: Mapping[str, Any]) -> di
|
|||
|
||||
desired_placement = _placement(source, saved)
|
||||
current_placement = _placement(live, live_window) if live_window else None
|
||||
if current_placement != desired_placement:
|
||||
# WHY: une fenêtre absente n'a aucune position live à comparer. Émettre
|
||||
# immédiatement restore-tree-position bloquerait tout lancement STEP19B,
|
||||
# même pour une simple racine de workspace. STEP19C pourra replanifier
|
||||
# la topologie une fois la nouvelle fenêtre réellement observable.
|
||||
# CONTRACT: le planner ne demande une reconstruction d'arbre que pour
|
||||
# une fenêtre déjà matchée dont la structure diffère effectivement.
|
||||
# INVARIANT: l'absence n'est jamais assimilée à une structure divergente.
|
||||
if live_window is not None and current_placement != desired_placement:
|
||||
add(_draft(
|
||||
f"tree:{saved_id}", "restore-tree-position", "tree-layout", saved,
|
||||
depends=(base,), capability="partially-supported",
|
||||
|
|
|
|||
|
|
@ -1133,6 +1133,62 @@ def _load_restore_planner() -> Callable[[Mapping[str, Any], Mapping[str, Any]],
|
|||
return planner
|
||||
|
||||
|
||||
def _load_restore_modules() -> tuple[Any, Any]:
|
||||
"""Charger explicitement planner pur et frontière d'effets STEP19B."""
|
||||
planner_path = Path(__file__).with_name("session_restore.py")
|
||||
executor_path = Path(__file__).with_name("session_executor.py")
|
||||
|
||||
def load(name: str, path: Path) -> Any:
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise SnapshotError(f"module de restauration indisponible: {path.name}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except (OSError, ImportError, SyntaxError) as exc:
|
||||
raise SnapshotError(f"chargement de {path.name} impossible: {exc}") from exc
|
||||
return module
|
||||
|
||||
return load("labfy_session_restore_apply", planner_path), load("labfy_session_executor", executor_path)
|
||||
|
||||
|
||||
def apply_session(
|
||||
name: str,
|
||||
autostart_path: Path | None = None,
|
||||
*,
|
||||
snapshot_loader: Callable[[str], dict[str, Any]] | None = None,
|
||||
live_collector: Callable[[Path | None], dict[str, Any]] | None = None,
|
||||
sessions_path: Path | None = None,
|
||||
planner_module: Any | None = None,
|
||||
executor_module: Any | None = None,
|
||||
desktop_loader: Callable[[], Sequence[Mapping[str, str | None]]] | None = None,
|
||||
runner: Callable[[Sequence[str], float], Any] | None = None,
|
||||
timeout: float = 10.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Construire puis exécuter un plan frais sous le verrou STEP19B."""
|
||||
# WHY: un plan persistant pourrait viser des con_id recyclés. L'API apply
|
||||
# n'accepte donc qu'un nom de snapshot et recollecte toujours Sway.
|
||||
# CONTRACT: l'exécuteur détient le lock pendant load→plan→execute→rehash.
|
||||
# INVARIANT: aucun plan fourni par l'appelant n'est une autorité exécutable.
|
||||
normalized = normalize_session_name(name)
|
||||
if planner_module is None or executor_module is None:
|
||||
loaded_planner, loaded_executor = _load_restore_modules()
|
||||
planner_module = loaded_planner if planner_module is None else planner_module
|
||||
executor_module = loaded_executor if executor_module is None else executor_module
|
||||
kwargs: dict[str, Any] = {
|
||||
"sessions_path": sessions_directory() if sessions_path is None else sessions_path,
|
||||
"snapshot_loader": show_session if snapshot_loader is None else snapshot_loader,
|
||||
"live_collector": collect if live_collector is None else live_collector,
|
||||
"planner_module": planner_module,
|
||||
"desktop_loader": load_desktop_entries if desktop_loader is None else desktop_loader,
|
||||
"resolve_desktop_entry": resolve_desktop_entry,
|
||||
"timeout": timeout,
|
||||
}
|
||||
if runner is not None:
|
||||
kwargs["runner"] = runner
|
||||
return executor_module.apply_session(normalized, autostart_path, **kwargs)
|
||||
|
||||
|
||||
def plan_session(
|
||||
name: str,
|
||||
autostart_path: Path | None = None,
|
||||
|
|
@ -1161,26 +1217,36 @@ def plan_session(
|
|||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Snapshots persistants et restore planner read-only pour Sway")
|
||||
parser = argparse.ArgumentParser(description="Snapshots persistants et restauration contrôlée pour Sway")
|
||||
parser.add_argument("--stdout", action="store_true", help="émettre le JSON validé sur stdout")
|
||||
parser.add_argument("--compact", action="store_true", help="émettre un JSON compact")
|
||||
parser.add_argument(
|
||||
"--execute",
|
||||
action="store_true",
|
||||
help="autoriser explicitement les mutations, uniquement avec apply",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--autostart",
|
||||
type=Path,
|
||||
default=Path.home() / ".config/sway/autostart",
|
||||
help="fichier Sway autostart utilisé seulement pour la classification",
|
||||
)
|
||||
parser.add_argument("command", nargs="?", choices=("save", "list", "show", "delete", "plan"))
|
||||
parser.add_argument("command", nargs="?", choices=("save", "list", "show", "delete", "plan", "apply"))
|
||||
parser.add_argument("name", nargs="?")
|
||||
args = parser.parse_args(argv)
|
||||
if args.stdout and args.command is not None:
|
||||
parser.error("--stdout ne peut pas être combiné avec une commande persistante")
|
||||
if not args.stdout and args.command is None:
|
||||
parser.error("utiliser --stdout ou une commande save/list/show/delete/plan")
|
||||
if args.command in {"save", "show", "delete", "plan"} and args.name is None:
|
||||
parser.error("utiliser --stdout ou une commande save/list/show/delete/plan/apply")
|
||||
if args.command in {"save", "show", "delete", "plan", "apply"} and args.name is None:
|
||||
parser.error(f"{args.command} requiert un nom de session")
|
||||
if (args.stdout or args.command == "list") and args.name is not None:
|
||||
parser.error("nom de session inattendu")
|
||||
if args.execute and args.command != "apply":
|
||||
parser.error("--execute est réservé à la commande apply")
|
||||
if args.command == "apply" and not args.execute:
|
||||
print("RESTORE_EXECUTION_REQUIRES_EXPLICIT_EXECUTE: ajouter --execute", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
if args.stdout:
|
||||
started = time.monotonic()
|
||||
|
|
@ -1204,6 +1270,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
result = show_session(args.name)
|
||||
elif args.command == "plan":
|
||||
result = plan_session(args.name, args.autostart)
|
||||
elif args.command == "apply":
|
||||
result = apply_session(args.name, args.autostart)
|
||||
else:
|
||||
delete_session(args.name)
|
||||
result = {"deleted": args.name}
|
||||
|
|
@ -1215,6 +1283,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
else:
|
||||
json.dump(result, sys.stdout, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
if args.command == "apply" and result.get("status") != "success":
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
479
tests/test_session_executor.py
Normal file
479
tests/test_session_executor.py
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
"""STEP19B : exécuteur contrôlé, tous les effets externes sont simulés."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
try:
|
||||
from tests.test_session_restore import session, window
|
||||
except ModuleNotFoundError: # unittest discovery ajoute directement tests/ à sys.path.
|
||||
from test_session_restore import session, window
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SESSION_DIR = ROOT / "quickshell/.config/quickshell/labfy-sway/session"
|
||||
|
||||
|
||||
def load_module(name, filename):
|
||||
spec = importlib.util.spec_from_file_location(name, SESSION_DIR / filename)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
planner = load_module("session_restore_executor_test", "session_restore.py")
|
||||
executor = load_module("session_executor_test", "session_executor.py")
|
||||
snapshot_backend = load_module("session_snapshot_executor_test", "session_snapshot.py")
|
||||
|
||||
|
||||
DESKTOP_ENTRIES = [{
|
||||
"desktop_entry": "kitty.desktop",
|
||||
"stem": "kitty",
|
||||
"startup_wm_class": "kitty",
|
||||
"name": "kitty",
|
||||
}]
|
||||
|
||||
|
||||
def kitty_saved(**kwargs):
|
||||
defaults = {"workspace": "3", "con_id": 10, "pid": 1000}
|
||||
defaults.update(kwargs)
|
||||
return window("w1", "kitty", desktop_entry="kitty.desktop", executable="kitty", **defaults)
|
||||
|
||||
|
||||
class FakeWorld:
|
||||
def __init__(self, windows=(), *, launch_window=None):
|
||||
self.windows = [copy.deepcopy(item) for item in windows]
|
||||
self.launch_window = copy.deepcopy(launch_window)
|
||||
self.commands = []
|
||||
|
||||
def collect(self):
|
||||
focused = next((item["window_id"] for item in self.windows if item.get("focused")), None)
|
||||
return session(self.windows, focused=focused)
|
||||
|
||||
def run(self, argv, timeout):
|
||||
self.commands.append(list(argv))
|
||||
if argv[:4] == ["uwsm", "app", "--", "kitty.desktop"]:
|
||||
if self.launch_window is not None:
|
||||
self.windows.append(copy.deepcopy(self.launch_window))
|
||||
self.launch_window = None
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
command = argv[-1]
|
||||
prefix, operation = command.split("] ", 1)
|
||||
con_id = int(prefix.removeprefix("[con_id="))
|
||||
target = next(item for item in self.windows if item["runtime"]["con_id"] == con_id)
|
||||
if operation.startswith("move container to workspace number "):
|
||||
target["workspace"] = operation.rsplit(" ", 1)[1]
|
||||
elif operation == "floating enable":
|
||||
target["floating"] = True
|
||||
elif operation == "floating disable":
|
||||
target["floating"] = False
|
||||
elif operation == "fullscreen enable":
|
||||
target["fullscreen_mode"] = 1
|
||||
elif operation == "fullscreen disable":
|
||||
target["fullscreen_mode"] = 0
|
||||
elif operation == "focus":
|
||||
for item in self.windows:
|
||||
item["focused"] = item is target
|
||||
return SimpleNamespace(returncode=0, stdout='[{"success":true}]', stderr="")
|
||||
|
||||
|
||||
def execute(source, world, **kwargs):
|
||||
plan = planner.build_restore_plan(source, world.collect())
|
||||
return executor.execute_fresh_plan(
|
||||
source,
|
||||
plan,
|
||||
collector=world.collect,
|
||||
planner_module=planner,
|
||||
desktop_entries=DESKTOP_ENTRIES,
|
||||
resolve_desktop_entry=snapshot_backend.resolve_desktop_entry,
|
||||
runner=world.run,
|
||||
timeout=0.01,
|
||||
sleeper=lambda _delay: None,
|
||||
**kwargs,
|
||||
), plan
|
||||
|
||||
|
||||
class SessionExecutorTest(unittest.TestCase):
|
||||
def test_apply_requires_explicit_execute(self):
|
||||
stderr = io.StringIO()
|
||||
with mock.patch.object(snapshot_backend, "apply_session") as apply, mock.patch("sys.stderr", stderr):
|
||||
self.assertEqual(snapshot_backend.main(("apply", "dev")), 1)
|
||||
apply.assert_not_called()
|
||||
self.assertIn("REQUIRES_EXPLICIT_EXECUTE", stderr.getvalue())
|
||||
|
||||
def test_apply_execute_reaches_executor(self):
|
||||
report = {"schema": executor.REPORT_SCHEMA, "status": "success"}
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(snapshot_backend, "apply_session", return_value=report) as apply, mock.patch("sys.stdout", output):
|
||||
self.assertEqual(snapshot_backend.main(("apply", "dev", "--execute")), 0)
|
||||
apply.assert_called_once()
|
||||
self.assertEqual(json.loads(output.getvalue()), report)
|
||||
|
||||
def test_plan_blocking_happens_before_mutation(self):
|
||||
saved = kitty_saved(parent="c1")
|
||||
live = copy.deepcopy(saved)
|
||||
live["tree_position"]["parent_container_id"] = None
|
||||
container = {
|
||||
"container_id": "c1", "parent_container_id": None,
|
||||
"tree_position": {"branch": "tiling", "index": 0},
|
||||
"layout": "splitv", "orientation": "vertical", "percent": 1.0,
|
||||
}
|
||||
source = session([saved], persistent=True, containers=[container])
|
||||
world = FakeWorld([live])
|
||||
plan = planner.build_restore_plan(source, world.collect())
|
||||
with self.assertRaisesRegex(executor.RestoreExecutionError, "RESTORE_BLOCKED_UNSUPPORTED_ACTION"):
|
||||
executor.execute_fresh_plan(
|
||||
source, plan, collector=world.collect, planner_module=planner,
|
||||
desktop_entries=DESKTOP_ENTRIES,
|
||||
resolve_desktop_entry=snapshot_backend.resolve_desktop_entry,
|
||||
runner=world.run,
|
||||
)
|
||||
self.assertEqual(world.commands, [])
|
||||
|
||||
def test_command_reconstruction_ignores_arbitrary_argv(self):
|
||||
saved = kitty_saved()
|
||||
source = session([saved], persistent=True)
|
||||
world = FakeWorld([], launch_window=kitty_saved(con_id=22, pid=2000, workspace="3"))
|
||||
plan = planner.build_restore_plan(source, world.collect())
|
||||
launch = next(item for item in plan["actions"] if item["action"] == "launch-application")
|
||||
launch["launch"]["argv"] = ["sh", "-c", "touch /tmp/forbidden"]
|
||||
result = executor.execute_fresh_plan(
|
||||
source, plan, collector=world.collect, planner_module=planner,
|
||||
desktop_entries=DESKTOP_ENTRIES,
|
||||
resolve_desktop_entry=snapshot_backend.resolve_desktop_entry,
|
||||
runner=world.run, timeout=0.01, sleeper=lambda _delay: None,
|
||||
)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(world.commands[0], ["uwsm", "app", "--", "kitty.desktop"])
|
||||
|
||||
def test_default_uwsm_launch_is_direct_and_nonblocking(self):
|
||||
process = SimpleNamespace(returncode=None)
|
||||
with mock.patch.object(executor.subprocess, "Popen", return_value=process) as popen, mock.patch.object(
|
||||
executor.subprocess, "run"
|
||||
) as run:
|
||||
self.assertIs(
|
||||
executor._default_runner(["uwsm", "app", "--", "kitty.desktop"], 10.0),
|
||||
process,
|
||||
)
|
||||
popen.assert_called_once_with(
|
||||
["uwsm", "app", "--", "kitty.desktop"],
|
||||
stdin=executor.subprocess.DEVNULL,
|
||||
stdout=executor.subprocess.DEVNULL,
|
||||
stderr=executor.subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_desktop_entry_is_revalidated(self):
|
||||
source = session([kitty_saved()], persistent=True)
|
||||
world = FakeWorld([])
|
||||
plan = planner.build_restore_plan(source, world.collect())
|
||||
with self.assertRaisesRegex(executor.RestoreExecutionError, "DesktopEntry absente"):
|
||||
executor.execute_fresh_plan(
|
||||
source, plan, collector=world.collect, planner_module=planner,
|
||||
desktop_entries=[], resolve_desktop_entry=snapshot_backend.resolve_desktop_entry,
|
||||
runner=world.run,
|
||||
)
|
||||
self.assertEqual(world.commands, [])
|
||||
|
||||
def test_restore_lock_is_private_and_exclusive(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary)
|
||||
path.chmod(0o700)
|
||||
with executor.restore_lock(path):
|
||||
self.assertEqual((path / ".restore.lock").stat().st_mode & 0o777, 0o600)
|
||||
with self.assertRaisesRegex(executor.RestoreExecutionError, "RESTORE_ALREADY_RUNNING"):
|
||||
with executor.restore_lock(path):
|
||||
self.fail("second lock unexpectedly acquired")
|
||||
|
||||
def test_restore_lock_rejects_non_private_directory(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary)
|
||||
path.chmod(0o755)
|
||||
with self.assertRaisesRegex(executor.RestoreExecutionError, "répertoire sessions non privé"):
|
||||
with executor.restore_lock(path):
|
||||
self.fail("unsafe directory unexpectedly accepted")
|
||||
|
||||
def test_ambiguous_reuse_blocks_before_mutation(self):
|
||||
saved = kitty_saved()
|
||||
source = session([saved], persistent=True)
|
||||
world = FakeWorld([
|
||||
kitty_saved(con_id=81, pid=8001),
|
||||
kitty_saved(con_id=82, pid=8002),
|
||||
])
|
||||
plan = planner.build_restore_plan(source, world.collect())
|
||||
self.assertEqual(plan["actions"][0]["confidence"], "ambiguous")
|
||||
with self.assertRaisesRegex(executor.RestoreExecutionError, "ambiguous-runtime-match"):
|
||||
executor.execute_fresh_plan(
|
||||
source, plan, collector=world.collect, planner_module=planner,
|
||||
desktop_entries=DESKTOP_ENTRIES,
|
||||
resolve_desktop_entry=snapshot_backend.resolve_desktop_entry,
|
||||
runner=world.run,
|
||||
)
|
||||
self.assertEqual(world.commands, [])
|
||||
|
||||
def test_reuse_is_validated_noop(self):
|
||||
saved = kitty_saved(con_id=10)
|
||||
source = session([saved], persistent=True)
|
||||
world = FakeWorld([copy.deepcopy(saved)])
|
||||
result, _plan = execute(source, world)
|
||||
self.assertEqual(result["actions"][0]["status"], "success")
|
||||
self.assertEqual(world.commands, [])
|
||||
|
||||
def test_autostart_skip_is_validated_noop(self):
|
||||
saved = kitty_saved(category="autostart-managed-application", managed_by="sway-autostart")
|
||||
source = session([saved], persistent=True)
|
||||
world = FakeWorld([])
|
||||
result, _plan = execute(source, world)
|
||||
self.assertEqual(result["actions"][0]["status"], "skipped")
|
||||
self.assertEqual(world.commands, [])
|
||||
|
||||
def test_launch_waits_for_new_matching_window(self):
|
||||
source = session([kitty_saved()], persistent=True)
|
||||
launched = kitty_saved(con_id=55, pid=5000, workspace="3")
|
||||
world = FakeWorld([], launch_window=launched)
|
||||
result, _plan = execute(source, world)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["runtime_windows"], {"w1": 55})
|
||||
|
||||
def test_launch_timeout(self):
|
||||
source = session([kitty_saved()], persistent=True)
|
||||
world = FakeWorld([])
|
||||
ticks = iter((0.0, 0.0, 1.0))
|
||||
result, _plan = execute(source, world, monotonic=lambda: next(ticks))
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("RESTORE_LAUNCH_TIMEOUT", result["reason"])
|
||||
|
||||
def test_launch_aborts_if_inventory_drifted_before_uwsm(self):
|
||||
source = session([kitty_saved()], persistent=True)
|
||||
initial = session([])
|
||||
plan = planner.build_restore_plan(source, initial)
|
||||
intruder = window("w9", "other", desktop_entry="other.desktop", con_id=99, pid=9000)
|
||||
commands = []
|
||||
result = executor.execute_fresh_plan(
|
||||
source, plan, collector=lambda: session([intruder]), planner_module=planner,
|
||||
desktop_entries=DESKTOP_ENTRIES,
|
||||
resolve_desktop_entry=snapshot_backend.resolve_desktop_entry,
|
||||
runner=lambda argv, _timeout: commands.append(list(argv)),
|
||||
)
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("inventaire con_id modifié avant lancement", result["reason"])
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_launch_ambiguous(self):
|
||||
source = session([kitty_saved()], persistent=True)
|
||||
first = kitty_saved(con_id=51, pid=5001, workspace="3")
|
||||
second = kitty_saved(con_id=52, pid=5002, workspace="3")
|
||||
world = FakeWorld([])
|
||||
|
||||
def run(argv, timeout):
|
||||
world.commands.append(list(argv))
|
||||
world.windows.extend([first, second])
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
world.run = run
|
||||
result, _plan = execute(source, world)
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("RESTORE_LAUNCH_AMBIGUOUS", result["reason"])
|
||||
|
||||
def test_move_is_targeted_and_confirmed(self):
|
||||
saved = kitty_saved(workspace="3", con_id=10)
|
||||
live = kitty_saved(workspace="2", con_id=44)
|
||||
source = session([saved], persistent=True)
|
||||
world = FakeWorld([live])
|
||||
result, _plan = execute(source, world)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertIn("[con_id=44] move container to workspace number 3", world.commands[-1])
|
||||
|
||||
def test_floating_enable_and_disable(self):
|
||||
for desired, current, expected in ((True, False, "floating enable"), (False, True, "floating disable")):
|
||||
with self.subTest(desired=desired):
|
||||
source = session([kitty_saved(floating=desired)], persistent=True)
|
||||
world = FakeWorld([kitty_saved(floating=current, con_id=88)])
|
||||
result, _plan = execute(source, world)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertTrue(any(expected in command[-1] for command in world.commands))
|
||||
|
||||
def test_fullscreen_workspace_mode_and_disabled(self):
|
||||
for desired, current, expected in ((1, 0, "fullscreen enable"), (0, 1, "fullscreen disable")):
|
||||
with self.subTest(desired=desired):
|
||||
source = session([kitty_saved(fullscreen=desired)], persistent=True)
|
||||
world = FakeWorld([kitty_saved(fullscreen=current, con_id=89)])
|
||||
result, _plan = execute(source, world)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertTrue(any(expected in command[-1] for command in world.commands))
|
||||
|
||||
def test_unsupported_fullscreen_blocks_before_mutation(self):
|
||||
source = session([kitty_saved(fullscreen=2)], persistent=True)
|
||||
world = FakeWorld([kitty_saved(fullscreen=0, con_id=90)])
|
||||
plan = planner.build_restore_plan(source, world.collect())
|
||||
with self.assertRaisesRegex(executor.RestoreExecutionError, "unsupported-fullscreen-mode"):
|
||||
executor.execute_fresh_plan(
|
||||
source, plan, collector=world.collect, planner_module=planner,
|
||||
desktop_entries=DESKTOP_ENTRIES,
|
||||
resolve_desktop_entry=snapshot_backend.resolve_desktop_entry,
|
||||
runner=world.run,
|
||||
)
|
||||
self.assertEqual(world.commands, [])
|
||||
|
||||
def test_focus_is_last(self):
|
||||
saved = kitty_saved(workspace="3", focused=True)
|
||||
source = session([saved], persistent=True, focused="w1")
|
||||
world = FakeWorld([kitty_saved(workspace="2", con_id=91, focused=False)])
|
||||
result, plan = execute(source, world)
|
||||
self.assertEqual(plan["actions"][-1]["action"], "restore-focus")
|
||||
self.assertTrue(world.commands[-1][-1].endswith(" focus"))
|
||||
self.assertEqual(result["status"], "success")
|
||||
|
||||
def test_postcondition_polling_accepts_delayed_focus_visibility(self):
|
||||
saved = kitty_saved(con_id=92)
|
||||
states = [kitty_saved(con_id=92, focused=False), kitty_saved(con_id=92, focused=True)]
|
||||
sleeps = []
|
||||
|
||||
def collect():
|
||||
return session([states.pop(0) if len(states) > 1 else states[0]])
|
||||
|
||||
result = executor._wait_for_postcondition(
|
||||
saved,
|
||||
92,
|
||||
lambda item: bool(item.get("focused")),
|
||||
"focus cible non atteint",
|
||||
collector=collect,
|
||||
planner_module=planner,
|
||||
monotonic=lambda: 0.0,
|
||||
sleeper=sleeps.append,
|
||||
)
|
||||
self.assertTrue(result["focused"])
|
||||
self.assertEqual(sleeps, [executor.POLL_INTERVAL_SECONDS])
|
||||
|
||||
def test_runtime_con_id_disappeared(self):
|
||||
saved = kitty_saved()
|
||||
source = session([saved], persistent=True)
|
||||
initial = session([copy.deepcopy(saved)])
|
||||
plan = planner.build_restore_plan(source, initial)
|
||||
result = executor.execute_fresh_plan(
|
||||
source, plan, collector=lambda: session([]), planner_module=planner,
|
||||
desktop_entries=DESKTOP_ENTRIES,
|
||||
resolve_desktop_entry=snapshot_backend.resolve_desktop_entry,
|
||||
runner=lambda *_args: self.fail("mutation must not run"),
|
||||
)
|
||||
self.assertIn("con_id disparu", result["reason"])
|
||||
|
||||
def test_runtime_identity_changed(self):
|
||||
saved = kitty_saved()
|
||||
source = session([saved], persistent=True)
|
||||
plan = planner.build_restore_plan(source, session([copy.deepcopy(saved)]))
|
||||
changed = window("w9", "evil", desktop_entry="evil.desktop", con_id=10, pid=999)
|
||||
result = executor.execute_fresh_plan(
|
||||
source, plan, collector=lambda: session([changed]), planner_module=planner,
|
||||
desktop_entries=DESKTOP_ENTRIES,
|
||||
resolve_desktop_entry=snapshot_backend.resolve_desktop_entry,
|
||||
runner=lambda *_args: self.fail("mutation must not run"),
|
||||
)
|
||||
self.assertIn("identité modifiée", result["reason"])
|
||||
|
||||
def test_snapshot_hash_is_unchanged_and_plan_is_fresh(self):
|
||||
saved = kitty_saved()
|
||||
source = session([saved], persistent=True, name="fresh")
|
||||
world = FakeWorld([copy.deepcopy(saved)])
|
||||
loads = []
|
||||
|
||||
def loader(name):
|
||||
loads.append(name)
|
||||
return copy.deepcopy(source)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
report = executor.apply_session(
|
||||
"fresh", None, sessions_path=Path(temporary), snapshot_loader=loader,
|
||||
live_collector=lambda _path: world.collect(), planner_module=planner,
|
||||
desktop_loader=lambda: DESKTOP_ENTRIES,
|
||||
resolve_desktop_entry=snapshot_backend.resolve_desktop_entry,
|
||||
runner=world.run,
|
||||
)
|
||||
self.assertEqual(loads, ["fresh", "fresh"])
|
||||
self.assertTrue(report["snapshot_unchanged"])
|
||||
self.assertEqual(report["snapshot_hash_before"], report["snapshot_hash_after"])
|
||||
self.assertEqual(report["plan"]["live"]["windows"][0]["runtime"]["con_id"], 10)
|
||||
|
||||
def test_unsupported_and_manual_actions_abort(self):
|
||||
for action_type in ("restore-tree-position", "restore-scratchpad-hidden", "restore-scratchpad-visible", "manual-required", "unknown-action"):
|
||||
with self.subTest(action=action_type):
|
||||
source = session([kitty_saved()], persistent=True)
|
||||
plan = planner.build_restore_plan(source, session([kitty_saved()]))
|
||||
injected = copy.deepcopy(plan["actions"][0])
|
||||
injected.update(action_id="a999", action=action_type, phase="tree-layout", depends_on=[])
|
||||
plan["actions"] = [injected]
|
||||
world = FakeWorld([kitty_saved()])
|
||||
with self.assertRaisesRegex(executor.RestoreExecutionError, "RESTORE_BLOCKED_UNSUPPORTED_ACTION"):
|
||||
executor.execute_fresh_plan(
|
||||
source, plan, collector=world.collect, planner_module=planner,
|
||||
desktop_entries=DESKTOP_ENTRIES,
|
||||
resolve_desktop_entry=snapshot_backend.resolve_desktop_entry,
|
||||
runner=world.run,
|
||||
)
|
||||
self.assertEqual(world.commands, [])
|
||||
|
||||
def test_malicious_workspace_is_never_a_command(self):
|
||||
source = session([kitty_saved(workspace="3")], persistent=True)
|
||||
world = FakeWorld([kitty_saved(workspace="2", con_id=77)])
|
||||
plan = planner.build_restore_plan(source, world.collect())
|
||||
move = next(item for item in plan["actions"] if item["action"] == "move-to-workspace")
|
||||
move["desired_workspace"] = "3; exec touch /tmp/forbidden"
|
||||
with self.assertRaisesRegex(executor.RestoreExecutionError, "unsupported-non-numeric-workspace"):
|
||||
executor.execute_fresh_plan(
|
||||
source, plan, collector=world.collect, planner_module=planner,
|
||||
desktop_entries=DESKTOP_ENTRIES,
|
||||
resolve_desktop_entry=snapshot_backend.resolve_desktop_entry,
|
||||
runner=world.run,
|
||||
)
|
||||
self.assertEqual(world.commands, [])
|
||||
|
||||
def test_malicious_desktop_entry_is_blocked_before_command(self):
|
||||
saved = kitty_saved()
|
||||
saved["restore_identity"]["desktop_entry"] = "kitty.desktop;touch"
|
||||
source = session([saved], persistent=True)
|
||||
world = FakeWorld([])
|
||||
plan = planner.build_restore_plan(source, world.collect())
|
||||
with self.assertRaisesRegex(executor.RestoreExecutionError, "identité de lancement non exacte"):
|
||||
executor.execute_fresh_plan(
|
||||
source, plan, collector=world.collect, planner_module=planner,
|
||||
desktop_entries=[{
|
||||
"desktop_entry": "kitty.desktop;touch", "stem": "kitty",
|
||||
"startup_wm_class": "kitty", "name": "hostile",
|
||||
}],
|
||||
resolve_desktop_entry=snapshot_backend.resolve_desktop_entry,
|
||||
runner=world.run,
|
||||
)
|
||||
self.assertEqual(world.commands, [])
|
||||
|
||||
def test_title_and_app_id_are_not_interpolated_into_commands(self):
|
||||
malicious = kitty_saved(workspace="3")
|
||||
malicious["title_hint"] = "] exec touch /tmp/forbidden"
|
||||
source = session([malicious], persistent=True)
|
||||
world = FakeWorld([kitty_saved(workspace="2", con_id=73)])
|
||||
result, _plan = execute(source, world)
|
||||
self.assertEqual(result["status"], "success")
|
||||
serialized = json.dumps(world.commands)
|
||||
self.assertNotIn("touch", serialized)
|
||||
self.assertNotIn("title", serialized)
|
||||
|
||||
def test_source_contains_no_shell_execution_primitive(self):
|
||||
source = (SESSION_DIR / "session_executor.py").read_text(encoding="utf-8")
|
||||
self.assertNotIn("shell=True", source)
|
||||
self.assertNotIn("os.system", source)
|
||||
self.assertNotIn("eval(", source)
|
||||
self.assertNotIn("exec(", source)
|
||||
self.assertNotIn("shell=True", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue