elsewindow 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
elsewindow/session.py ADDED
@@ -0,0 +1,788 @@
1
+ # Copyright (c) 2026 kogeler
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Owned Xpra server, client, and SSH lifecycle orchestration."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import secrets
14
+ import shlex
15
+ import signal
16
+ from pathlib import Path
17
+
18
+ from ssh_wrapper.connection import OpenSSHMaster, SSHMasterSettings
19
+ from ssh_wrapper.errors import SSHError
20
+ from ssh_wrapper.remote_process import BoundedTail, OwnedRemoteProcess
21
+
22
+ from .config import XpraConfig
23
+ from .live_config import (
24
+ command_cli_options,
25
+ network_profile,
26
+ production_encoding,
27
+ production_transport_options,
28
+ static_cli_options,
29
+ )
30
+
31
+ LOCAL_PROCESS_STOP_TIMEOUT = 5.0
32
+ CAPABILITY_TIMEOUT = 15.0
33
+ OPENGL_CAPABILITY_TIMEOUT = 45.0
34
+ CLIENT_DIAGNOSTIC_LINES = 8
35
+ CLIENT_DIAGNOSTIC_CHARACTERS = 2 * 1024
36
+ CLIENT_DIAGNOSTIC_MARKERS = (
37
+ "drm",
38
+ "error",
39
+ "failed",
40
+ "failure",
41
+ "connection",
42
+ "disconnect",
43
+ "handshake",
44
+ "invalid",
45
+ "metadata",
46
+ "protocol",
47
+ "renderer",
48
+ "segmentation",
49
+ "socket",
50
+ "timeout",
51
+ "unsupported",
52
+ "warning",
53
+ "wayland",
54
+ )
55
+ ANSI_ESCAPE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|[@-_])")
56
+ DISPLAY_PATTERN = re.compile(r"wayland-[0-9]+")
57
+ REMOTE_SESSIONS_DIR = "$XDG_RUNTIME_DIR/xpra"
58
+ REMOTE_SOCKET_DIR = "$XDG_RUNTIME_DIR/xpra"
59
+ SERVER_RUNTIME_PATHS = {
60
+ "--socket-dir": REMOTE_SOCKET_DIR,
61
+ "--socket-dirs": REMOTE_SOCKET_DIR,
62
+ "--sessions-dir": REMOTE_SESSIONS_DIR,
63
+ }
64
+ SERVER_SECURITY_OPTIONS = (
65
+ "--start-new-commands=no",
66
+ "--dbus=no",
67
+ "--mdns=no",
68
+ "--ssh-upgrade=no",
69
+ "--audio=no",
70
+ "--webcam=no",
71
+ "--printing=no",
72
+ "--file-transfer=no",
73
+ "--open-files=no",
74
+ "--open-url=no",
75
+ "--notifications=no",
76
+ )
77
+ CLIENT_SECURITY_OPTIONS = (
78
+ "--title=@title@",
79
+ "--clipboard=no",
80
+ "--remote-logging=no",
81
+ "--tray=no",
82
+ "--splash=no",
83
+ "--audio=no",
84
+ "--webcam=no",
85
+ "--printing=no",
86
+ "--file-transfer=no",
87
+ "--open-files=no",
88
+ "--open-url=no",
89
+ "--notifications=no",
90
+ )
91
+
92
+
93
+ def _translate_server_runtime_options(
94
+ options: tuple[str, ...],
95
+ ) -> tuple[tuple[str, ...], frozenset[str]]:
96
+ """Translate canonical container-private paths without copying their values."""
97
+ translated: list[str] = []
98
+ replaced: set[str] = set()
99
+ for option in options:
100
+ name, separator, _value = option.partition("=")
101
+ replacement = SERVER_RUNTIME_PATHS.get(name)
102
+ if replacement is None:
103
+ translated.append(option)
104
+ continue
105
+ if not separator or name in replaced:
106
+ raise RuntimeError("the mirrored server runtime-path contract changed")
107
+ translated.append(f"{name}={replacement}")
108
+ replaced.add(name)
109
+ return tuple(translated), frozenset(replaced)
110
+
111
+
112
+ def _production_server_base_options() -> tuple[str, ...]:
113
+ """Translate only the fork's container-private paths to the owned runtime."""
114
+ translated, replaced = _translate_server_runtime_options(
115
+ static_cli_options("server", "base")
116
+ )
117
+ if replaced != set(SERVER_RUNTIME_PATHS):
118
+ raise RuntimeError("the mirrored server runtime-path contract changed")
119
+ return translated
120
+
121
+
122
+ def _production_server_info_options(
123
+ display: str, options: tuple[str, ...]
124
+ ) -> tuple[str, ...]:
125
+ """Translate the canonical server-info target to the owned live session."""
126
+ if DISPLAY_PATTERN.fullmatch(display) is None:
127
+ raise RuntimeError("the remote Xpra display is invalid")
128
+ translated_options, replaced = _translate_server_runtime_options(options)
129
+ positional = tuple(
130
+ index
131
+ for index, option in enumerate(translated_options)
132
+ if not option.startswith("-")
133
+ )
134
+ if len(positional) != 1 or not replaced:
135
+ raise RuntimeError("the mirrored server info command contract changed")
136
+ translated = list(translated_options)
137
+ translated[positional[0]] = display
138
+ return tuple(translated)
139
+
140
+
141
+ def build_xpra_command_argv(
142
+ role: str,
143
+ command: str,
144
+ xpra_path: str | Path,
145
+ *,
146
+ display: str | None = None,
147
+ uri: str | None = None,
148
+ ssh_wrapper: str | Path | None = None,
149
+ ) -> tuple[str, ...]:
150
+ """Build one canonical auxiliary Xpra command for production or live use."""
151
+ options = command_cli_options(role, command)
152
+ if command == "version" and display is None and uri is None and ssh_wrapper is None:
153
+ return (str(xpra_path), *options)
154
+ if (
155
+ role == "server"
156
+ and command == "info"
157
+ and display is not None
158
+ and uri is None
159
+ and ssh_wrapper is None
160
+ ):
161
+ return (
162
+ str(xpra_path),
163
+ command,
164
+ *_production_server_info_options(display, options),
165
+ )
166
+ if (
167
+ role == "client"
168
+ and command == "detach"
169
+ and display is None
170
+ and uri is not None
171
+ and ssh_wrapper is not None
172
+ ):
173
+ return (
174
+ str(xpra_path),
175
+ command,
176
+ uri,
177
+ f"--ssh={ssh_wrapper}",
178
+ *options,
179
+ )
180
+ raise RuntimeError("the mirrored auxiliary Xpra command context is invalid")
181
+
182
+
183
+ def _gpu_mode(profile: str) -> str:
184
+ return "yes" if production_encoding(profile) == "h264" else "auto"
185
+
186
+
187
+ def _option_names(*blocks: tuple[str, ...]) -> tuple[str, ...]:
188
+ """Return unique long-option names in source order, without copying tables."""
189
+ names: list[str] = []
190
+ for option in (value for block in blocks for value in block):
191
+ if not option.startswith("--"):
192
+ continue
193
+ name = option.partition("=")[0].replace("_", "-")
194
+ if name not in names:
195
+ names.append(name)
196
+ return tuple(names)
197
+
198
+
199
+ def build_server_argv(
200
+ application: tuple[str, ...],
201
+ session_name: str,
202
+ encoding_profile: str,
203
+ ) -> tuple[str, ...]:
204
+ """Build one production Wayland server command from the mirrored profile."""
205
+ return (
206
+ "env",
207
+ f"XPRA_WAYLAND_GPU={_gpu_mode(encoding_profile)}",
208
+ "xpra",
209
+ "seamless",
210
+ *_production_server_base_options(),
211
+ f"--session-name={session_name}",
212
+ f"--start-child-after-connect={shlex.join(application)}",
213
+ *static_cli_options("server", "lifecycle"),
214
+ *production_transport_options("server", encoding_profile),
215
+ *SERVER_SECURITY_OPTIONS,
216
+ )
217
+
218
+
219
+ SESSION_METADATA_PROBE = r"""\
220
+ import hashlib
221
+ import json
222
+ import os
223
+ import stat
224
+ import sys
225
+
226
+ NOT_FOUND = 1
227
+ MISMATCH = 2
228
+ INVALID_RUNTIME = 3
229
+ MAX_FILE_SIZE = 65536
230
+ PROC_ROOT = "/proc"
231
+
232
+ def fail(status, reason):
233
+ if status != NOT_FOUND:
234
+ print(f"metadata-{reason}", file=sys.stderr)
235
+ raise SystemExit(status)
236
+
237
+ def owned_stat(path, kind):
238
+ try:
239
+ info = os.lstat(path)
240
+ except FileNotFoundError:
241
+ return None
242
+ except OSError:
243
+ fail(MISMATCH, "stat-failed")
244
+ if info.st_uid != os.getuid() or info.st_mode & 0o022:
245
+ fail(MISMATCH, "unsafe-owner-or-mode")
246
+ if kind == "directory" and not stat.S_ISDIR(info.st_mode):
247
+ fail(MISMATCH, "not-directory")
248
+ if kind == "file" and not stat.S_ISREG(info.st_mode):
249
+ fail(MISMATCH, "not-file")
250
+ if kind == "socket" and not stat.S_ISSOCK(info.st_mode):
251
+ fail(MISMATCH, "not-socket")
252
+ return info
253
+
254
+ def read_file(path):
255
+ if owned_stat(path, "file") is None:
256
+ return None
257
+ try:
258
+ with open(path, "rb") as source:
259
+ value = source.read(MAX_FILE_SIZE + 1)
260
+ except OSError:
261
+ fail(MISMATCH, "read-failed")
262
+ if len(value) > MAX_FILE_SIZE or b"\0" in value:
263
+ fail(MISMATCH, "invalid-file-content")
264
+ try:
265
+ return value.decode("utf-8", errors="strict")
266
+ except UnicodeDecodeError:
267
+ fail(MISMATCH, "invalid-file-encoding")
268
+
269
+ def load_session(directory):
270
+ if owned_stat(directory, "directory") is None:
271
+ return None
272
+ config_text = read_file(os.path.join(directory, "config"))
273
+ command_text = read_file(os.path.join(directory, "cmdline"))
274
+ pid_text = read_file(os.path.join(directory, "server.pid"))
275
+ socket_info = owned_stat(os.path.join(directory, "socket"), "socket")
276
+ if None in (config_text, command_text, pid_text, socket_info):
277
+ return None
278
+ try:
279
+ pid = int(pid_text.strip())
280
+ except ValueError:
281
+ fail(MISMATCH, "invalid-pid")
282
+ if pid <= 0:
283
+ fail(MISMATCH, "invalid-pid")
284
+ process_dir = os.path.join(PROC_ROOT, str(pid))
285
+ try:
286
+ process_info = os.stat(process_dir)
287
+ with open(os.path.join(process_dir, "cmdline"), "rb") as source:
288
+ process_command = source.read(MAX_FILE_SIZE + 1)
289
+ except (FileNotFoundError, ProcessLookupError):
290
+ return None
291
+ except OSError:
292
+ fail(MISMATCH, "process-read-failed")
293
+ if process_info.st_uid != os.getuid() or len(process_command) > MAX_FILE_SIZE:
294
+ fail(MISMATCH, "process-owner-or-size")
295
+ recorded_argv = tuple(command_text.splitlines())
296
+ try:
297
+ process_argv = tuple(
298
+ value.decode("utf-8", errors="strict")
299
+ for value in process_command.rstrip(b"\0").split(b"\0")
300
+ )
301
+ except UnicodeDecodeError:
302
+ fail(MISMATCH, "invalid-process-encoding")
303
+ interpreter_prefix = (
304
+ len(process_argv) == len(recorded_argv) + 1
305
+ and os.path.basename(process_argv[0]).startswith("python")
306
+ and process_argv[1:] == recorded_argv
307
+ )
308
+ same_process = (
309
+ recorded_argv == process_argv
310
+ or process_argv == (" ".join(recorded_argv),)
311
+ or interpreter_prefix
312
+ )
313
+ if not same_process or len(recorded_argv) < 2:
314
+ fail(MISMATCH, "process-arguments")
315
+ if os.path.basename(recorded_argv[0]) != "xpra":
316
+ fail(MISMATCH, "unexpected-program")
317
+ if recorded_argv[1] != "seamless":
318
+ fail(MISMATCH, "unexpected-mode-argument")
319
+ fields = {}
320
+ identity_fields = {"mode", "session-name", "backend"}
321
+ for line in config_text.splitlines():
322
+ key, separator, value = line.partition("=")
323
+ if separator:
324
+ key = key.strip().replace("_", "-")
325
+ if key in fields and key in identity_fields:
326
+ fail(MISMATCH, "duplicate-config-field")
327
+ fields[key] = value.strip()
328
+ if fields.get("mode") != "seamless":
329
+ fail(MISMATCH, "unexpected-config-mode")
330
+ return fields, recorded_argv
331
+
332
+ def sessions_root():
333
+ runtime = os.environ.get("XDG_RUNTIME_DIR", "")
334
+ if not runtime:
335
+ runtime = f"/run/user/{os.getuid()}"
336
+ if not os.path.isabs(runtime):
337
+ fail(INVALID_RUNTIME, "relative-runtime")
338
+ try:
339
+ runtime_info = os.lstat(runtime)
340
+ except FileNotFoundError:
341
+ fail(INVALID_RUNTIME, "missing-runtime")
342
+ except OSError:
343
+ fail(INVALID_RUNTIME, "runtime-stat-failed")
344
+ if not stat.S_ISDIR(runtime_info.st_mode) or runtime_info.st_uid != os.getuid():
345
+ fail(INVALID_RUNTIME, "unsafe-runtime")
346
+ return os.path.join(runtime, "xpra")
347
+
348
+ operation = sys.argv[1]
349
+ root = sessions_root()
350
+ if operation == "ready" and len(sys.argv) == 5:
351
+ actual_display, session_name, expected_argv_sha256 = sys.argv[2:]
352
+ component = actual_display
353
+ if (
354
+ not component.startswith("wayland-")
355
+ or not component[8:].isdigit()
356
+ or "/" in component
357
+ ):
358
+ fail(MISMATCH, "invalid-display")
359
+ session = load_session(os.path.join(root, component))
360
+ if session is None:
361
+ fail(NOT_FOUND, "session-not-ready")
362
+ fields, argv = session
363
+ positionals = tuple(value for value in argv[2:] if not value.startswith("--"))
364
+ if positionals:
365
+ fail(MISMATCH, "unexpected-display-argument")
366
+ normalized_argv = (
367
+ "xpra",
368
+ *argv[1:],
369
+ )
370
+ argv_sha256 = hashlib.sha256(
371
+ json.dumps(
372
+ list(normalized_argv),
373
+ ensure_ascii=False,
374
+ separators=(",", ":"),
375
+ ).encode("utf-8")
376
+ ).hexdigest()
377
+ if argv_sha256 != expected_argv_sha256:
378
+ fail(MISMATCH, "arguments")
379
+ if fields.get("session-name") != session_name:
380
+ fail(MISMATCH, "session-name")
381
+ if fields.get("backend") != "wayland":
382
+ fail(MISMATCH, "backend")
383
+ raise SystemExit(0)
384
+ fail(MISMATCH, "invalid-operation")
385
+ """
386
+
387
+
388
+ async def _terminate_local(process: asyncio.subprocess.Process | None) -> None:
389
+ if process is None or process.returncode is not None:
390
+ return
391
+ try:
392
+ os.killpg(process.pid, signal.SIGTERM)
393
+ except ProcessLookupError:
394
+ return
395
+ try:
396
+ await asyncio.wait_for(process.wait(), timeout=LOCAL_PROCESS_STOP_TIMEOUT)
397
+ except TimeoutError:
398
+ try:
399
+ os.killpg(process.pid, signal.SIGKILL)
400
+ except ProcessLookupError:
401
+ pass
402
+ await process.wait()
403
+
404
+
405
+ class XpraSession:
406
+ """Own one new remote Xpra session and one local attaching client."""
407
+
408
+ def __init__(self, config: XpraConfig) -> None:
409
+ self.config = config
410
+ self.session_id = secrets.token_hex(16)
411
+ executable_name = config.application[0].rsplit("/", maxsplit=1)[-1]
412
+ safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", executable_name).strip(".-")
413
+ self.application_name = (safe_name[:64] or "application").lower()
414
+ self.session_name = self.application_name
415
+ self.master = OpenSSHMaster(
416
+ SSHMasterSettings(
417
+ ssh_path=config.ssh_path,
418
+ false_path=config.false_path,
419
+ connect_timeout=config.connect_timeout,
420
+ runtime_prefix="elsewindow",
421
+ ),
422
+ config.connection,
423
+ )
424
+ self.remote: OwnedRemoteProcess | None = None
425
+ self.client: asyncio.subprocess.Process | None = None
426
+ self.client_stdout = BoundedTail()
427
+ self.client_stderr = BoundedTail()
428
+ self._client_drains: tuple[asyncio.Task[None], ...] = ()
429
+ self._wrapper: Path | None = None
430
+ self._remote_display: str | None = None
431
+
432
+ @staticmethod
433
+ def _require_options(output: bytes, options: tuple[str, ...], side: str) -> None:
434
+ normalized = output.decode("utf-8", errors="replace").replace("_", "-")
435
+ missing = [option for option in options if option not in normalized]
436
+ if missing:
437
+ raise SSHError(
438
+ "xpra_incompatible",
439
+ f"{side} Xpra does not expose the required lifecycle options",
440
+ )
441
+
442
+ def _local_required_options(self) -> tuple[str, ...]:
443
+ return _option_names(
444
+ ("--ssh=owned",),
445
+ static_cli_options("client", "base"),
446
+ network_profile(self.config.network_profile).client_options(),
447
+ production_transport_options("client", self.config.encoding_profile),
448
+ CLIENT_SECURITY_OPTIONS,
449
+ )
450
+
451
+ def _remote_required_options(self) -> tuple[str, ...]:
452
+ return _option_names(
453
+ _production_server_base_options(),
454
+ ("--session-name=owned", "--start-child-after-connect=owned"),
455
+ static_cli_options("server", "lifecycle"),
456
+ production_transport_options("server", self.config.encoding_profile),
457
+ SERVER_SECURITY_OPTIONS,
458
+ )
459
+
460
+ async def _run_local(
461
+ self, argv: list[str], timeout: float
462
+ ) -> tuple[int, bytes, bytes]:
463
+ try:
464
+ process = await asyncio.create_subprocess_exec(
465
+ *argv,
466
+ stdin=asyncio.subprocess.DEVNULL,
467
+ stdout=asyncio.subprocess.PIPE,
468
+ stderr=asyncio.subprocess.PIPE,
469
+ start_new_session=True,
470
+ )
471
+ except OSError as error:
472
+ raise SSHError(
473
+ "xpra_start_failed", "cannot start the local Xpra client"
474
+ ) from error
475
+ try:
476
+ stdout, stderr = await asyncio.wait_for(
477
+ process.communicate(), timeout=timeout
478
+ )
479
+ except TimeoutError:
480
+ await _terminate_local(process)
481
+ return 124, b"", b""
482
+ return process.returncode or 0, stdout, stderr
483
+
484
+ async def _run_mux(
485
+ self, remote_program: str, timeout: float = CAPABILITY_TIMEOUT
486
+ ) -> tuple[int, bytes, bytes]:
487
+ await self.master.ensure_ready()
488
+ process = await asyncio.create_subprocess_exec(
489
+ *self.master.command_argv(remote_program),
490
+ stdin=asyncio.subprocess.DEVNULL,
491
+ stdout=asyncio.subprocess.PIPE,
492
+ stderr=asyncio.subprocess.PIPE,
493
+ start_new_session=True,
494
+ )
495
+ try:
496
+ stdout, stderr = await asyncio.wait_for(
497
+ process.communicate(), timeout=timeout
498
+ )
499
+ except TimeoutError:
500
+ await _terminate_local(process)
501
+ raise SSHError(
502
+ "xpra_probe_timeout", "remote Xpra probe timed out"
503
+ ) from None
504
+ return process.returncode or 0, stdout, stderr
505
+
506
+ async def _check_local_capabilities(self) -> None:
507
+ help_status, stdout, stderr = await self._run_local(
508
+ [str(self.config.xpra_path), "attach", "--help"], CAPABILITY_TIMEOUT
509
+ )
510
+ if help_status != 0:
511
+ raise SSHError("xpra_incompatible", "local Xpra capability probe failed")
512
+ self._require_options(stdout + stderr, self._local_required_options(), "local")
513
+ if production_encoding(self.config.encoding_profile) == "h264":
514
+ opengl_status, stdout, _stderr = await self._run_local(
515
+ [str(self.config.xpra_path), "opengl"],
516
+ OPENGL_CAPABILITY_TIMEOUT,
517
+ )
518
+ properties = {}
519
+ for line in stdout.decode("utf-8", errors="replace").splitlines():
520
+ key, separator, value = line.partition("=")
521
+ if separator:
522
+ properties[key.strip()] = value.strip().lower()
523
+ if (
524
+ opengl_status != 0
525
+ or properties.get("success") != "true"
526
+ or properties.get("safe") != "true"
527
+ ):
528
+ raise SSHError(
529
+ "xpra_incompatible",
530
+ "local Xpra OpenGL renderer is unavailable or unsafe",
531
+ )
532
+
533
+ async def _check_remote_capabilities(self) -> None:
534
+ status, stdout, stderr = await self._run_mux(
535
+ "command -v python3 >/dev/null && xpra seamless --help"
536
+ )
537
+ if status != 0:
538
+ raise SSHError("xpra_incompatible", "remote Xpra capability probe failed")
539
+ self._require_options(
540
+ stdout + stderr, self._remote_required_options(), "remote"
541
+ )
542
+
543
+ def server_argv(self) -> tuple[str, ...]:
544
+ """Return the exact foreground remote Xpra server argv."""
545
+ return build_server_argv(
546
+ self.config.application,
547
+ self.session_name,
548
+ self.config.encoding_profile,
549
+ )
550
+
551
+ def _capture_remote_display(self) -> None:
552
+ """Capture the selected display published by Xpra."""
553
+ if self._remote_display is not None:
554
+ return
555
+ remote = self.remote
556
+ if remote is None:
557
+ return
558
+ raw = getattr(remote, "stdout_head", b"") or remote.stdout_tail.data
559
+ for line in raw.decode("utf-8", errors="replace").splitlines():
560
+ candidate = line.strip()
561
+ if DISPLAY_PATTERN.fullmatch(candidate):
562
+ self._remote_display = candidate
563
+ return
564
+
565
+ def _uri(self) -> str:
566
+ """Return the owned session URI after its actual display is known."""
567
+ display = self._remote_display
568
+ if display is None:
569
+ raise RuntimeError("remote Xpra display is not initialized")
570
+ return f"{self.config.authority_uri}/{display}"
571
+
572
+ def readiness_probe(self) -> str:
573
+ """Return the non-connecting remote session-readiness probe."""
574
+ display = self._remote_display
575
+ if display is None:
576
+ raise RuntimeError("remote Xpra display is not initialized")
577
+ server = self.server_argv()
578
+ normalized_argv = ("xpra", *server[3:])
579
+ argv_sha256 = hashlib.sha256(
580
+ json.dumps(
581
+ list(normalized_argv),
582
+ ensure_ascii=False,
583
+ separators=(",", ":"),
584
+ ).encode("utf-8")
585
+ ).hexdigest()
586
+ return shlex.join(
587
+ (
588
+ "python3",
589
+ "-c",
590
+ SESSION_METADATA_PROBE,
591
+ "ready",
592
+ display,
593
+ self.session_name,
594
+ argv_sha256,
595
+ )
596
+ )
597
+
598
+ def attach_argv(self) -> list[str]:
599
+ """Return the exact mirrored production attach profile."""
600
+ if self._wrapper is None:
601
+ raise RuntimeError("SSH wrapper is not initialized")
602
+ return [
603
+ str(self.config.xpra_path),
604
+ "attach",
605
+ self._uri(),
606
+ f"--ssh={self._wrapper}",
607
+ *static_cli_options("client", "base"),
608
+ *network_profile(self.config.network_profile).client_options(),
609
+ *production_transport_options("client", self.config.encoding_profile),
610
+ *CLIENT_SECURITY_OPTIONS,
611
+ ]
612
+
613
+ async def _wait_ready(self) -> None:
614
+ deadline = asyncio.get_running_loop().time() + self.config.ready_timeout
615
+ while asyncio.get_running_loop().time() < deadline:
616
+ remote = self.remote
617
+ if remote is None or remote.returncode is not None:
618
+ diagnostic = self._remote_diagnostic()
619
+ message = "remote Xpra exited before becoming ready"
620
+ if diagnostic:
621
+ message = f"{message}: {diagnostic}"
622
+ raise SSHError(
623
+ "xpra_start_failed",
624
+ message,
625
+ )
626
+ self._capture_remote_display()
627
+ if self._remote_display is None:
628
+ await asyncio.sleep(self.config.poll_interval)
629
+ continue
630
+ status, _stdout, stderr = await self._run_mux(
631
+ self.readiness_probe(), self.config.probe_timeout
632
+ )
633
+ if status == 0:
634
+ return
635
+ if status != 1:
636
+ detail = self._diagnostic(stderr.decode("utf-8", errors="replace"))
637
+ message = "remote Xpra session metadata does not match the owned server"
638
+ if detail:
639
+ message = f"{message}: {detail}"
640
+ raise SSHError(
641
+ "xpra_identity_mismatch",
642
+ message,
643
+ )
644
+ await asyncio.sleep(self.config.poll_interval)
645
+ raise SSHError(
646
+ "xpra_ready_timeout", "remote Xpra did not become ready before the deadline"
647
+ )
648
+
649
+ async def _drain_client(
650
+ self, stream: asyncio.StreamReader, destination: BoundedTail
651
+ ) -> None:
652
+ while chunk := await stream.read(4096):
653
+ destination.append(chunk)
654
+
655
+ async def _start_client(self) -> None:
656
+ try:
657
+ self.client = await asyncio.create_subprocess_exec(
658
+ *self.attach_argv(),
659
+ stdin=asyncio.subprocess.DEVNULL,
660
+ stdout=asyncio.subprocess.PIPE,
661
+ stderr=asyncio.subprocess.PIPE,
662
+ start_new_session=True,
663
+ )
664
+ except OSError as error:
665
+ raise SSHError(
666
+ "xpra_start_failed", "cannot attach the local Xpra client"
667
+ ) from error
668
+ assert self.client.stdout is not None and self.client.stderr is not None
669
+ self._client_drains = (
670
+ asyncio.create_task(
671
+ self._drain_client(self.client.stdout, self.client_stdout)
672
+ ),
673
+ asyncio.create_task(
674
+ self._drain_client(self.client.stderr, self.client_stderr)
675
+ ),
676
+ )
677
+
678
+ async def _finish_client_drains(self) -> None:
679
+ if not self._client_drains:
680
+ return
681
+ drains, self._client_drains = self._client_drains, ()
682
+ await asyncio.gather(*drains, return_exceptions=True)
683
+
684
+ def _diagnostic(self, raw: str) -> str:
685
+ """Bound one diagnostic line and remove owned private local paths."""
686
+ for path in (
687
+ self._wrapper,
688
+ self.master.control_path,
689
+ self.master.runtime_dir,
690
+ ):
691
+ if path is not None:
692
+ raw = raw.replace(str(path), "<private-path>")
693
+
694
+ selected: list[str] = []
695
+ for raw_line in raw.splitlines():
696
+ line = ANSI_ESCAPE.sub("", raw_line)
697
+ line = "".join(character for character in line if character.isprintable())
698
+ line = " ".join(line.split())
699
+ lowered = line.lower()
700
+ if line and any(marker in lowered for marker in CLIENT_DIAGNOSTIC_MARKERS):
701
+ selected.append(line)
702
+ diagnostic = " | ".join(selected[-CLIENT_DIAGNOSTIC_LINES:])
703
+ return diagnostic[-CLIENT_DIAGNOSTIC_CHARACTERS:]
704
+
705
+ def _client_diagnostic(self) -> str:
706
+ raw = self.client_stderr.text() or self.client_stdout.text()
707
+ return self._diagnostic(raw)
708
+
709
+ def _remote_diagnostic(self) -> str:
710
+ remote = self.remote
711
+ if remote is None:
712
+ return ""
713
+ stderr = getattr(remote, "stderr_tail", None)
714
+ stdout = getattr(remote, "stdout_tail", None)
715
+ raw = stderr.text() if stderr is not None else ""
716
+ if not raw and stdout is not None:
717
+ raw = stdout.text()
718
+ return self._diagnostic(raw)
719
+
720
+ async def _wait_lifecycle(self) -> int:
721
+ remote = self.remote
722
+ client = self.client
723
+ master_process = self.master.process
724
+ assert remote is not None and client is not None and master_process is not None
725
+ client_wait = asyncio.create_task(client.wait())
726
+ remote_wait = asyncio.create_task(remote.wait())
727
+ master_wait = asyncio.create_task(master_process.wait())
728
+ waits = {client_wait, remote_wait, master_wait}
729
+ try:
730
+ done, _pending = await asyncio.wait(
731
+ waits, return_when=asyncio.FIRST_COMPLETED
732
+ )
733
+ if client_wait in done and client_wait.result() == 0:
734
+ return 0
735
+ if master_wait in done:
736
+ raise SSHError("connection_lost", "SSH master was lost")
737
+ if remote_wait in done:
738
+ status = remote_wait.result()
739
+ if status == 0:
740
+ return 0
741
+ diagnostic = self._remote_diagnostic()
742
+ message = f"remote Xpra exited with status {status}"
743
+ if diagnostic:
744
+ message = f"{message}: {diagnostic}"
745
+ raise SSHError(
746
+ "xpra_server_exited",
747
+ message,
748
+ )
749
+ status = client_wait.result()
750
+ await self._finish_client_drains()
751
+ diagnostic = self._client_diagnostic()
752
+ message = f"local Xpra client exited with status {status}"
753
+ if diagnostic:
754
+ message = f"{message}: {diagnostic}"
755
+ raise SSHError(
756
+ "xpra_client_failed",
757
+ message,
758
+ )
759
+ finally:
760
+ for task in waits:
761
+ if not task.done():
762
+ task.cancel()
763
+ await asyncio.gather(*waits, return_exceptions=True)
764
+
765
+ async def run(self) -> int:
766
+ """Start, attach, wait, and clean every owned process in order."""
767
+ try:
768
+ await self._check_local_capabilities()
769
+ await self.master.start()
770
+ self._wrapper = self.master.create_mux_wrapper("xpra-ssh")
771
+ await self._check_remote_capabilities()
772
+ self.remote = OwnedRemoteProcess(
773
+ self.master,
774
+ self.server_argv(),
775
+ heartbeat_interval=self.config.heartbeat_interval,
776
+ lease_timeout=self.config.lease_timeout,
777
+ grace_timeout=self.config.grace_timeout,
778
+ )
779
+ await self.remote.start()
780
+ await self._wait_ready()
781
+ await self._start_client()
782
+ return await self._wait_lifecycle()
783
+ finally:
784
+ await _terminate_local(self.client)
785
+ await self._finish_client_drains()
786
+ if self.remote is not None:
787
+ await self.remote.close()
788
+ await self.master.close()