Release 1.1.3 with game window and Gamescope lifecycle fixes

This commit is contained in:
2026-10-25 20:47:19 +01:00
parent b6de80e758
commit 1f2aad0ee2
42 changed files with 9291 additions and 153 deletions

View File

@@ -4,4 +4,4 @@ from .paths import AppPaths
__all__ = ["AppPaths", "__version__"]
__version__ = "1.1.2"
__version__ = "1.1.3"

View File

@@ -31,6 +31,7 @@ from ...umu import UmuBootstrapResult
from ...window_diagnostics import (
WINDOW_DIAGNOSTICS_ENV,
WINDOW_DIAGNOSTICS_SESSION_ENV,
WindowDiagnosticLog,
)
@@ -82,6 +83,11 @@ def play_locked(
and bool(getattr(telemetry, "enabled", True))
)
launch_session_id = uuid4().hex if window_diagnostics_active else None
window_diagnostic_log = (
WindowDiagnosticLog(paths.logs / f"windows-{launch_session_id}.jsonl", launch_session_id)
if launch_session_id is not None
else None
)
launch_session_metadata = (
{"launch_session_id": launch_session_id}
if launch_session_id is not None
@@ -218,6 +224,7 @@ def play_locked(
# CommandRunner keeps a bounded output tail. Preserve readiness here so a
# long, noisy session cannot evict the evidence and become a false failure.
observed_readiness_signal: str | None = None
observed_gamescope_exit: dict[str, object] = {}
launch_success_reported = False
reported_runtime_warnings: set[str] = set()
if telemetry is not None and not dry_run:
@@ -245,6 +252,11 @@ def play_locked(
"""Timestamp launch success when the supervisor first confirms readiness."""
nonlocal observed_readiness_signal, launch_success_reported
if "gamescope exited without a recorded game status" in line.casefold():
# Retain supervisor evidence even if noisy child cleanup later evicts
# this line from CommandRunner's bounded stderr capture.
with launch_success_lock:
observed_gamescope_exit.update(gamescope_exit_summary(line))
presentation_failure = api._gamescope_presentation_failure(line)
if telemetry is not None and not dry_run and presentation_failure is not None:
with launch_success_lock:
@@ -271,6 +283,9 @@ def play_locked(
diagnostic_session_id = window_diagnostic.get("session_id")
if launch_session_id is None or diagnostic_session_id != launch_session_id:
return line
if window_diagnostic_log is not None:
with launch_success_lock:
window_diagnostic_log.record(window_diagnostic)
if telemetry is not None and window_diagnostics_active:
with launch_success_lock:
telemetry.send_event(
@@ -386,6 +401,7 @@ def play_locked(
game_started_ns,
)
runtime_issues = api._runtime_issue_summary(outcome.stderr)
runtime_issues.update(observed_gamescope_exit)
retained_readiness_signal = api._game_readiness_signal(outcome.stderr)
game_readiness_signal = observed_readiness_signal or retained_readiness_signal
game_window_ready = game_readiness_signal is not None
@@ -400,7 +416,8 @@ def play_locked(
)
if recommended_comparison is not None:
runtime_issues["recommended_one_variable_comparison"] = recommended_comparison
clean_exit = outcome.returncode == 0 and current_game_crash is None
game_status_missing = runtime_issues.get("gamescope_child_status_missing") is True
clean_exit = outcome.returncode == 0 and current_game_crash is None and not game_status_missing
session_issue = game_log_issue_reason(current_game_log)
launch_evidence = game_readiness_signal
if launch_evidence is None and current_game_log is not None:
@@ -432,6 +449,11 @@ def play_locked(
"current_game_log": current_game_log,
"current_game_crash": current_game_crash,
"runtime_issues": runtime_issues,
"window_diagnostics_log": (
str(window_diagnostic_log.path)
if window_diagnostic_log is not None and window_diagnostic_log.path.is_file()
else None
),
}
game_runtime_seconds = max(0.0, time.monotonic() - game_started_at)
if telemetry is not None and not dry_run:
@@ -449,8 +471,16 @@ def play_locked(
"session_duration_class": api._session_duration_class(game_runtime_seconds),
"runtime": selection.kind,
"realm": start_option.name,
**api._game_exit_metadata(outcome.returncode, selection.kind),
**api._game_exit_metadata(
outcome.returncode, "gamescope" if game_status_missing else selection.kind,
),
}
if game_status_missing:
common_game_metadata.update({
"game_exit_status_available": False,
"exit_status_source": "gamescope_supervisor",
"gamescope_exit_code": runtime_issues.get("gamescope_exit_code"),
})
if not clean_exit or session_issue is not None:
common_game_metadata["failure_reason"] = game_failure_reason(
outcome,
@@ -509,6 +539,8 @@ def play_locked(
if current_game_crash is not None
else "Game session timed out"
if outcome.timed_out
else gamescope_exit_message(runtime_issues)
if game_status_missing
else f"Game client exited with status {outcome.returncode}"
),
metadata={
@@ -531,6 +563,8 @@ def play_locked(
exit_message = (
"Game client exited after logging Direct3D resource exhaustion"
)
elif game_status_missing:
exit_message = gamescope_exit_message(runtime_issues)
elif common_game_metadata.get("signal_inferred") is True:
signal_wrapper = (
"Proton" if selection.kind == "proton7" else "UMU"
@@ -580,7 +614,9 @@ def play_locked(
runtime_issues,
current_game_crash,
)
return 0 if outcome.returncode == 0 and current_game_crash is None else 1
if not clean_exit and report["window_diagnostics_log"] is not None:
print(f"Window diagnostics: {report['window_diagnostics_log']}", file=sys.stderr)
return 0 if clean_exit else 1
def session_duration_class(duration_seconds: float) -> str:
@@ -686,6 +722,11 @@ def game_failure_reason(
return "client_crash_dump"
if outcome.timed_out:
return "session_timeout"
if (
exit_metadata.get("game_exit_status_available") is False
or "gamescope exited without a recorded game status" in outcome.stderr.casefold()
):
return "gamescope_game_status_missing"
if "signal" in exit_metadata:
return "process_signal"
return "nonzero_exit"
@@ -923,6 +964,20 @@ def crash_dump_telemetry_summary(report: dict[str, object]) -> dict[str, object]
return {key: report[key] for key in keys if key in report}
def gamescope_exit_summary(stderr: str) -> dict[str, object]:
folded = stderr.casefold()
gamescope_status = re.search(
r"gamescope exited without a recorded game status \(compositor status (-?\d{1,3})\)",
folded,
)
return {
"gamescope_child_status_missing": (
"gamescope exited without a recorded game status" in folded
),
"gamescope_exit_code": int(gamescope_status[1]) if gamescope_status is not None else None,
}
def runtime_issue_summary(stderr: str) -> dict[str, object]:
folded = stderr.casefold()
futex_message = "the futex facility returned an unexpected error code"
@@ -965,9 +1020,7 @@ def runtime_issue_summary(stderr: str) -> dict[str, object]:
"gamescope_forced_cleanup": (
"gamescope did not stop gracefully; forcing cleanup" in folded
),
"gamescope_child_status_missing": (
"gamescope exited without a recorded game status" in folded
),
**gamescope_exit_summary(stderr),
"proton_game_drive_warning": "unable to use parent for game drive" in folded,
"steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded,
"dbghelp_symdeferred_assertion_count": folded.count(dbghelp_assertion),
@@ -995,6 +1048,9 @@ def game_window_was_ready(stderr: str) -> bool:
def game_readiness_signal(output: str) -> str | None:
folded = output.casefold()
# Older supervisors used a readiness prefix without observing a window.
# Preserve their UI fallback without treating it as successful presentation.
folded = folded.replace("game window ready: unmanaged ", "unmonitored game process: ")
for signal_name, marker in (
("window_ready", "game window ready:"),
("rendered_frame", "rendered game frame:"),
@@ -1035,6 +1091,14 @@ def window_diagnostic_severity(diagnostic: dict[str, object]) -> str:
return "debug"
def gamescope_exit_message(runtime_issues: dict[str, object]) -> str:
message = "Game session ended before the game reported its exit status"
status = runtime_issues.get("gamescope_exit_code")
if isinstance(status, int):
message += f" (Gamescope status {status})"
return message
def print_game_outcome(
outcome: CommandOutcome,
current_game_log: dict[str, object] | None,
@@ -1063,6 +1127,8 @@ def print_game_outcome(
"exhaustion.",
file=sys.stderr,
)
elif runtime_issues.get("gamescope_child_status_missing") is True:
print(gamescope_exit_message(runtime_issues) + ".", file=sys.stderr)
elif outcome.returncode == 0:
print("Game client exited normally.")
elif outcome.returncode is None:

View File

@@ -0,0 +1,53 @@
"""Parse the publisher's Windows argument string without invoking a shell."""
from __future__ import annotations
def split_windows_arguments(command_line: str) -> tuple[str, ...]:
"""Decode MS C runtime argument quoting (the executable is supplied separately).
Backslashes are literal except before double quotes; single quotes and shell
metacharacters are ordinary characters. Empty quoted arguments are retained.
See https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments
"""
if any(ord(character) < 32 and character != "\t" for character in command_line):
raise ValueError("launch arguments contain control characters")
arguments: list[str] = []
index = 0
while index < len(command_line):
while index < len(command_line) and command_line[index] in " \t":
index += 1
if index == len(command_line):
break
argument: list[str] = []
quoted = False
while index < len(command_line):
character = command_line[index]
if character in " \t" and not quoted:
break
if character == "\\":
start = index
while index < len(command_line) and command_line[index] == "\\":
index += 1
count = index - start
if index == len(command_line) or command_line[index] != '"':
argument.append("\\" * count)
continue
argument.append("\\" * (count // 2))
if count % 2:
argument.append('"')
index += 1
continue
character = '"'
if character == '"':
if quoted and command_line[index:index + 2] == '""':
argument.append('"')
index += 2
continue
quoted = not quoted
else:
argument.append(character)
index += 1
arguments.append("".join(argument))
return tuple(arguments)

View File

@@ -0,0 +1,121 @@
"""Recover a usable GUI interpreter and report missing Tk without a Tk window."""
from __future__ import annotations
from importlib import import_module
import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Sequence
class GuiDependencyError(RuntimeError):
"""The current Python installation cannot provide the dashboard toolkit."""
def tkinter_path() -> str | None:
"""Check the Python package, extension, and Tcl data without opening a display."""
try:
tk = import_module("tkinter")
import_module("tkinter.ttk")
import_module("tkinter.filedialog")
tk.Tcl()
return tk.__file__ or "available"
except (ImportError, OSError, RuntimeError):
return None
except Exception as exc:
# TclError is only available after a successful tkinter import.
if type(exc).__name__ == "TclError":
return None
raise
def recover_gui_python(arguments: Sequence[str]) -> bool:
"""Use the distribution's Python if a PATH/venv Python has no usable Tk."""
if tkinter_path() is not None:
return True
if os.environ.get("PTLINUX_PYTHON"):
return False
candidate = Path("/usr/bin/python3")
if not candidate.is_file() or os.environ.get("PTLINUX_GUI_PYTHON_RETRIED") == "1":
return False
# A venv may share the same binary but have different package search paths.
if str(candidate) == sys.executable and sys.prefix == sys.base_prefix:
return False
probe = (
"import sys; assert sys.version_info >= (3, 11); "
"import tkinter; from tkinter import ttk, filedialog; tkinter.Tcl()"
)
try:
result = subprocess.run(
[str(candidate), "-I", "-B", "-c", probe],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=5, check=False,
)
if result.returncode != 0:
return False
environment = os.environ.copy()
# Preserve the selected release when restarting from python -m or a
# desktop entry. Ignore Python-home settings belonging to the failed SDK.
environment.pop("PYTHONHOME", None)
environment["PYTHONPATH"] = str(Path(__file__).resolve().parent.parent)
environment["PYTHONSAFEPATH"] = "1"
environment["PYTHONDONTWRITEBYTECODE"] = "1"
environment["PTLINUX_GUI_PYTHON_RETRIED"] = "1"
os.execve(str(candidate), [str(candidate), "-B", "-P", "-m", "ptlinux", *arguments], environment)
except (OSError, subprocess.TimeoutExpired):
return False
return False
def missing_tk_message() -> str:
from .system_identity import detect_distribution_id
distribution = detect_distribution_id()
if distribution in {"ubuntu", "debian", "linuxmint", "pop", "elementary", "zorin"}:
repair = "Install python3-tk with your package manager (sudo apt install python3-tk)."
elif distribution in {"fedora", "nobara"}:
repair = "Install python3-tkinter with your package manager."
elif distribution in {"arch", "cachyos", "endeavouros", "garuda", "manjaro"}:
repair = "Install tk with your package manager."
elif distribution == "steamos":
repair = "Use a Python installation that includes tkinter and Tcl/Tk."
else:
repair = "Install the Tk package matching your Python version with your distribution's package manager."
return (
f"Python Tk is unavailable for {sys.executable} (Python {sys.version_info.major}.{sys.version_info.minor}). "
f"{repair} If you selected a custom Python, install its matching Tk support or "
"start with PTLINUX_PYTHON=/usr/bin/python3. "
"The pristontale-eu command-line interface remains available."
)
def show_gui_dependency_error(message: str) -> None:
"""Give desktop launches an actionable message even when Tk cannot import."""
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
return
for name, arguments in (
("kdialog", ["--title", "PristonTale EU", "--error", message]),
("zenity", ["--error", "--title=PristonTale EU", "--text", message]),
("notify-send", ["--urgency=critical", "PristonTale EU", message]),
):
executable = shutil.which(name)
if executable is None:
continue
try:
result = subprocess.run(
[executable, *arguments], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=60, check=False,
)
if result.returncode == 0:
return
except subprocess.TimeoutExpired:
return
except OSError:
continue

View File

@@ -2605,7 +2605,7 @@ def run_game_window(
raise
if mapper is None:
print(
f"Game window ready: unmanaged {window_title}",
f"Game window monitoring unavailable; process started: {window_title}",
file=sys.stderr,
flush=True,
)

View File

@@ -336,8 +336,10 @@ def _supervised_command(
str(command[0]),
"--backend",
backend,
"--keep-alive",
]
# The child records its status before exiting. Leave Gamescope's normal
# child-exit shutdown enabled so a reporting failure (for example ENOSPC)
# cannot strand an empty compositor with no status for us to observe.
if backend == "sdl" or force_composition:
result.append("--force-composition")
result.extend(map(str, command[1:]))
@@ -611,20 +613,19 @@ def _wait_for_optimized_gamescope(
child_status = (
_read_child_status(status_file) if status_file is not None else None
)
if status_file is not None and child_status is None:
if child_status is None:
# A compositor can exit with 0 when its host window closes,
# even though its nested game was killed. Only the game's
# recorded status can establish a clean game exit.
print(
"Gamescope exited without a recorded game status; "
"the game session was interrupted.",
"Gamescope exited without a recorded game status "
f"(compositor status {gamescope_status}); "
"the game's exit result is unknown.",
file=sys.stderr,
flush=True,
)
return _process_exit_status(gamescope_status) or 1
return _process_exit_status(
gamescope_status if child_status is None else child_status
)
return _process_exit_status(child_status)
time.sleep(CHILD_STATUS_POLL_SECONDS)
@@ -880,104 +881,104 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
diagnostics.close(process_started=False, backend=backend)
raise GamescopeRuntimeError(f"could not start Gamescope: {exc}") from exc
if kde_wayland_xwayland_fallback:
print(
"KDE Wayland compatibility mode active: Gamescope is using its "
"SDL host window through XWayland to avoid KWin presentation, "
"fullscreen, and fractional-scaling failures.",
file=sys.stderr,
flush=True,
)
elif cinnamon_wayland_xwayland_fallback:
print(
"Cinnamon Wayland compatibility mode active: Gamescope is using "
"its resizable SDL host through XWayland so Muffin provides the "
"native minimize, maximize, and close controls.",
file=sys.stderr,
flush=True,
)
if backend == "wayland" and not force_composition:
message = (
"Gamescope resize optimization active: native Wayland direct "
"presentation keeps live host-window changes off the game surface."
)
if using_bundled_gamescope:
message += (
" Launcher-local lazy-output, live-resize, letterbox backing, "
"decoration-input, and login-window visibility fixes are active."
)
print(message, file=sys.stderr, flush=True)
elif backend == "wayland" and kde_session:
print(
"KDE Wayland compatibility mode active: Gamescope is using its "
"composed native fallback because no XWayland display is available.",
file=sys.stderr,
flush=True,
)
elif backend == "wayland":
print(
"Warning: this Gamescope build lacks safe direct Wayland letterbox "
"backing. Forced composition prevents flicker, but live resizing may "
"pause; install Gamescope 3.16.28 or newer for direct presentation.",
file=sys.stderr,
flush=True,
)
elif mapper is not None:
try:
if diagnostic_sink is None:
increments = _tune_process_window(
mapper,
process,
width,
height,
host_focus_publisher=host_focus_publisher,
)
else:
increments = _tune_process_window(
mapper,
process,
width,
height,
host_focus_publisher=host_focus_publisher,
diagnostics=diagnostic_sink,
)
except Exception as exc:
increments = None
diagnostics.record(
"host_scaling_configuration_failed",
error_type=type(exc).__name__,
)
print(
f"Warning: Gamescope resize tuning failed: {exc}; "
"continuing normally.",
file=sys.stderr,
flush=True,
)
if increments is not None:
print(
"Gamescope resize optimization active: the host window remains "
"composited and size changes use "
f"{increments[0]}x{increments[1]} pixel steps.",
file=sys.stderr,
flush=True,
)
elif process.poll() is None:
diagnostics.record("host_scaling_unavailable")
print(
"Warning: Gamescope's host window could not be resize-tuned; "
"continuing normally.",
file=sys.stderr,
flush=True,
)
else:
diagnostics.record("host_x11_monitor_unavailable")
print(
"Gamescope is using its SDL presentation fallback; host X11 resize "
"tuning is unavailable.",
file=sys.stderr,
flush=True,
)
try:
if kde_wayland_xwayland_fallback:
print(
"KDE Wayland compatibility mode active: Gamescope is using its "
"SDL host window through XWayland to avoid KWin presentation, "
"fullscreen, and fractional-scaling failures.",
file=sys.stderr,
flush=True,
)
elif cinnamon_wayland_xwayland_fallback:
print(
"Cinnamon Wayland compatibility mode active: Gamescope is using "
"its resizable SDL host through XWayland so Muffin provides the "
"native minimize, maximize, and close controls.",
file=sys.stderr,
flush=True,
)
if backend == "wayland" and not force_composition:
message = (
"Gamescope resize optimization active: native Wayland direct "
"presentation keeps live host-window changes off the game surface."
)
if using_bundled_gamescope:
message += (
" Launcher-local lazy-output, live-resize, letterbox backing, "
"decoration-input, and login-window visibility fixes are active."
)
print(message, file=sys.stderr, flush=True)
elif backend == "wayland" and kde_session:
print(
"KDE Wayland compatibility mode active: Gamescope is using its "
"composed native fallback because no XWayland display is available.",
file=sys.stderr,
flush=True,
)
elif backend == "wayland":
print(
"Warning: this Gamescope build lacks safe direct Wayland letterbox "
"backing. Forced composition prevents flicker, but live resizing may "
"pause; install Gamescope 3.16.28 or newer for direct presentation.",
file=sys.stderr,
flush=True,
)
elif mapper is not None:
try:
if diagnostic_sink is None:
increments = _tune_process_window(
mapper,
process,
width,
height,
host_focus_publisher=host_focus_publisher,
)
else:
increments = _tune_process_window(
mapper,
process,
width,
height,
host_focus_publisher=host_focus_publisher,
diagnostics=diagnostic_sink,
)
except Exception as exc:
increments = None
diagnostics.record(
"host_scaling_configuration_failed",
error_type=type(exc).__name__,
)
print(
f"Warning: Gamescope resize tuning failed: {exc}; "
"continuing normally.",
file=sys.stderr,
flush=True,
)
if increments is not None:
print(
"Gamescope resize optimization active: the host window remains "
"composited and size changes use "
f"{increments[0]}x{increments[1]} pixel steps.",
file=sys.stderr,
flush=True,
)
elif process.poll() is None:
diagnostics.record("host_scaling_unavailable")
print(
"Warning: Gamescope's host window could not be resize-tuned; "
"continuing normally.",
file=sys.stderr,
flush=True,
)
else:
diagnostics.record("host_x11_monitor_unavailable")
print(
"Gamescope is using its SDL presentation fallback; host X11 resize "
"tuning is unavailable.",
file=sys.stderr,
flush=True,
)
return _wait_for_optimized_gamescope(
process,
status_file,

View File

@@ -208,6 +208,10 @@ def start_operation_process(
)
events.put(("game_started", game_client_id))
if game_client_id is not None and line.startswith(
("Game window monitoring unavailable; process started:", "Game window ready: unmanaged ")
):
events.put(("game_window_unmonitored", game_client_id))
elif game_client_id is not None and line.startswith(
("Game window ready:", "Rendered game frame:")
):
events.put(("game_window_ready", game_client_id))

View File

@@ -1012,6 +1012,7 @@ def run_dashboard(paths: AppPaths) -> None:
ready: bool,
returncode: int | None = None,
cancelled: bool = False,
window_unmonitored: bool = False,
) -> None:
nonlocal active_game_launch_id
nonlocal operation_active, game_window_active
@@ -1037,6 +1038,9 @@ def run_dashboard(paths: AppPaths) -> None:
status.set("Ready for another account.")
progress_text.set("Click Play to open another game window.")
dashboard_chrome.minimize()
elif window_unmonitored:
status.set(f"Client {client_id} started; window readiness could not be checked.")
progress_text.set("Select the game window to continue, or see the activity log for details.")
elif cancelled:
root.lift()
status.set("Game launch cancelled.")
@@ -1166,6 +1170,19 @@ def run_dashboard(paths: AppPaths) -> None:
game_window_active = True
release_game_launch_controls(value, ready=True)
def handle_game_window_unmonitored_event(value: object) -> None:
nonlocal game_window_active
if not (
isinstance(value, int)
and not game_launch_supervisors.is_cancel_requested(value)
):
return
if game_clients.mark_running(value):
game_window_active = True
# Release the modal preloader without claiming a visible window or
# minimizing the dashboard before the user can inspect startup errors.
release_game_launch_controls(value, ready=False, window_unmonitored=True)
def handle_game_done_event(value: object) -> None:
nonlocal game_window_active
if not (
@@ -1366,6 +1383,7 @@ def run_dashboard(paths: AppPaths) -> None:
"game_operation_status": handle_game_operation_status_event,
"game_started": handle_game_started_event,
"game_window_ready": handle_game_window_ready_event,
"game_window_unmonitored": handle_game_window_unmonitored_event,
"game_done": handle_game_done_event,
"broadcast": handle_broadcast_event,
"server_status_check": handle_server_status_event,

View File

@@ -7,6 +7,7 @@ from collections.abc import Callable, Mapping, Sequence
import json
import math
import os
from pathlib import Path
import re
import sys
import time
@@ -33,6 +34,46 @@ def window_diagnostics_enabled(
return env.get(WINDOW_DIAGNOSTICS_ENV, "").strip().casefold() in _TRUE_VALUES
class WindowDiagnosticLog:
"""Retain bounded supervisor evidence independently of remote telemetry."""
def __init__(self, path: Path, session_id: str) -> None:
self.path = path
self.session_id = session_id
self._samples: Counter[str] = Counter()
self._summaries: set[str] = set()
self._failed = False
def record(self, payload: dict[str, object]) -> None:
if self._failed or payload.get("session_id") != self.session_id:
return
component = payload.get("component")
if not isinstance(component, str) or component not in WINDOW_DIAGNOSTIC_COMPONENTS:
return
if payload.get("diagnostic") == "session_summary":
if component in self._summaries:
return
self._summaries.add(component)
elif self._samples[component] >= WINDOW_DIAGNOSTIC_EVENT_LIMIT:
return
else:
self._samples[component] += 1
try:
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
if len(encoded.encode("utf-8")) > WINDOW_DIAGNOSTIC_LINE_LIMIT:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
descriptor = os.open(
self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600,
)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
stream.write(encoded + "\n")
except (OSError, TypeError, ValueError):
# Logging must never interrupt the game or repeatedly fill stderr.
self._failed = True
print("Warning: window diagnostics could not be saved locally.", file=sys.stderr)
class WindowDiagnosticRecorder:
"""Emit a few samples while retaining aggregate counts for session shutdown."""