Harden desktop session reliability
Target LibreOffice saves to the requested document window and report whether the file content changed. Fail fast on dead XFCE startup, probe the live X display, replace session manifests atomically, and decode XWD screenshots through Pillow's raw decoder. Add focused regressions for each path.
Alessandro committed
Jul 11, 2026 at 23:25 UTC
07e7e1e07554341d1e9815ceb815f3e691313a0a
4 files changed
+269
-164
plugins/_desktop/helpers/desktop_session.py
+108
-76
@@ -5,7 +5,6 @@ import fcntl
5
import hashlib
6
import json
7
import os
8
-import re
8
import shutil
9
import socket
10
import subprocess
@@ -223,39 +222,69 @@ class DesktopSessionManager:
222
return {"ok": True, "refreshed": refreshed, "desktop": session.public(doc)}
223
224
def save(self, session_id: str, file_id: str = "") -> dict[str, Any]:
226
- session = self.require(session_id)
227
- doc = self._document_for_save(session, file_id)
228
- xdotool = shutil.which("xdotool")
229
- if not xdotool:
230
- updated = document_store.register_document(doc["path"]) if doc else None
231
- return {
232
- "ok": False,
233
- "error": "xdotool is not installed; use LibreOffice's Save control inside the canvas.",
234
- "document": _public_doc(updated) if updated else None,
235
- }
225
+ with self._lock:
226
+ session = self.require(session_id)
227
+ doc = self._document_for_save(session, file_id)
228
+ if not doc:
229
+ return {
230
+ "ok": True,
231
+ "session_id": session.session_id,
232
+ "document": None,
233
+ "changed": False,
234
+ }
235
237
- result = subprocess.run(
238
- [xdotool, "key", "--clearmodifiers", "ctrl+s"],
239
- check=False,
240
- capture_output=True,
241
- text=True,
242
- timeout=8,
243
- env=self._display_env(session),
244
- )
245
- time.sleep(0.8)
246
- updated = document_store.register_document(doc["path"]) if doc else None
247
- if result.returncode != 0:
248
- detail = (result.stderr or result.stdout or "").strip()
236
+ xdotool = shutil.which("xdotool")
237
+ if not xdotool:
238
+ updated = document_store.register_document(doc["path"])
239
+ return {
240
+ "ok": False,
241
+ "error": "xdotool is not installed; use LibreOffice's Save control inside the canvas.",
242
+ "document": _public_doc(updated),
243
+ }
244
+
245
+ window_id = self._office_window_id_locked(
246
+ session,
247
+ title=str(doc.get("basename") or Path(doc["path"]).name),
248
+ fallback=False,
249
+ )
250
+ if not window_id:
251
+ return {
252
+ "ok": False,
253
+ "error": f"LibreOffice window not found for {doc['basename']}.",
254
+ "document": _public_doc(doc),
255
+ }
256
+
257
+ result = subprocess.run(
258
+ [
259
+ xdotool,
260
+ "windowactivate",
261
+ "--sync",
262
+ window_id,
263
+ "key",
264
+ "--clearmodifiers",
265
+ "ctrl+s",
266
+ ],
267
+ check=False,
268
+ capture_output=True,
269
+ text=True,
270
+ timeout=8,
271
+ env=self._display_env(session),
272
+ )
273
+ time.sleep(0.8)
274
+ updated = document_store.register_document(doc["path"])
275
+ if result.returncode != 0:
276
+ detail = (result.stderr or result.stdout or "").strip()
277
+ return {
278
+ "ok": False,
279
+ "error": detail or "LibreOffice desktop save shortcut failed.",
280
+ "document": _public_doc(updated),
281
+ }
282
return {
250
- "ok": False,
251
- "error": detail or "LibreOffice desktop save shortcut failed.",
252
- "document": _public_doc(updated) if updated else None,
283
+ "ok": True,
284
+ "session_id": session.session_id,
285
+ "document": _public_doc(updated),
286
+ "changed": updated.get("sha256") != doc.get("sha256"),
287
}
254
- return {
255
- "ok": True,
256
- "session_id": session.session_id,
257
- "document": _public_doc(updated) if updated else None,
258
- }
288
289
def sync(self, session_id: str = "", file_id: str = "") -> dict[str, Any]:
290
session = self.get(session_id) if session_id else self._find_by_file_id(file_id)
@@ -820,57 +849,48 @@ class DesktopSessionManager:
849
return ""
850
env = self._display_env(session)
851
title = str(title or "").strip()
823
- searches: list[list[str]] = []
852
+ try:
853
+ result = subprocess.run(
854
+ [xdotool, "search", "--onlyvisible", "--class", "libreoffice"],
855
+ check=False,
856
+ capture_output=True,
857
+ text=True,
858
+ timeout=2,
859
+ env=env,
860
+ )
861
+ window_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
862
+ except (OSError, subprocess.TimeoutExpired):
863
+ window_ids = []
864
if title:
825
- escaped_title = re.escape(title)
826
- searches.append([
827
- xdotool,
828
- "search",
829
- "--onlyvisible",
830
- "--name",
831
- escaped_title,
832
- ])
833
- for window_class in (
834
- "libreoffice",
835
- "libreoffice-writer",
836
- "libreoffice-calc",
837
- "libreoffice-impress",
838
- ):
839
- searches.append([
840
- xdotool,
841
- "search",
842
- "--onlyvisible",
843
- "--class",
844
- window_class,
845
- "--name",
846
- escaped_title,
847
- ])
865
+ for window_id in reversed(window_ids):
866
+ try:
867
+ result = subprocess.run(
868
+ [xdotool, "getwindowname", window_id],
869
+ check=False,
870
+ capture_output=True,
871
+ text=True,
872
+ timeout=2,
873
+ env=env,
874
+ )
875
+ except (OSError, subprocess.TimeoutExpired):
876
+ continue
877
+ if title.casefold() in result.stdout.strip().casefold():
878
+ return window_id
879
+ if fallback and window_ids:
880
+ return window_ids[-1]
881
if fallback:
849
- for window_class in (
850
- "libreoffice",
851
- "libreoffice-writer",
852
- "libreoffice-calc",
853
- "libreoffice-impress",
854
- ):
855
- searches.append([xdotool, "search", "--onlyvisible", "--class", window_class])
856
- searches.append([xdotool, "search", "--onlyvisible", "--name", "LibreOffice"])
857
- for command in searches:
882
try:
883
result = subprocess.run(
860
- command,
884
+ [xdotool, "search", "--onlyvisible", "--name", "LibreOffice"],
885
check=False,
886
capture_output=True,
887
text=True,
888
timeout=2,
889
env=env,
890
)
891
+ window_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
892
except (OSError, subprocess.TimeoutExpired):
868
- continue
869
- window_ids = [
870
- line.strip()
871
- for line in result.stdout.splitlines()
872
- if line.strip()
873
- ]
893
+ window_ids = []
894
if window_ids:
895
return window_ids[-1]
896
return ""
@@ -1573,14 +1593,20 @@ fi
1593
self._remove_manifest(session_id)
1594
1595
def _wait_for_display(self, session: DesktopSession) -> None:
1576
- marker = Path(f"/tmp/.X11-unix/X{session.display}")
1596
deadline = time.time() + DISPLAY_START_TIMEOUT_SECONDS
1597
while time.time() < deadline:
1598
process = session.processes.get("xvfb") or session.processes.get("xpra")
1599
if process and process.poll() is not None:
1600
raise RuntimeError("The LibreOffice X display exited before it was ready.")
1582
- if marker.exists():
1583
- return
1601
+ try:
1602
+ if virtual_desktop.current_display_size(
1603
+ session.display,
1604
+ xauthority=self._xauthority(session),
1605
+ home=str(session.profile_dir),
1606
+ ):
1607
+ return
1608
+ except (OSError, subprocess.TimeoutExpired):
1609
+ pass
1610
time.sleep(0.1)
1611
raise TimeoutError("Timed out waiting for the LibreOffice X display.")
1612
@@ -1589,7 +1615,7 @@ fi
1615
while time.time() < deadline:
1616
process = session.processes.get("xfce")
1617
if process and process.poll() is not None:
1592
- return
1618
+ raise RuntimeError("The XFCE desktop session exited before it was ready.")
1619
if virtual_desktop.has_window(
1620
display=session.display,
1621
name="xfce4-panel",
@@ -1619,7 +1645,13 @@ fi
1645
"owner_pid": os.getpid(),
1646
"pids": pids,
1647
}
1622
- (SESSION_DIR / f"{session.session_id}.json").write_text(json.dumps(payload), encoding="utf-8")
1648
+ manifest = SESSION_DIR / f"{session.session_id}.json"
1649
+ tmp = manifest.with_name(f".{manifest.name}.{uuid.uuid4().hex}.tmp")
1650
+ try:
1651
+ tmp.write_text(json.dumps(payload), encoding="utf-8")
1652
+ os.replace(tmp, manifest)
1653
+ finally:
1654
+ tmp.unlink(missing_ok=True)
1655
1656
def _remove_manifest(self, session_id: str) -> None:
1657
(SESSION_DIR / f"{session_id}.json").unlink(missing_ok=True)
plugins/_desktop/helpers/desktop_state.py
+26
-55
@@ -262,50 +262,43 @@ def convert_xwd_to_image(raw_path: Path, target: Path) -> dict[str, int]:
262
from PIL import Image
263
264
data = raw_path.read_bytes()
265
- header, endian = parse_xwd_header(data)
265
+ header, _ = parse_xwd_header(data)
266
width = header["pixmap_width"]
267
height = header["pixmap_height"]
268
bytes_per_line = header["bytes_per_line"]
269
bits_per_pixel = header["bits_per_pixel"]
270
- image_byte_order = "little" if header["byte_order"] == 0 else "big"
270
color_table_size = header["ncolors"] * 12
271
pixel_offset = header["header_size"] + color_table_size
273
- bytes_per_pixel = max((bits_per_pixel + 7) // 8, 1)
274
- if width > 0 and bytes_per_line % width == 0:
275
- bytes_per_pixel = max(bytes_per_pixel, bytes_per_line // width)
272
if width <= 0 or height <= 0 or bytes_per_line <= 0:
273
raise ValueError("invalid XWD dimensions")
278
- if pixel_offset + (height * bytes_per_line) > len(data):
274
+ pixel_size = height * bytes_per_line
275
+ if pixel_offset + pixel_size > len(data):
276
raise ValueError("truncated XWD pixel data")
277
281
- red_mask = header["red_mask"]
282
- green_mask = header["green_mask"]
283
- blue_mask = header["blue_mask"]
284
- red_shift, red_bits = mask_shift_and_bits(red_mask)
285
- green_shift, green_bits = mask_shift_and_bits(green_mask)
286
- blue_shift, blue_bits = mask_shift_and_bits(blue_mask)
287
- if min(red_bits, green_bits, blue_bits) <= 0:
278
+ if (header["red_mask"], header["green_mask"], header["blue_mask"]) != (
279
+ 0x00FF0000,
280
+ 0x0000FF00,
281
+ 0x000000FF,
282
+ ):
283
raise ValueError("unsupported XWD visual masks")
289
-
290
- pixels: list[tuple[int, int, int]] = []
291
- for row in range(height):
292
- row_start = pixel_offset + (row * bytes_per_line)
293
- for column in range(width):
294
- start = row_start + (column * bytes_per_pixel)
295
- pixel_bytes = data[start : start + bytes_per_pixel]
296
- if len(pixel_bytes) < bytes_per_pixel:
297
- raise ValueError("truncated XWD pixel")
298
- pixel = int.from_bytes(pixel_bytes, image_byte_order, signed=False)
299
- pixels.append(
300
- (
301
- scale_channel((pixel & red_mask) >> red_shift, red_bits),
302
- scale_channel((pixel & green_mask) >> green_shift, green_bits),
303
- scale_channel((pixel & blue_mask) >> blue_shift, blue_bits),
304
- ),
305
- )
306
-
307
- image = Image.new("RGB", (width, height))
308
- image.putdata(pixels)
284
+ raw_mode = {
285
+ (24, 0): "BGR",
286
+ (24, 1): "RGB",
287
+ (32, 0): "BGRX",
288
+ (32, 1): "XRGB",
289
+ }.get((bits_per_pixel, header["byte_order"]))
290
+ if not raw_mode:
291
+ raise ValueError(f"unsupported XWD pixel layout: {bits_per_pixel} bpp")
292
+
293
+ image = Image.frombytes(
294
+ "RGB",
295
+ (width, height),
296
+ data[pixel_offset : pixel_offset + pixel_size],
297
+ "raw",
298
+ raw_mode,
299
+ bytes_per_line,
300
+ 1,
301
+ )
302
image.save(target)
303
return {"width": width, "height": height}
304
@@ -348,28 +341,6 @@ def parse_xwd_header(data: bytes) -> tuple[dict[str, int], str]:
341
raise ValueError("unsupported XWD header")
342
343
351
-def mask_shift_and_bits(mask: int) -> tuple[int, int]:
352
- if mask <= 0:
353
- return 0, 0
354
- shift = 0
355
- value = mask
356
- while value and value & 1 == 0:
357
- shift += 1
358
- value >>= 1
359
- bits = 0
360
- while value & 1:
361
- bits += 1
362
- value >>= 1
363
- return shift, bits
364
-
365
-
366
-def scale_channel(value: int, bits: int) -> int:
367
- if bits >= 8:
368
- return max(0, min(255, value >> (bits - 8)))
369
- max_value = (1 << bits) - 1
370
- return 0 if max_value <= 0 else round((value / max_value) * 255)
371
-
372
-
344
def resolve_environment(*, errors: list[str] | None = None, session_id: str = SESSION_ID) -> dict[str, str]:
345
local_errors = errors if errors is not None else []
346
manifest = session_manifest_path(session_id)
tests/test_office_desktop_state.py
+18
-33
@@ -6,6 +6,8 @@ import sys
6
import types
7
from pathlib import Path
8
9
+import pytest
10
+
11
PROJECT_ROOT = Path(__file__).resolve().parents[1]
12
if str(PROJECT_ROOT) not in sys.path:
13
sys.path.insert(0, str(PROJECT_ROOT))
@@ -324,7 +326,16 @@ def test_virtual_desktop_system_display_normalization_rejects_portrait_viewports
326
assert virtual_desktop.normalize_desktop_display_size(1600, 900) == (1600, 900)
327
328
327
-def test_xwd_fallback_parser_handles_truecolor_pixels(tmp_path, monkeypatch):
329
+@pytest.mark.parametrize(
330
+ ("byte_order", "pixel_bytes"),
331
+ (
332
+ (0, bytes.fromhex("0000ff00") + bytes.fromhex("00ff0000")),
333
+ (1, bytes.fromhex("00ff0000") + bytes.fromhex("0000ff00")),
334
+ ),
335
+)
336
+def test_xwd_fallback_parser_handles_truecolor_pixels(tmp_path, byte_order, pixel_bytes):
337
+ from PIL import Image
338
+
339
raw_path = tmp_path / "shot.xwd"
340
target = tmp_path / "shot.png"
341
header_values = [
@@ -335,7 +346,7 @@ def test_xwd_fallback_parser_handles_truecolor_pixels(tmp_path, monkeypatch):
346
2, # pixmap_width
347
1, # pixmap_height
348
0, # xoffset
338
- 1, # byte_order: MSBFirst for pixel bytes
349
+ byte_order,
350
32, # bitmap_unit
351
1, # bitmap_bit_order
352
32, # bitmap_pad
@@ -354,38 +365,12 @@ def test_xwd_fallback_parser_handles_truecolor_pixels(tmp_path, monkeypatch):
365
0, # window_y
366
0, # window_bdrwidth
367
]
357
- raw_path.write_bytes(
358
- struct.pack(">25I", *header_values)
359
- + bytes.fromhex("00ff0000")
360
- + bytes.fromhex("0000ff00")
361
- )
362
-
363
- captured: dict[str, object] = {}
364
- image_module = types.ModuleType("PIL.Image")
365
-
366
- class FakeOutputImage:
367
- def putdata(self, pixels):
368
- captured["pixels"] = list(pixels)
369
-
370
- def save(self, path):
371
- Path(path).write_bytes(b"fallback-png")
372
-
373
- def fake_new(mode, size):
374
- captured["mode"] = mode
375
- captured["size"] = size
376
- return FakeOutputImage()
377
-
378
- image_module.new = fake_new
379
- pil_module = types.ModuleType("PIL")
380
- pil_module.Image = image_module
381
-
382
- monkeypatch.setitem(sys.modules, "PIL", pil_module)
383
- monkeypatch.setitem(sys.modules, "PIL.Image", image_module)
368
+ raw_path.write_bytes(struct.pack(">25I", *header_values) + pixel_bytes)
369
370
converted = desktop_state.convert_xwd_to_image(raw_path, target)
371
372
assert converted == {"width": 2, "height": 1}
388
- assert captured["mode"] == "RGB"
389
- assert captured["size"] == (2, 1)
390
- assert captured["pixels"] == [(255, 0, 0), (0, 255, 0)]
391
- assert target.read_bytes() == b"fallback-png"
373
+ with Image.open(target) as image:
374
+ assert image.mode == "RGB"
375
+ assert image.size == (2, 1)
376
+ assert list(image.getdata()) == [(255, 0, 0), (0, 255, 0)]
tests/test_office_document_store.py
+117
@@ -1037,6 +1037,123 @@ def test_official_desktop_session_manager_opens_binary_session(office_state, tmp
1037
assert manager.close(payload["session_id"], save_first=False)["persistent"] is True
1038
1039
1040
+def test_desktop_save_targets_requested_libreoffice_window(office_state, tmp_path, monkeypatch):
1041
+ doc = document_store.create_document("spreadsheet", "Targeted Save", "ods", "Name,Value\nA,1")
1042
+ session = desktop_session.DesktopSession(
1043
+ session_id=desktop_session.SYSTEM_SESSION_ID,
1044
+ file_id=doc["file_id"],
1045
+ extension=doc["extension"],
1046
+ path=doc["path"],
1047
+ title=doc["basename"],
1048
+ display=desktop_session.DISPLAY_BASE,
1049
+ xpra_port=desktop_session.XPRA_PORT_BASE,
1050
+ token=desktop_session.SYSTEM_SESSION_ID,
1051
+ url="/desktop/session/agent-zero-desktop/index.html",
1052
+ profile_dir=tmp_path / "profile",
1053
+ processes={"xpra": types.SimpleNamespace(poll=lambda: None)},
1054
+ )
1055
+ manager = desktop_session.DesktopSessionManager()
1056
+ manager._sessions[session.session_id] = session
1057
+ commands = []
1058
+
1059
+ monkeypatch.setattr(desktop_session.shutil, "which", lambda name: f"/usr/bin/{name}")
1060
+ monkeypatch.setattr(desktop_session.time, "sleep", lambda _seconds: None)
1061
+
1062
+ def fake_run(command, **_kwargs):
1063
+ commands.append(command)
1064
+ if command[1] == "search":
1065
+ assert command == ["/usr/bin/xdotool", "search", "--onlyvisible", "--class", "libreoffice"]
1066
+ return subprocess.CompletedProcess(command, 0, "222\n", "")
1067
+ if command[1] == "getwindowname":
1068
+ return subprocess.CompletedProcess(command, 0, f"{doc['basename']} — LibreOffice Calc\n", "")
1069
+ Path(doc["path"]).write_bytes(Path(doc["path"]).read_bytes() + b"changed")
1070
+ return subprocess.CompletedProcess(command, 0, "", "")
1071
+
1072
+ monkeypatch.setattr(desktop_session.subprocess, "run", fake_run)
1073
+
1074
+ result = manager.save(session.session_id, doc["file_id"])
1075
+
1076
+ assert result["ok"] is True
1077
+ assert result["changed"] is True
1078
+ assert commands[-1] == [
1079
+ "/usr/bin/xdotool",
1080
+ "windowactivate",
1081
+ "--sync",
1082
+ "222",
1083
+ "key",
1084
+ "--clearmodifiers",
1085
+ "ctrl+s",
1086
+ ]
1087
+
1088
+ session.file_id = desktop_session.SYSTEM_FILE_ID
1089
+ command_count = len(commands)
1090
+ assert manager.save(session.session_id)["changed"] is False
1091
+ assert len(commands) == command_count
1092
+
1093
+
1094
+def test_desktop_startup_waiters_probe_display_and_reject_dead_xfce(tmp_path, monkeypatch):
1095
+ session = desktop_session.DesktopSession(
1096
+ session_id=desktop_session.SYSTEM_SESSION_ID,
1097
+ file_id=desktop_session.SYSTEM_FILE_ID,
1098
+ extension="desktop",
1099
+ path=str(tmp_path),
1100
+ title=desktop_session.SYSTEM_TITLE,
1101
+ display=desktop_session.DISPLAY_BASE,
1102
+ xpra_port=desktop_session.XPRA_PORT_BASE,
1103
+ token=desktop_session.SYSTEM_SESSION_ID,
1104
+ url="/desktop/session/agent-zero-desktop/index.html",
1105
+ profile_dir=tmp_path / "profile",
1106
+ processes={"xvfb": types.SimpleNamespace(poll=lambda: None)},
1107
+ )
1108
+ manager = desktop_session.DesktopSessionManager()
1109
+ probes = iter((None, (1920, 1080)))
1110
+ monkeypatch.setattr(
1111
+ desktop_session.virtual_desktop,
1112
+ "current_display_size",
1113
+ lambda *_args, **_kwargs: next(probes),
1114
+ )
1115
+ monkeypatch.setattr(desktop_session.time, "sleep", lambda _seconds: None)
1116
+
1117
+ manager._wait_for_display(session)
1118
+
1119
+ session.processes = {"xfce": types.SimpleNamespace(poll=lambda: 1)}
1120
+ with pytest.raises(RuntimeError, match="XFCE desktop session exited"):
1121
+ manager._wait_for_xfce(session)
1122
+
1123
+
1124
+def test_desktop_manifest_is_replaced_atomically(tmp_path, monkeypatch):
1125
+ session_dir = tmp_path / "sessions"
1126
+ session_dir.mkdir()
1127
+ manifest = session_dir / f"{desktop_session.SYSTEM_SESSION_ID}.json"
1128
+ manifest.write_text('{"old": true}', encoding="utf-8")
1129
+ session = desktop_session.DesktopSession(
1130
+ session_id=desktop_session.SYSTEM_SESSION_ID,
1131
+ file_id=desktop_session.SYSTEM_FILE_ID,
1132
+ extension="desktop",
1133
+ path=str(tmp_path),
1134
+ title=desktop_session.SYSTEM_TITLE,
1135
+ display=desktop_session.DISPLAY_BASE,
1136
+ xpra_port=desktop_session.XPRA_PORT_BASE,
1137
+ token=desktop_session.SYSTEM_SESSION_ID,
1138
+ url="/desktop/session/agent-zero-desktop/index.html",
1139
+ profile_dir=tmp_path / "profile",
1140
+ )
1141
+ real_replace = os.replace
1142
+
1143
+ def assert_atomic_replace(source, destination):
1144
+ assert json.loads(Path(destination).read_text(encoding="utf-8")) == {"old": True}
1145
+ assert json.loads(Path(source).read_text(encoding="utf-8"))["display"] == session.display
1146
+ real_replace(source, destination)
1147
+
1148
+ monkeypatch.setattr(desktop_session, "SESSION_DIR", session_dir)
1149
+ monkeypatch.setattr(desktop_session.os, "replace", assert_atomic_replace)
1150
+
1151
+ desktop_session.DesktopSessionManager()._write_manifest(session)
1152
+
1153
+ assert json.loads(manifest.read_text(encoding="utf-8"))["session_id"] == session.session_id
1154
+ assert list(session_dir.glob(".*.tmp")) == []
1155
+
1156
+
1157
def test_shutdown_panel_launcher_requires_second_click(tmp_path):
1158
profile_dir = tmp_path / "desktop" / "profiles" / desktop_session.SYSTEM_SESSION_ID
1159
profile_dir.mkdir(parents=True)