Fixed issues with telemetry.
This commit is contained in:
198
ptlinux/cli.py
198
ptlinux/cli.py
@@ -9,7 +9,9 @@ from datetime import datetime
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Callable, Sequence
|
from typing import Callable, Sequence
|
||||||
|
|
||||||
@@ -1379,6 +1381,8 @@ def _play_locked(
|
|||||||
)
|
)
|
||||||
game_started_ns = time.time_ns()
|
game_started_ns = time.time_ns()
|
||||||
game_started_at = time.monotonic()
|
game_started_at = time.monotonic()
|
||||||
|
launch_success_lock = threading.Lock()
|
||||||
|
launch_success_reported = False
|
||||||
if telemetry is not None and not dry_run:
|
if telemetry is not None and not dry_run:
|
||||||
telemetry.send_event(
|
telemetry.send_event(
|
||||||
"game_launch_started",
|
"game_launch_started",
|
||||||
@@ -1399,6 +1403,36 @@ def _play_locked(
|
|||||||
if depot_file_catalog is not None
|
if depot_file_catalog is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def observe_game_output(line: str) -> str:
|
||||||
|
"""Timestamp launch success when the supervisor first confirms readiness."""
|
||||||
|
|
||||||
|
nonlocal launch_success_reported
|
||||||
|
live_readiness_signal = _game_readiness_signal(line)
|
||||||
|
if telemetry is None or dry_run or live_readiness_signal is None:
|
||||||
|
return line
|
||||||
|
with launch_success_lock:
|
||||||
|
if launch_success_reported:
|
||||||
|
return line
|
||||||
|
ready_at = time.monotonic()
|
||||||
|
launch_success_reported = telemetry.send_event(
|
||||||
|
"game_launch_success",
|
||||||
|
metadata={
|
||||||
|
"launch_duration_ms": round(
|
||||||
|
(ready_at - launch_requested_at) * 1000
|
||||||
|
),
|
||||||
|
"game_start_duration_ms": round(
|
||||||
|
(ready_at - game_started_at) * 1000
|
||||||
|
),
|
||||||
|
"game_window_ready": True,
|
||||||
|
"readiness_signal": live_readiness_signal,
|
||||||
|
"runtime": selection.kind,
|
||||||
|
"realm": start_option.name,
|
||||||
|
},
|
||||||
|
context={"operation": "launch_game", "stage": "ready"},
|
||||||
|
)
|
||||||
|
return line
|
||||||
|
|
||||||
try:
|
try:
|
||||||
outcome = run_game(
|
outcome = run_game(
|
||||||
paths,
|
paths,
|
||||||
@@ -1409,6 +1443,7 @@ def _play_locked(
|
|||||||
use_gamebox=not no_box,
|
use_gamebox=not no_box,
|
||||||
concurrent_prefix_launch=reuse_prepared,
|
concurrent_prefix_launch=reuse_prepared,
|
||||||
check=False,
|
check=False,
|
||||||
|
output_line_filter=observe_game_output,
|
||||||
pass_fds=game_lease_fds,
|
pass_fds=game_lease_fds,
|
||||||
)
|
)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -1449,6 +1484,7 @@ def _play_locked(
|
|||||||
game_started_ns,
|
game_started_ns,
|
||||||
)
|
)
|
||||||
runtime_issues = _runtime_issue_summary(outcome.stderr)
|
runtime_issues = _runtime_issue_summary(outcome.stderr)
|
||||||
|
game_window_ready = runtime_issues["game_window_ready"] is True
|
||||||
if (
|
if (
|
||||||
selection.kind == "proton7-container"
|
selection.kind == "proton7-container"
|
||||||
and runtime_issues["host_futex_fatal_count"]
|
and runtime_issues["host_futex_fatal_count"]
|
||||||
@@ -1458,8 +1494,13 @@ def _play_locked(
|
|||||||
runtime_issues["recommended_one_variable_comparison"] = "proton7-container"
|
runtime_issues["recommended_one_variable_comparison"] = "proton7-container"
|
||||||
elif runtime_issues["dbghelp_symdeferred_assertion_count"]:
|
elif runtime_issues["dbghelp_symdeferred_assertion_count"]:
|
||||||
runtime_issues["recommended_one_variable_comparison"] = "umu"
|
runtime_issues["recommended_one_variable_comparison"] = "umu"
|
||||||
|
clean_exit = outcome.returncode == 0 and current_game_crash is None
|
||||||
|
launch_succeeded = (
|
||||||
|
clean_exit or game_window_ready or current_game_log is not None
|
||||||
|
)
|
||||||
report = {
|
report = {
|
||||||
"ok": outcome.returncode == 0 and current_game_crash is None,
|
"ok": clean_exit,
|
||||||
|
"launch_succeeded": launch_succeeded,
|
||||||
"dry_run": dry_run,
|
"dry_run": dry_run,
|
||||||
"runtime": asdict(selection),
|
"runtime": asdict(selection),
|
||||||
"umu_bootstrap": asdict(umu_bootstrap) if umu_bootstrap else None,
|
"umu_bootstrap": asdict(umu_bootstrap) if umu_bootstrap else None,
|
||||||
@@ -1481,22 +1522,40 @@ def _play_locked(
|
|||||||
"runtime_issues": runtime_issues,
|
"runtime_issues": runtime_issues,
|
||||||
}
|
}
|
||||||
game_runtime_seconds = max(0.0, time.monotonic() - game_started_at)
|
game_runtime_seconds = max(0.0, time.monotonic() - game_started_at)
|
||||||
clean_exit = outcome.returncode == 0 and current_game_crash is None
|
|
||||||
if telemetry is not None and not dry_run:
|
if telemetry is not None and not dry_run:
|
||||||
common_game_metadata = {
|
common_game_metadata: dict[str, object] = {
|
||||||
"runtime_seconds": round(game_runtime_seconds),
|
"runtime_seconds": round(game_runtime_seconds),
|
||||||
"exit_code": outcome.returncode,
|
|
||||||
"clean_exit": clean_exit,
|
"clean_exit": clean_exit,
|
||||||
|
"timed_out": outcome.timed_out,
|
||||||
|
"launch_succeeded": launch_succeeded,
|
||||||
|
"game_window_ready": game_window_ready,
|
||||||
|
"crash_dump": current_game_crash is not None,
|
||||||
"session_duration_class": _session_duration_class(game_runtime_seconds),
|
"session_duration_class": _session_duration_class(game_runtime_seconds),
|
||||||
"runtime": selection.kind,
|
"runtime": selection.kind,
|
||||||
"realm": start_option.name,
|
"realm": start_option.name,
|
||||||
|
**_game_exit_metadata(outcome.returncode, selection.kind),
|
||||||
}
|
}
|
||||||
if clean_exit:
|
if launch_succeeded:
|
||||||
telemetry.send_event(
|
if not launch_success_reported:
|
||||||
"game_launch_success",
|
readiness_signal = _game_readiness_signal(outcome.stderr)
|
||||||
metadata=common_game_metadata,
|
if readiness_signal is None:
|
||||||
context={"operation": "launch_game", "stage": "completed"},
|
readiness_signal = (
|
||||||
)
|
"game_log"
|
||||||
|
if current_game_log is not None
|
||||||
|
else "clean_process_exit"
|
||||||
|
)
|
||||||
|
telemetry.send_event(
|
||||||
|
"game_launch_success",
|
||||||
|
metadata={
|
||||||
|
"detection_duration_ms": round(game_runtime_seconds * 1000),
|
||||||
|
"detected_after_exit": True,
|
||||||
|
"game_window_ready": game_window_ready,
|
||||||
|
"readiness_signal": readiness_signal,
|
||||||
|
"runtime": selection.kind,
|
||||||
|
"realm": start_option.name,
|
||||||
|
},
|
||||||
|
context={"operation": "launch_game", "stage": "post_exit"},
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
telemetry.send_event(
|
telemetry.send_event(
|
||||||
"game_launch_failed",
|
"game_launch_failed",
|
||||||
@@ -1504,17 +1563,57 @@ def _play_locked(
|
|||||||
message=(
|
message=(
|
||||||
"Game client wrote a crash dump"
|
"Game client wrote a crash dump"
|
||||||
if current_game_crash is not None
|
if current_game_crash is not None
|
||||||
|
else "Game session timed out"
|
||||||
|
if outcome.timed_out
|
||||||
else f"Game client exited with status {outcome.returncode}"
|
else f"Game client exited with status {outcome.returncode}"
|
||||||
),
|
),
|
||||||
metadata={**common_game_metadata, "runtime_issues": runtime_issues},
|
metadata={
|
||||||
context={"operation": "launch_game", "stage": "running"},
|
**common_game_metadata,
|
||||||
|
"runtime_issues": runtime_issues,
|
||||||
|
"stdout": outcome.stdout,
|
||||||
|
"stderr": outcome.stderr,
|
||||||
|
},
|
||||||
|
context={"operation": "launch_game", "stage": "game_start"},
|
||||||
|
)
|
||||||
|
if launch_succeeded:
|
||||||
|
exit_message = None
|
||||||
|
if current_game_crash is not None:
|
||||||
|
exit_message = "Game client wrote a crash dump"
|
||||||
|
elif outcome.timed_out:
|
||||||
|
exit_message = "Game session timed out"
|
||||||
|
elif common_game_metadata.get("signal_inferred") is True:
|
||||||
|
exit_message = (
|
||||||
|
"Game session ended with status "
|
||||||
|
f"{common_game_metadata['raw_exit_code']}, consistent with "
|
||||||
|
f"{common_game_metadata['signal_name']} wrapped by UMU"
|
||||||
|
)
|
||||||
|
elif "signal" in common_game_metadata:
|
||||||
|
exit_message = (
|
||||||
|
"Game session was terminated by "
|
||||||
|
f"{common_game_metadata['signal_name']}"
|
||||||
|
)
|
||||||
|
elif outcome.returncode not in {0, None}:
|
||||||
|
exit_message = f"Game session ended with status {outcome.returncode}"
|
||||||
|
telemetry.send_event(
|
||||||
|
"game_exited",
|
||||||
|
severity=(
|
||||||
|
"error"
|
||||||
|
if current_game_crash is not None
|
||||||
|
else "info" if clean_exit else "warning"
|
||||||
|
),
|
||||||
|
message=exit_message,
|
||||||
|
metadata=(
|
||||||
|
common_game_metadata
|
||||||
|
if clean_exit
|
||||||
|
else {
|
||||||
|
**common_game_metadata,
|
||||||
|
"runtime_issues": runtime_issues,
|
||||||
|
"stdout": outcome.stdout,
|
||||||
|
"stderr": outcome.stderr,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
context={"operation": "launch_game", "stage": "exited"},
|
||||||
)
|
)
|
||||||
telemetry.send_event(
|
|
||||||
"game_exited",
|
|
||||||
severity="info" if clean_exit else "warning",
|
|
||||||
metadata=common_game_metadata,
|
|
||||||
context={"operation": "launch_game", "stage": "exited"},
|
|
||||||
)
|
|
||||||
if as_json or dry_run:
|
if as_json or dry_run:
|
||||||
_emit(report, as_json)
|
_emit(report, as_json)
|
||||||
else:
|
else:
|
||||||
@@ -1535,6 +1634,49 @@ def _session_duration_class(duration_seconds: float) -> str:
|
|||||||
return "normal"
|
return "normal"
|
||||||
|
|
||||||
|
|
||||||
|
def _game_exit_metadata(
|
||||||
|
returncode: int | None,
|
||||||
|
runtime_kind: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
metadata: dict[str, object] = {"exit_code": returncode}
|
||||||
|
if returncode is None:
|
||||||
|
return metadata
|
||||||
|
if returncode < 0:
|
||||||
|
signal_number = -returncode
|
||||||
|
metadata.update(
|
||||||
|
{
|
||||||
|
"exit_code": 128 + min(signal_number, 127),
|
||||||
|
"raw_exit_code": returncode,
|
||||||
|
"signal": signal_number,
|
||||||
|
"signal_name": _signal_name(signal_number),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
# UMU returns Popen's negative child status from a Python main function.
|
||||||
|
# SystemExit encodes -SIGKILL as 247 before our Python gamebox sees it.
|
||||||
|
wrapped_sigkill = 256 - int(signal.SIGKILL)
|
||||||
|
if runtime_kind in {"umu", "proton7-container"} and returncode == wrapped_sigkill:
|
||||||
|
metadata.update(
|
||||||
|
{
|
||||||
|
"exit_code": 128 + int(signal.SIGKILL),
|
||||||
|
"raw_exit_code": returncode,
|
||||||
|
"signal": int(signal.SIGKILL),
|
||||||
|
"signal_name": signal.SIGKILL.name,
|
||||||
|
"signal_inferred": True,
|
||||||
|
"signal_inference": "umu_python_system_exit",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_name(signal_number: int) -> str:
|
||||||
|
try:
|
||||||
|
return signal.Signals(signal_number).name
|
||||||
|
except ValueError:
|
||||||
|
return f"signal {signal_number}"
|
||||||
|
|
||||||
|
|
||||||
def _ensure_portable_umu_for_play(
|
def _ensure_portable_umu_for_play(
|
||||||
paths: AppPaths,
|
paths: AppPaths,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
@@ -1684,6 +1826,7 @@ def _runtime_issue_summary(stderr: str) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
|
"game_window_ready": _game_window_was_ready(folded),
|
||||||
"host_futex_fatal_count": folded.count(futex_message),
|
"host_futex_fatal_count": folded.count(futex_message),
|
||||||
"gstreamer_wrong_elf_class_count": folded.count(wrong_elf_message),
|
"gstreamer_wrong_elf_class_count": folded.count(wrong_elf_message),
|
||||||
"gstreamer_missing_library_count": len(missing_libraries),
|
"gstreamer_missing_library_count": len(missing_libraries),
|
||||||
@@ -1696,6 +1839,25 @@ def _runtime_issue_summary(stderr: str) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _game_window_was_ready(stderr: str) -> bool:
|
||||||
|
return _game_readiness_signal(stderr) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _game_readiness_signal(output: str) -> str | None:
|
||||||
|
folded = output.casefold()
|
||||||
|
for signal_name, marker in (
|
||||||
|
("window_ready", "game window ready:"),
|
||||||
|
("rendered_frame", "rendered game frame:"),
|
||||||
|
(
|
||||||
|
"window_supervision",
|
||||||
|
"game window resizing is disabled to prevent repeated direct3d device resets",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
if marker in folded:
|
||||||
|
return signal_name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _print_game_outcome(
|
def _print_game_outcome(
|
||||||
outcome: CommandOutcome,
|
outcome: CommandOutcome,
|
||||||
current_game_log: dict[str, object] | None,
|
current_game_log: dict[str, object] | None,
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ class CommandRunner:
|
|||||||
output_line_filter: OutputLineFilter | None = None,
|
output_line_filter: OutputLineFilter | None = None,
|
||||||
pass_fds: Sequence[int] = (),
|
pass_fds: Sequence[int] = (),
|
||||||
telemetry_stage: str | None = None,
|
telemetry_stage: str | None = None,
|
||||||
|
report_failure_telemetry: bool = True,
|
||||||
) -> CommandOutcome:
|
) -> CommandOutcome:
|
||||||
arguments = [str(value) for value in argv]
|
arguments = [str(value) for value in argv]
|
||||||
if not arguments:
|
if not arguments:
|
||||||
@@ -179,7 +180,9 @@ class CommandRunner:
|
|||||||
dry_run=False,
|
dry_run=False,
|
||||||
)
|
)
|
||||||
self._record(outcome, cwd, overrides)
|
self._record(outcome, cwd, overrides)
|
||||||
if outcome.timed_out or outcome.returncode != 0:
|
if report_failure_telemetry and (
|
||||||
|
outcome.timed_out or outcome.returncode != 0
|
||||||
|
):
|
||||||
telemetry = current_telemetry()
|
telemetry = current_telemetry()
|
||||||
if telemetry is not None:
|
if telemetry is not None:
|
||||||
telemetry.report_subprocess_failure(
|
telemetry.report_subprocess_failure(
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ DEFAULT_TELEMETRY_ENDPOINT = "https://pristontale.eu/api/launcher/telemetry"
|
|||||||
TELEMETRY_ENDPOINT_VARIABLE = "PTLINUX_TELEMETRY_ENDPOINT"
|
TELEMETRY_ENDPOINT_VARIABLE = "PTLINUX_TELEMETRY_ENDPOINT"
|
||||||
TELEMETRY_KEY_VARIABLE = "PTLINUX_TELEMETRY_KEY"
|
TELEMETRY_KEY_VARIABLE = "PTLINUX_TELEMETRY_KEY"
|
||||||
TELEMETRY_DEBUG_VARIABLE = "PTLINUX_TELEMETRY_DEBUG"
|
TELEMETRY_DEBUG_VARIABLE = "PTLINUX_TELEMETRY_DEBUG"
|
||||||
|
TELEMETRY_PROCESS_ROLE_VARIABLE = "PTLINUX_TELEMETRY_PROCESS_ROLE"
|
||||||
|
TELEMETRY_OPERATION_PROCESS_ROLE = "operation"
|
||||||
|
|
||||||
MAX_REQUEST_BYTES = 64 * 1024
|
MAX_REQUEST_BYTES = 64 * 1024
|
||||||
MAX_MESSAGE_BYTES = 10 * 1024
|
MAX_MESSAGE_BYTES = 10 * 1024
|
||||||
@@ -263,9 +265,14 @@ class TelemetryService:
|
|||||||
self._session_metadata = dict(session_metadata or {})
|
self._session_metadata = dict(session_metadata or {})
|
||||||
self._monotonic = monotonic
|
self._monotonic = monotonic
|
||||||
self._started_at = monotonic()
|
self._started_at = monotonic()
|
||||||
|
self._reports_launcher_lifecycle = (
|
||||||
|
self._environment.get(TELEMETRY_PROCESS_ROLE_VARIABLE, "").strip().casefold()
|
||||||
|
!= TELEMETRY_OPERATION_PROCESS_ROLE
|
||||||
|
)
|
||||||
self._enabled = bool(enabled)
|
self._enabled = bool(enabled)
|
||||||
self._active = False
|
self._active = False
|
||||||
self._accepting = False
|
self._accepting = False
|
||||||
|
self._closing = False
|
||||||
self._ready = False
|
self._ready = False
|
||||||
self._first_run = False
|
self._first_run = False
|
||||||
self._installation_id: str | None = None
|
self._installation_id: str | None = None
|
||||||
@@ -322,6 +329,7 @@ class TelemetryService:
|
|||||||
self._base_payload = self._collect_common_payload(first_seen)
|
self._base_payload = self._collect_common_payload(first_seen)
|
||||||
self._stop.clear()
|
self._stop.clear()
|
||||||
self._accepting = True
|
self._accepting = True
|
||||||
|
self._closing = False
|
||||||
self._active = True
|
self._active = True
|
||||||
worker = threading.Thread(
|
worker = threading.Thread(
|
||||||
target=self._worker_loop,
|
target=self._worker_loop,
|
||||||
@@ -338,15 +346,16 @@ class TelemetryService:
|
|||||||
self._debug_log(f"initialization failed: {type(exc).__name__}")
|
self._debug_log(f"initialization failed: {type(exc).__name__}")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.send_event(
|
if self._reports_launcher_lifecycle:
|
||||||
"launcher_started",
|
|
||||||
metadata={"cold_start": True, **self._session_metadata},
|
|
||||||
)
|
|
||||||
if self._first_run:
|
|
||||||
self.send_event(
|
self.send_event(
|
||||||
"first_run_started",
|
"launcher_started",
|
||||||
metadata={"install_type": self._install_type()},
|
metadata={"cold_start": True, **self._session_metadata},
|
||||||
)
|
)
|
||||||
|
if self._first_run:
|
||||||
|
self.send_event(
|
||||||
|
"first_run_started",
|
||||||
|
metadata={"install_type": self._install_type()},
|
||||||
|
)
|
||||||
self._start_environment_collection()
|
self._start_environment_collection()
|
||||||
|
|
||||||
def set_enabled(self, enabled: bool) -> None:
|
def set_enabled(self, enabled: bool) -> None:
|
||||||
@@ -368,6 +377,8 @@ class TelemetryService:
|
|||||||
if self._ready or not self._active:
|
if self._ready or not self._active:
|
||||||
return
|
return
|
||||||
self._ready = True
|
self._ready = True
|
||||||
|
if not self._reports_launcher_lifecycle:
|
||||||
|
return
|
||||||
duration_ms = round((self._monotonic() - self._started_at) * 1000)
|
duration_ms = round((self._monotonic() - self._started_at) * 1000)
|
||||||
self.send_event(
|
self.send_event(
|
||||||
"launcher_ready",
|
"launcher_ready",
|
||||||
@@ -674,11 +685,12 @@ class TelemetryService:
|
|||||||
self._send_repeated_error(state)
|
self._send_repeated_error(state)
|
||||||
|
|
||||||
def shutdown(self, timeout_seconds: float = SHUTDOWN_WAIT_SECONDS) -> None:
|
def shutdown(self, timeout_seconds: float = SHUTDOWN_WAIT_SECONDS) -> None:
|
||||||
"""Queue the closing event and give the daemon worker a bounded flush."""
|
"""Finish this process role and give the daemon worker a bounded flush."""
|
||||||
|
|
||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
if not self._active:
|
if not self._active or self._closing:
|
||||||
return
|
return
|
||||||
|
self._closing = True
|
||||||
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
||||||
environment_worker = self._environment_worker
|
environment_worker = self._environment_worker
|
||||||
if isinstance(environment_worker, threading.Thread) and (
|
if isinstance(environment_worker, threading.Thread) and (
|
||||||
@@ -692,17 +704,18 @@ class TelemetryService:
|
|||||||
)
|
)
|
||||||
self._flush_repeated_errors()
|
self._flush_repeated_errors()
|
||||||
runtime_seconds = max(0, round(self._monotonic() - self._started_at))
|
runtime_seconds = max(0, round(self._monotonic() - self._started_at))
|
||||||
if self._first_run and not self._ready:
|
if self._reports_launcher_lifecycle:
|
||||||
|
if self._first_run and not self._ready:
|
||||||
|
self.send_event(
|
||||||
|
"first_run_failed",
|
||||||
|
severity="error",
|
||||||
|
message="Launcher did not reach the ready state",
|
||||||
|
metadata={"duration_ms": runtime_seconds * 1000},
|
||||||
|
)
|
||||||
self.send_event(
|
self.send_event(
|
||||||
"first_run_failed",
|
"launcher_closed",
|
||||||
severity="error",
|
metadata={"runtime_seconds": runtime_seconds, **self._session_metadata},
|
||||||
message="Launcher did not reach the ready state",
|
|
||||||
metadata={"duration_ms": runtime_seconds * 1000},
|
|
||||||
)
|
)
|
||||||
self.send_event(
|
|
||||||
"launcher_closed",
|
|
||||||
metadata={"runtime_seconds": runtime_seconds, **self._session_metadata},
|
|
||||||
)
|
|
||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
self._accepting = False
|
self._accepting = False
|
||||||
self._stop.set()
|
self._stop.set()
|
||||||
@@ -719,6 +732,7 @@ class TelemetryService:
|
|||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
self._enabled = False
|
self._enabled = False
|
||||||
self._accepting = False
|
self._accepting = False
|
||||||
|
self._closing = True
|
||||||
self._stop.set()
|
self._stop.set()
|
||||||
self._repeated_errors.clear()
|
self._repeated_errors.clear()
|
||||||
while True:
|
while True:
|
||||||
@@ -795,6 +809,11 @@ class TelemetryService:
|
|||||||
self._base_payload[key] = {**existing, **value}
|
self._base_payload[key] = {**existing, **value}
|
||||||
else:
|
else:
|
||||||
self._base_payload[key] = value
|
self._base_payload[key] = value
|
||||||
|
if not self._reports_launcher_lifecycle:
|
||||||
|
# Dashboard operation subprocesses enrich their own events with the
|
||||||
|
# detected environment, while the dashboard remains the sole source
|
||||||
|
# of launcher lifecycle and automatic compatibility events.
|
||||||
|
return
|
||||||
|
|
||||||
runtime = sanitized.get("runtime")
|
runtime = sanitized.get("runtime")
|
||||||
dependencies = sanitized.get("dependencies")
|
dependencies = sanitized.get("dependencies")
|
||||||
@@ -816,14 +835,38 @@ class TelemetryService:
|
|||||||
context={"operation": "launcher", "stage": "environment_detection"},
|
context={"operation": "launcher", "stage": "environment_detection"},
|
||||||
)
|
)
|
||||||
missing_required = dependency_details.get("missing_required")
|
missing_required = dependency_details.get("missing_required")
|
||||||
if dependency_details.get("healthy") is False:
|
generic_dependency_failures = (
|
||||||
|
[
|
||||||
|
str(value)
|
||||||
|
for value in missing_required
|
||||||
|
if isinstance(value, str) and value
|
||||||
|
]
|
||||||
|
if isinstance(missing_required, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
dependency_details.get("container_ready") is False
|
||||||
|
and "container_host" not in generic_dependency_failures
|
||||||
|
):
|
||||||
|
generic_dependency_failures.append("container_host")
|
||||||
|
if (
|
||||||
|
dependency_details.get("healthy") is False
|
||||||
|
and generic_dependency_failures
|
||||||
|
):
|
||||||
self.send_event(
|
self.send_event(
|
||||||
"dependency_check_failed",
|
"dependency_check_failed",
|
||||||
severity="warning",
|
severity="warning",
|
||||||
message="One or more launcher dependencies are unavailable",
|
message="One or more launcher dependencies are unavailable",
|
||||||
metadata={
|
metadata={
|
||||||
"dependencies": (
|
"dependencies": generic_dependency_failures,
|
||||||
missing_required if isinstance(missing_required, list) else []
|
"required_shared_libraries_present": dependency_details.get(
|
||||||
|
"required_shared_libraries_present"
|
||||||
|
),
|
||||||
|
"vulkan_64bit_available": dependency_details.get(
|
||||||
|
"vulkan_64bit_available"
|
||||||
|
),
|
||||||
|
"vulkan_32bit_available": dependency_details.get(
|
||||||
|
"vulkan_32bit_available"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
context={"operation": "launcher", "stage": "dependency_check"},
|
context={"operation": "launcher", "stage": "dependency_check"},
|
||||||
@@ -846,7 +889,17 @@ class TelemetryService:
|
|||||||
self.send_event(
|
self.send_event(
|
||||||
"vulkan_32bit_check_failed",
|
"vulkan_32bit_check_failed",
|
||||||
severity="warning",
|
severity="warning",
|
||||||
metadata={"dependency": "vulkan_32bit", "check": "32bit_vulkan"},
|
metadata={
|
||||||
|
"dependency": "vulkan_32bit",
|
||||||
|
"check": "32bit_vulkan",
|
||||||
|
"loader_available": runtime_details.get(
|
||||||
|
"vulkan_32bit_loader_available"
|
||||||
|
),
|
||||||
|
"icd_available": runtime_details.get(
|
||||||
|
"vulkan_32bit_icd_available"
|
||||||
|
),
|
||||||
|
"detection": runtime_details.get("vulkan_32bit_detection"),
|
||||||
|
},
|
||||||
context={"operation": "launcher", "stage": "vulkan_check"},
|
context={"operation": "launcher", "stage": "vulkan_check"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,11 @@ _VULKAN_MANIFEST_DIRECTORIES = (
|
|||||||
)
|
)
|
||||||
_LIBRARY_ROOTS = (
|
_LIBRARY_ROOTS = (
|
||||||
Path("/lib"),
|
Path("/lib"),
|
||||||
|
Path("/lib32"),
|
||||||
Path("/usr/lib"),
|
Path("/usr/lib"),
|
||||||
|
Path("/usr/lib32"),
|
||||||
Path("/usr/local/lib"),
|
Path("/usr/local/lib"),
|
||||||
|
Path("/usr/local/lib32"),
|
||||||
)
|
)
|
||||||
_PCI_DATABASES = (
|
_PCI_DATABASES = (
|
||||||
Path("/usr/share/hwdata/pci.ids"),
|
Path("/usr/share/hwdata/pci.ids"),
|
||||||
@@ -198,13 +201,12 @@ def _vulkan_info(
|
|||||||
if api_version and _VERSION_PATTERN.fullmatch(api_version):
|
if api_version and _VERSION_PATTERN.fullmatch(api_version):
|
||||||
api_versions.append(api_version)
|
api_versions.append(api_version)
|
||||||
library = _short_string(icd.get("library_path"))
|
library = _short_string(icd.get("library_path"))
|
||||||
architecture = _manifest_library_architecture(
|
architectures = _manifest_library_architectures(
|
||||||
manifest,
|
manifest,
|
||||||
library,
|
library,
|
||||||
library_roots,
|
library_roots,
|
||||||
)
|
)
|
||||||
if architecture is not None:
|
manifest_architectures.update(architectures)
|
||||||
manifest_architectures.add(architecture)
|
|
||||||
|
|
||||||
vulkaninfo = shutil.which("vulkaninfo", path=environment.get("PATH", ""))
|
vulkaninfo = shutil.which("vulkaninfo", path=environment.get("PATH", ""))
|
||||||
probe = (
|
probe = (
|
||||||
@@ -240,6 +242,11 @@ def _vulkan_info(
|
|||||||
"vulkan_available": available_64bit,
|
"vulkan_available": available_64bit,
|
||||||
"vulkan_64bit": available_64bit,
|
"vulkan_64bit": available_64bit,
|
||||||
"vulkan_32bit": available_32bit,
|
"vulkan_32bit": available_32bit,
|
||||||
|
"vulkan_64bit_loader_available": 64 in loader_architectures,
|
||||||
|
"vulkan_64bit_icd_available": 64 in manifest_architectures,
|
||||||
|
"vulkan_64bit_probe_succeeded": functional_64bit,
|
||||||
|
"vulkan_32bit_loader_available": 32 in loader_architectures,
|
||||||
|
"vulkan_32bit_icd_available": 32 in manifest_architectures,
|
||||||
"vulkan_64bit_detection": (
|
"vulkan_64bit_detection": (
|
||||||
"vulkaninfo" if functional_64bit else "loader_and_icd"
|
"vulkaninfo" if functional_64bit else "loader_and_icd"
|
||||||
),
|
),
|
||||||
@@ -860,17 +867,18 @@ def _vulkan_loader_architectures(library_roots: Sequence[Path]) -> set[int]:
|
|||||||
return architectures
|
return architectures
|
||||||
|
|
||||||
|
|
||||||
def _manifest_library_architecture(
|
def _manifest_library_architectures(
|
||||||
manifest: Path,
|
manifest: Path,
|
||||||
library: str | None,
|
library: str | None,
|
||||||
library_roots: Sequence[Path],
|
library_roots: Sequence[Path],
|
||||||
) -> int | None:
|
) -> set[int]:
|
||||||
|
architectures: set[int] = set()
|
||||||
if library:
|
if library:
|
||||||
path = Path(library)
|
path = Path(library)
|
||||||
if path.is_absolute():
|
if path.is_absolute():
|
||||||
architecture = _elf_architecture(path)
|
architecture = _elf_architecture(path)
|
||||||
if architecture is not None:
|
if architecture is not None:
|
||||||
return architecture
|
architectures.add(architecture)
|
||||||
else:
|
else:
|
||||||
candidates = [manifest.parent / path]
|
candidates = [manifest.parent / path]
|
||||||
for root in library_roots:
|
for root in library_roots:
|
||||||
@@ -878,13 +886,15 @@ def _manifest_library_architecture(
|
|||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
architecture = _elf_architecture(candidate)
|
architecture = _elf_architecture(candidate)
|
||||||
if architecture is not None:
|
if architecture is not None:
|
||||||
return architecture
|
architectures.add(architecture)
|
||||||
|
if architectures:
|
||||||
|
return architectures
|
||||||
lowered = manifest.name.casefold()
|
lowered = manifest.name.casefold()
|
||||||
if any(token in lowered for token in ("i386", "i486", "i586", "i686", "x86_32")):
|
if any(token in lowered for token in ("i386", "i486", "i586", "i686", "x86_32")):
|
||||||
return 32
|
return {32}
|
||||||
if any(token in lowered for token in ("x86_64", "amd64")):
|
if any(token in lowered for token in ("x86_64", "amd64")):
|
||||||
return 64
|
return {64}
|
||||||
return None
|
return set()
|
||||||
|
|
||||||
|
|
||||||
def _library_candidates(root: Path, prefix: str) -> tuple[Path, ...]:
|
def _library_candidates(root: Path, prefix: str) -> tuple[Path, ...]:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from .viewmodels.dashboard import (
|
|||||||
game_client_activity_text,
|
game_client_activity_text,
|
||||||
interrupt_game_supervisor,
|
interrupt_game_supervisor,
|
||||||
launcher_child_command,
|
launcher_child_command,
|
||||||
|
launcher_child_environment,
|
||||||
launcher_restart_spec,
|
launcher_restart_spec,
|
||||||
launcher_update_presentation,
|
launcher_update_presentation,
|
||||||
news_card_columns,
|
news_card_columns,
|
||||||
@@ -131,6 +132,7 @@ __all__ = [
|
|||||||
"interrupt_game_supervisor",
|
"interrupt_game_supervisor",
|
||||||
"launch_external_url",
|
"launch_external_url",
|
||||||
"launcher_child_command",
|
"launcher_child_command",
|
||||||
|
"launcher_child_environment",
|
||||||
"launcher_restart_spec",
|
"launcher_restart_spec",
|
||||||
"launcher_update_presentation",
|
"launcher_update_presentation",
|
||||||
"news_card_columns",
|
"news_card_columns",
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ from .viewmodels.dashboard import (
|
|||||||
game_client_activity_text,
|
game_client_activity_text,
|
||||||
interrupt_game_supervisor,
|
interrupt_game_supervisor,
|
||||||
launcher_child_command,
|
launcher_child_command,
|
||||||
|
launcher_child_environment,
|
||||||
launcher_restart_spec,
|
launcher_restart_spec,
|
||||||
launcher_update_presentation,
|
launcher_update_presentation,
|
||||||
news_card_columns,
|
news_card_columns,
|
||||||
@@ -1064,7 +1065,8 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
)
|
env=launcher_child_environment(),
|
||||||
|
)
|
||||||
except OSError as process_error:
|
except OSError as process_error:
|
||||||
publish_output(f"Could not start operation: {process_error}\n")
|
publish_output(f"Could not start operation: {process_error}\n")
|
||||||
if game_client_id is None:
|
if game_client_id is None:
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ from typing import TYPE_CHECKING, Callable, Mapping
|
|||||||
|
|
||||||
from ...paths import AppPaths
|
from ...paths import AppPaths
|
||||||
from ...self_update import LauncherUpdateCheck
|
from ...self_update import LauncherUpdateCheck
|
||||||
|
from ...telemetry import (
|
||||||
|
TELEMETRY_OPERATION_PROCESS_ROLE,
|
||||||
|
TELEMETRY_PROCESS_ROLE_VARIABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -465,6 +469,18 @@ def launcher_child_command(*arguments: str) -> list[str]:
|
|||||||
return [sys.executable, "-B", "-m", "ptlinux", *arguments]
|
return [sys.executable, "-B", "-m", "ptlinux", *arguments]
|
||||||
|
|
||||||
|
|
||||||
|
def launcher_child_environment(
|
||||||
|
environment: Mapping[str, str] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Mark an internal CLI process as part of the current dashboard session."""
|
||||||
|
|
||||||
|
child_environment = dict(os.environ if environment is None else environment)
|
||||||
|
child_environment[TELEMETRY_PROCESS_ROLE_VARIABLE] = (
|
||||||
|
TELEMETRY_OPERATION_PROCESS_ROLE
|
||||||
|
)
|
||||||
|
return child_environment
|
||||||
|
|
||||||
|
|
||||||
def dashboard_poll_interval(
|
def dashboard_poll_interval(
|
||||||
*,
|
*,
|
||||||
operation_active: bool,
|
operation_active: bool,
|
||||||
|
|||||||
Reference in New Issue
Block a user