mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-22 19:06:15 +08:00
1166 lines
50 KiB
Python
Executable File
1166 lines
50 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright (C) 2026 SharpEmu Emulator Project
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
"""Local web frontend and TCP bridge for the SharpEmu live debugger."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import copy
|
|
from collections import deque
|
|
import errno
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
import os
|
|
import platform
|
|
import queue
|
|
import re
|
|
from pathlib import Path
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from typing import Any
|
|
from urllib.parse import parse_qs, urlparse
|
|
from urllib.error import URLError
|
|
from urllib.request import Request, urlopen
|
|
import webbrowser
|
|
|
|
|
|
APP_ROOT = Path(__file__).resolve().parent
|
|
REPO_ROOT = APP_ROOT.parents[1]
|
|
WEB_ROOT = APP_ROOT / "web"
|
|
MAX_REQUEST_BYTES = 1024 * 1024
|
|
MAX_JOURNAL_MESSAGES = 1000
|
|
APPLICATION_ID = "sharpemu-debugger-frontend"
|
|
|
|
KNOWN_WAIT_IMPORTS: dict[str, tuple[str, str, str]] = {
|
|
"9UK1vLZQft4": ("libKernel", "scePthreadMutexLock", "mutex"),
|
|
"7H0iTOciTLo": ("libKernel", "pthread_mutex_lock", "mutex"),
|
|
"WKAXJ4XBPQ4": ("libKernel", "scePthreadCondWait", "condition"),
|
|
"BmMjYxmew1w": ("libKernel", "scePthreadCondTimedwait", "condition"),
|
|
"Op8TBGY5KHg": ("libKernel", "pthread_cond_wait", "condition"),
|
|
"27bAgiJmOh0": ("libKernel", "pthread_cond_timedwait", "condition"),
|
|
"Zxa0VhQVTsk": ("libKernel", "sceKernelWaitSema", "wait"),
|
|
"JTvBflhYazQ": ("libKernel", "sceKernelWaitEventFlag", "wait"),
|
|
"fzyMKs9kim0": ("libKernel", "sceKernelWaitEqueue", "wait"),
|
|
"j6RaAUlaLv0": ("libSceVideoOut", "sceVideoOutWaitVblank", "timeline"),
|
|
"1jfXLRVzisc": ("libKernel", "sceKernelUsleep", "sleep"),
|
|
}
|
|
|
|
|
|
def analyze_debug_stop(stop: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""Turns structured or legacy stall evidence into actionable guidance."""
|
|
|
|
if str(stop.get("reason", "")).lower() != "stall":
|
|
return None
|
|
stall = copy.deepcopy(stop.get("stall")) if isinstance(stop.get("stall"), dict) else {}
|
|
detail = str(stop.get("detail", ""))
|
|
legacy = _parse_stall_detail(detail)
|
|
for key, value in legacy.items():
|
|
stall.setdefault(key, value)
|
|
|
|
kind = str(stall.get("kind", "Unknown"))
|
|
nid = str(stall.get("nid", "")).strip() or None
|
|
known = KNOWN_WAIT_IMPORTS.get(nid or "")
|
|
library = str(stall.get("library") or (known[0] if known else "")).strip() or None
|
|
function = str(stall.get("function") or (known[1] if known else "")).strip() or None
|
|
resolved = bool(stall.get("resolved")) or function is not None
|
|
category = known[2] if known else _classify_stall_function(function)
|
|
dispatch_index = _parse_optional_int(stall.get("dispatchIndex"))
|
|
instruction_pointer = stall.get("instructionPointer") or legacy.get("instructionPointer")
|
|
argument0 = stall.get("argument0")
|
|
|
|
export_label = f"{library}:{function}" if library and function else function or nid or "unknown import"
|
|
evidence = [
|
|
"The import-loop watchdog observed a repeating pattern of at most two imports/return sites for several seconds.",
|
|
]
|
|
if nid:
|
|
evidence.append(
|
|
f"NID {nid} resolves to {export_label}." if resolved else f"NID {nid} is not resolved to an HLE export."
|
|
)
|
|
if dispatch_index is not None:
|
|
evidence.append(f"The stop occurred after import dispatch #{dispatch_index:,}, indicating sustained retry rather than a short spin.")
|
|
if instruction_pointer:
|
|
evidence.append(f"The repeating guest return site is {instruction_pointer}.")
|
|
if argument0 and str(argument0) not in {"0", "0x0000000000000000"}:
|
|
evidence.append(f"The first ABI argument is {argument0}; for synchronization calls this is usually the waited object address.")
|
|
|
|
if not resolved:
|
|
return {
|
|
"severity": "High",
|
|
"confidence": "High",
|
|
"title": "Unresolved import is being retried",
|
|
"summary": f"The guest repeatedly calls {nid or 'an unknown NID'} because the expected service never completes.",
|
|
"cause": "The NID has no matching HLE export for this target generation/library, so the fallback result sends the guest back into its retry path.",
|
|
"fix": "Implement or correctly register the missing export, including its expected return code and output-memory side effects. Confirm the library and Gen5 target flags match the game import.",
|
|
"actions": [
|
|
"Search the HLE export registry for the NID and verify its library and target generation.",
|
|
"Trace the import arguments and determine which output/status value the guest expects to change.",
|
|
"Add a focused export test before disabling the loop guard; disabling it only hides the livelock.",
|
|
],
|
|
"evidence": evidence,
|
|
"kind": kind,
|
|
"category": "unresolved-import",
|
|
}
|
|
|
|
if category == "mutex":
|
|
return {
|
|
"severity": "High",
|
|
"confidence": "High",
|
|
"title": "Mutex lock is livelocking",
|
|
"summary": f"The guest keeps re-entering {export_label} from the same small call pattern without acquiring the lock or blocking.",
|
|
"cause": "The mutex is probably contended or self-owned, but the HLE/scheduler path is returning control to the guest without durable progress. A mismatched owner identity, incorrect mutex type, or unlock path that does not wake the waiter can produce the same loop.",
|
|
"fix": "Start in KernelPthreadCompatExports.PthreadMutexLockCore: make sure a contended lock parks the guest waiter through GuestThreadExecution, and make sure PthreadMutexUnlockCore grants and wakes the queued waiter. Also validate NORMAL/RECURSIVE/ERRORCHECK self-lock behavior and thread-owner IDs.",
|
|
"actions": [
|
|
f"Trace lock/unlock ownership for {argument0 or 'the arg0 mutex address'} and compare the current thread handle with OwnerThreadId.",
|
|
"Verify GuestThreadExecution.IsGuestThread and TryGetCurrentImportCallFrame are true on the contended path so RequestCurrentThreadBlock is actually used.",
|
|
"Break on scePthreadMutexUnlock (NID tn3VlD0hG60) and confirm it removes/grants a waiter and signals the same wake key.",
|
|
"Do not simply increase or disable SHARPEMU_IMPORT_LOOP_GUARD_SECONDS; that masks the synchronization bug.",
|
|
],
|
|
"evidence": evidence,
|
|
"kind": kind,
|
|
"category": category,
|
|
}
|
|
|
|
if category == "condition":
|
|
title = "Condition wait is not sleeping or waking correctly"
|
|
cause = "The condition wait is returning into the guest retry loop instead of atomically releasing its mutex and parking, or its signal/broadcast path never wakes the waiter."
|
|
fix = "Audit the condition wait lifecycle: release the mutex, enqueue and block the guest thread, then reacquire the mutex only after signal, broadcast, or timeout completion."
|
|
actions = [
|
|
"Trace the condition and mutex addresses passed in arg0/arg1.",
|
|
"Verify signal/broadcast targets the same condition key and wakes at least one queued guest waiter.",
|
|
"Check timeout clock units and ensure a timed wait does not immediately retry forever.",
|
|
]
|
|
elif category == "wait":
|
|
title = "Kernel wait primitive is not reaching completion"
|
|
cause = "The wait call returns or is redispatched without the object becoming signaled, usually because the waiter was not parked or the producer does not issue its wake/completion transition."
|
|
fix = "Connect the wait export to guest-thread blocking and audit the matching signal/post/event producer so it updates state before waking the waiter."
|
|
actions = [
|
|
"Trace the waited object address/ID and its signal/post counterpart.",
|
|
"Confirm the wait path queues once instead of adding or polling a waiter on every import call.",
|
|
"Verify completion writes all output fields and returns the guest-expected status code.",
|
|
]
|
|
elif category == "timeline":
|
|
title = "Producer timeline is not advancing"
|
|
cause = "The guest repeatedly waits for a vblank/GPU/event milestone whose producer counter or completion fence is not being advanced."
|
|
fix = "Audit the producer pump and completion callback, then ensure the wait export blocks until the timeline changes instead of immediately returning unchanged state."
|
|
actions = [
|
|
"Compare the requested timeline/fence value with the current producer value.",
|
|
"Confirm the vblank, submit, or completion pump is running on the host.",
|
|
"Verify a completion wakes every waiter registered for the reached value.",
|
|
]
|
|
elif category == "sleep":
|
|
title = "Sleep/yield boundary is being redispatched"
|
|
cause = "The delay call is not yielding host execution or advancing the guest-visible clock, so the guest immediately retries its timing loop."
|
|
fix = "Treat the call as a scheduler boundary, block for the requested duration using the correct clock units, and update guest-visible time before resuming."
|
|
actions = [
|
|
"Verify the delay argument units and overflow handling.",
|
|
"Confirm the guest thread yields instead of busy-returning on the same host thread.",
|
|
"Check monotonic clock progression observed by the game's follow-up time query.",
|
|
]
|
|
else:
|
|
title = "Resolved HLE import is not making forward progress"
|
|
cause = f"{export_label} exists, but its return value or side effects leave the guest condition unchanged, causing the same import path to repeat."
|
|
fix = "Audit the export contract: return status, output pointers, state transitions, and any scheduler wake/completion it promises. Compare those effects with the condition checked at the repeating guest return site."
|
|
actions = [
|
|
"Trace the import arguments, return value, and output memory for several consecutive iterations.",
|
|
"Identify the guest branch at the repeating return site and the exact state it expects to change.",
|
|
"Add a focused regression test for that state transition before changing the loop detector.",
|
|
]
|
|
|
|
return {
|
|
"severity": "High",
|
|
"confidence": "Medium" if category == "generic" else "High",
|
|
"title": title,
|
|
"summary": f"{export_label} repeats without forward progress.",
|
|
"cause": cause,
|
|
"fix": fix,
|
|
"actions": actions,
|
|
"evidence": evidence,
|
|
"kind": kind,
|
|
"category": category,
|
|
}
|
|
|
|
|
|
def _parse_stall_detail(detail: str) -> dict[str, Any]:
|
|
patterns = {
|
|
"kind": r"(?:^|,\s*)kind=([^,]+)",
|
|
"nid": r"(?:^|,\s*)nid=([^,]+)",
|
|
"instructionPointer": r"(?:^|,\s*)rip=(0x[0-9a-fA-F]+)",
|
|
"dispatchIndex": r"(?:^|,\s*)dispatch#(\d+)",
|
|
"argument0": r"(?:^|,\s*)arg0=(0x[0-9a-fA-F]+)",
|
|
"argument1": r"(?:^|,\s*)arg1=(0x[0-9a-fA-F]+)",
|
|
}
|
|
result: dict[str, Any] = {}
|
|
for key, pattern in patterns.items():
|
|
match = re.search(pattern, detail)
|
|
if match:
|
|
result[key] = int(match.group(1)) if key == "dispatchIndex" else match.group(1).strip()
|
|
return result
|
|
|
|
|
|
def _classify_stall_function(function: str | None) -> str:
|
|
normalized = (function or "").lower()
|
|
if "mutex" in normalized and ("lock" in normalized or "wait" in normalized):
|
|
return "mutex"
|
|
if "cond" in normalized and "wait" in normalized:
|
|
return "condition"
|
|
if any(token in normalized for token in ("vblank", "waitregmem", "fence", "flip")):
|
|
return "timeline"
|
|
if any(token in normalized for token in ("usleep", "nanosleep", "sleep")):
|
|
return "sleep"
|
|
if any(token in normalized for token in ("wait", "sema", "event")):
|
|
return "wait"
|
|
return "generic"
|
|
|
|
|
|
def _parse_optional_int(value: Any) -> int | None:
|
|
try:
|
|
return int(value) if value is not None else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _attach_stop_analysis(stop: dict[str, Any]) -> dict[str, Any]:
|
|
decorated = copy.deepcopy(stop)
|
|
analysis = analyze_debug_stop(decorated)
|
|
if analysis is not None:
|
|
decorated["analysis"] = analysis
|
|
return decorated
|
|
|
|
|
|
class BridgeError(RuntimeError):
|
|
"""Raised when the debugger bridge cannot complete an operation."""
|
|
|
|
|
|
class EventJournal:
|
|
"""A bounded, cursor-addressable activity stream for the browser."""
|
|
|
|
def __init__(self, capacity: int = MAX_JOURNAL_MESSAGES) -> None:
|
|
self._lock = threading.Lock()
|
|
self._messages: deque[dict[str, Any]] = deque(maxlen=capacity)
|
|
self._next_id = 1
|
|
|
|
def append(self, kind: str, summary: str, payload: Any = None) -> None:
|
|
with self._lock:
|
|
item = {
|
|
"id": self._next_id,
|
|
"time": time.strftime("%H:%M:%S"),
|
|
"kind": kind,
|
|
"summary": summary,
|
|
}
|
|
if payload is not None:
|
|
item["payload"] = copy.deepcopy(payload)
|
|
self._messages.append(item)
|
|
self._next_id += 1
|
|
|
|
def since(self, cursor: int) -> tuple[list[dict[str, Any]], int]:
|
|
with self._lock:
|
|
messages = [copy.deepcopy(item) for item in self._messages if item["id"] > cursor]
|
|
return messages, self._next_id - 1
|
|
|
|
|
|
class EmulatorProcessManager:
|
|
"""Launches and supervises the one emulator process owned by the UI."""
|
|
|
|
def __init__(self, journal: EventJournal) -> None:
|
|
self.journal = journal
|
|
self._lock = threading.RLock()
|
|
self._process: subprocess.Popen[str] | None = None
|
|
self._reader_thread: threading.Thread | None = None
|
|
self._eboot_path: str | None = None
|
|
self._emulator_path: str | None = None
|
|
self._started_at: str | None = None
|
|
self._exit_code: int | None = None
|
|
|
|
def launch(
|
|
self,
|
|
eboot_path: str,
|
|
debug_port: int,
|
|
emulator_path: str | None = None,
|
|
) -> dict[str, Any]:
|
|
eboot = Path(eboot_path).expanduser().resolve()
|
|
if not eboot.is_file():
|
|
raise BridgeError(f"EBOOT file does not exist: {eboot}")
|
|
if debug_port < 1 or debug_port > 65535:
|
|
raise BridgeError("The debugger port must be between 1 and 65535.")
|
|
if self._port_is_open("127.0.0.1", debug_port):
|
|
raise BridgeError(f"Port {debug_port} is already in use. Stop the existing debugger or choose another port.")
|
|
|
|
executable = self.resolve_emulator(emulator_path)
|
|
with self._lock:
|
|
self._refresh_process_locked()
|
|
if self._process is not None and self._process.poll() is None:
|
|
raise BridgeError("An emulator launched by this frontend is already running.")
|
|
|
|
environment = os.environ.copy()
|
|
local_dotnet = REPO_ROOT.parent / ".dotnet"
|
|
if local_dotnet.is_dir():
|
|
environment["DOTNET_ROOT"] = str(local_dotnet)
|
|
environment["PATH"] = f"{local_dotnet}{os.pathsep}{environment.get('PATH', '')}"
|
|
|
|
command = [
|
|
str(executable),
|
|
f"--debug-server=127.0.0.1:{debug_port}",
|
|
str(eboot),
|
|
]
|
|
popen_options: dict[str, Any] = {
|
|
"cwd": str(REPO_ROOT),
|
|
"env": environment,
|
|
"stdout": subprocess.PIPE,
|
|
"stderr": subprocess.STDOUT,
|
|
"text": True,
|
|
"encoding": "utf-8",
|
|
"errors": "replace",
|
|
"bufsize": 1,
|
|
}
|
|
if os.name == "nt":
|
|
popen_options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
else:
|
|
popen_options["start_new_session"] = True
|
|
|
|
try:
|
|
process = subprocess.Popen(command, **popen_options)
|
|
except OSError as exc:
|
|
raise BridgeError(f"Could not launch SharpEmu: {exc}") from exc
|
|
|
|
self._process = process
|
|
self._eboot_path = str(eboot)
|
|
self._emulator_path = str(executable)
|
|
self._started_at = time.strftime("%H:%M:%S")
|
|
self._exit_code = None
|
|
self.journal.append("emulator", f"Launched {eboot.name} (PID {process.pid})", {
|
|
"executable": str(executable),
|
|
"eboot": str(eboot),
|
|
"debugPort": debug_port,
|
|
})
|
|
reader_thread = threading.Thread(
|
|
target=self._read_output,
|
|
args=(process,),
|
|
name="sharpemu-emulator-output",
|
|
daemon=True,
|
|
)
|
|
self._reader_thread = reader_thread
|
|
reader_thread.start()
|
|
return self._snapshot_locked()
|
|
|
|
def stop(self, timeout: float = 5.0, log: bool = True) -> dict[str, Any]:
|
|
with self._lock:
|
|
self._refresh_process_locked()
|
|
process = self._process
|
|
if process is None or process.poll() is not None:
|
|
return self._snapshot_locked()
|
|
if log:
|
|
self.journal.append("emulator", f"Stopping emulator (PID {process.pid})")
|
|
|
|
try:
|
|
if os.name == "nt":
|
|
process.terminate()
|
|
else:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
process.wait(timeout=timeout)
|
|
except ProcessLookupError:
|
|
pass
|
|
except subprocess.TimeoutExpired:
|
|
self.journal.append("error", "Emulator did not stop gracefully; terminating it now.")
|
|
try:
|
|
if os.name == "nt":
|
|
process.kill()
|
|
else:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
process.wait(timeout=2)
|
|
|
|
with self._lock:
|
|
self._refresh_process_locked()
|
|
return self._snapshot_locked()
|
|
|
|
def wait_for_debugger(self, port: int, timeout: float = 12.0) -> None:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
with self._lock:
|
|
self._refresh_process_locked()
|
|
process = self._process
|
|
exit_code = self._exit_code
|
|
if process is None or exit_code is not None:
|
|
raise BridgeError(f"SharpEmu exited before its debugger started (exit code {exit_code}).")
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", port), timeout=0.25):
|
|
return
|
|
except OSError:
|
|
time.sleep(0.15)
|
|
raise BridgeError(f"SharpEmu did not open debugger port {port} within {timeout:g} seconds.")
|
|
|
|
def select_eboot(self, initial_path: str | None = None) -> str | None:
|
|
initial = self._picker_initial_path(initial_path)
|
|
zenity = shutil.which("zenity")
|
|
kdialog = shutil.which("kdialog")
|
|
if zenity:
|
|
command = [
|
|
zenity,
|
|
"--file-selection",
|
|
"--title=Choose a SharpEmu eboot",
|
|
"--file-filter=Game executable | eboot.bin *.bin *.elf",
|
|
"--file-filter=All files | *",
|
|
]
|
|
if initial:
|
|
command.append(f"--filename={initial}")
|
|
elif kdialog:
|
|
command = [
|
|
kdialog,
|
|
"--getopenfilename",
|
|
initial or str(Path.home()),
|
|
"Game executable (eboot.bin *.bin *.elf)",
|
|
"--title",
|
|
"Choose a SharpEmu eboot",
|
|
]
|
|
else:
|
|
raise BridgeError("No desktop file picker was found. Enter the full eboot path manually.")
|
|
|
|
try:
|
|
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
except OSError as exc:
|
|
raise BridgeError(f"Could not open the file picker: {exc}") from exc
|
|
if result.returncode != 0:
|
|
return None
|
|
selected = result.stdout.strip()
|
|
return str(Path(selected).expanduser().resolve()) if selected else None
|
|
|
|
def resolve_emulator(self, explicit_path: str | None = None) -> Path:
|
|
if explicit_path:
|
|
candidate = Path(explicit_path).expanduser().resolve()
|
|
else:
|
|
system = platform.system().lower()
|
|
machine = platform.machine().lower()
|
|
rid = {
|
|
("linux", "x86_64"): "linux-x64",
|
|
("linux", "amd64"): "linux-x64",
|
|
("windows", "amd64"): "win-x64",
|
|
("windows", "x86_64"): "win-x64",
|
|
("darwin", "x86_64"): "osx-x64",
|
|
}.get((system, machine))
|
|
executable_name = "SharpEmu.exe" if os.name == "nt" else "SharpEmu"
|
|
candidates: list[Path] = []
|
|
if rid:
|
|
candidates.append(REPO_ROOT / "artifacts" / "bin" / "Release" / "net10.0" / rid / executable_name)
|
|
candidates.extend(
|
|
path for path in (REPO_ROOT / "artifacts" / "bin" / "Release" / "net10.0").glob(f"*/{executable_name}")
|
|
if path not in candidates
|
|
)
|
|
candidate = next((path.resolve() for path in candidates if path.is_file()), candidates[0] if candidates else Path())
|
|
|
|
if not candidate.is_file():
|
|
raise BridgeError(
|
|
"The SharpEmu Release executable was not found. Build the solution first with "
|
|
"'dotnet build SharpEmu.slnx --configuration Release'."
|
|
)
|
|
if os.name != "nt" and not os.access(candidate, os.X_OK):
|
|
raise BridgeError(f"The SharpEmu executable is not runnable: {candidate}")
|
|
return candidate
|
|
|
|
def snapshot(self) -> dict[str, Any]:
|
|
with self._lock:
|
|
self._refresh_process_locked()
|
|
return self._snapshot_locked()
|
|
|
|
def _read_output(self, process: subprocess.Popen[str]) -> None:
|
|
output = process.stdout
|
|
if output is not None:
|
|
try:
|
|
for line in output:
|
|
text = line.rstrip("\r\n")
|
|
if text:
|
|
self.journal.append("emulator", text[:4000])
|
|
except (OSError, ValueError):
|
|
pass
|
|
finally:
|
|
output.close()
|
|
try:
|
|
process.wait()
|
|
except OSError:
|
|
pass
|
|
with self._lock:
|
|
self._refresh_process_locked()
|
|
|
|
def _refresh_process_locked(self) -> None:
|
|
if self._process is None:
|
|
return
|
|
exit_code = self._process.poll()
|
|
if exit_code is not None and self._exit_code is None:
|
|
self._exit_code = exit_code
|
|
kind = "emulator" if exit_code == 0 else "error"
|
|
self.journal.append(kind, f"Emulator exited with code {exit_code}")
|
|
|
|
def _snapshot_locked(self) -> dict[str, Any]:
|
|
process = self._process
|
|
running = process is not None and process.poll() is None
|
|
return {
|
|
"running": running,
|
|
"pid": process.pid if running else None,
|
|
"eboot": self._eboot_path,
|
|
"executable": self._emulator_path,
|
|
"startedAt": self._started_at,
|
|
"exitCode": self._exit_code,
|
|
}
|
|
|
|
@staticmethod
|
|
def _port_is_open(host: str, port: int) -> bool:
|
|
try:
|
|
with socket.create_connection((host, port), timeout=0.2):
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
@staticmethod
|
|
def _picker_initial_path(initial_path: str | None) -> str | None:
|
|
if not initial_path:
|
|
return None
|
|
path = Path(initial_path).expanduser()
|
|
if path.is_file():
|
|
return str(path.resolve())
|
|
if path.is_dir():
|
|
return f"{path.resolve()}{os.sep}"
|
|
parent = path.parent
|
|
return f"{parent.resolve()}{os.sep}" if parent.is_dir() else None
|
|
|
|
|
|
class DebuggerBridge:
|
|
"""Owns one debugger socket and serializes request/reply traffic."""
|
|
|
|
def __init__(self, default_host: str = "127.0.0.1", default_port: int = 5714) -> None:
|
|
self.default_host = default_host
|
|
self.default_port = default_port
|
|
self.journal = EventJournal()
|
|
|
|
self._state_lock = threading.RLock()
|
|
self._lifecycle_lock = threading.RLock()
|
|
self._command_lock = threading.Lock()
|
|
self._send_lock = threading.Lock()
|
|
self._responses: queue.Queue[tuple[int, Any]] = queue.Queue()
|
|
self._generation = 0
|
|
self._hello_event = threading.Event()
|
|
self._socket: socket.socket | None = None
|
|
self._reader_thread: threading.Thread | None = None
|
|
|
|
self._connected = False
|
|
self._endpoint: str | None = None
|
|
self._protocol: str | None = None
|
|
self._target_state = "Disconnected"
|
|
self._last_stop: dict[str, Any] | None = None
|
|
self._registers: dict[str, Any] = {}
|
|
self._breakpoints: list[dict[str, Any]] = []
|
|
|
|
def connect(self, host: str, port: int, timeout: float = 3.0) -> None:
|
|
with self._lifecycle_lock:
|
|
self._connect(host, port, timeout)
|
|
|
|
def _connect(self, host: str, port: int, timeout: float) -> None:
|
|
host = host.strip()
|
|
if not host:
|
|
raise BridgeError("A debugger host is required.")
|
|
if port < 1 or port > 65535:
|
|
raise BridgeError("The debugger port must be between 1 and 65535.")
|
|
|
|
self.disconnect(log=False)
|
|
self._drain_responses()
|
|
try:
|
|
client = socket.create_connection((host, port), timeout=timeout)
|
|
client.settimeout(None)
|
|
client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
reader = client.makefile("r", encoding="utf-8", newline="\n")
|
|
except OSError as exc:
|
|
message = f"Could not connect to {host}:{port}: {exc}"
|
|
self.journal.append("error", message)
|
|
raise BridgeError(message) from exc
|
|
|
|
with self._state_lock:
|
|
self._generation += 1
|
|
generation = self._generation
|
|
self._socket = client
|
|
self._connected = True
|
|
self._endpoint = f"{host}:{port}"
|
|
self._protocol = None
|
|
self._target_state = "Connecting"
|
|
self._last_stop = None
|
|
self._registers = {}
|
|
self._breakpoints = []
|
|
self._hello_event = threading.Event()
|
|
|
|
self.journal.append("system", f"Connected to {host}:{port}")
|
|
thread = threading.Thread(
|
|
target=self._read_loop,
|
|
args=(generation, client, reader),
|
|
name="sharpemu-debug-reader",
|
|
daemon=True,
|
|
)
|
|
self._reader_thread = thread
|
|
thread.start()
|
|
|
|
self._hello_event.wait(timeout=min(timeout, 1.5))
|
|
with self._state_lock:
|
|
if generation != self._generation or not self._connected:
|
|
raise BridgeError(f"The debugger at {host}:{port} closed the connection.")
|
|
|
|
def disconnect(self, reason: str = "Disconnected", log: bool = True) -> None:
|
|
with self._lifecycle_lock:
|
|
self._disconnect(reason, log)
|
|
|
|
def _disconnect(self, reason: str, log: bool) -> None:
|
|
with self._state_lock:
|
|
client = self._socket
|
|
was_connected = self._connected
|
|
self._generation += 1
|
|
generation = self._generation
|
|
self._socket = None
|
|
self._connected = False
|
|
self._protocol = None
|
|
self._target_state = "Disconnected"
|
|
self._last_stop = None
|
|
self._registers = {}
|
|
self._breakpoints = []
|
|
|
|
if client is not None:
|
|
try:
|
|
client.shutdown(socket.SHUT_RDWR)
|
|
except OSError:
|
|
pass
|
|
client.close()
|
|
|
|
if was_connected:
|
|
self._responses.put((generation - 1, BridgeError(reason)))
|
|
if log:
|
|
self.journal.append("system", reason)
|
|
|
|
def request(self, payload: dict[str, Any], timeout: float = 6.0) -> dict[str, Any]:
|
|
command = payload.get("command")
|
|
if not isinstance(command, str) or not command.strip():
|
|
raise BridgeError("Every request requires a non-empty 'command'.")
|
|
|
|
wire_data = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8")
|
|
if len(wire_data) > MAX_REQUEST_BYTES:
|
|
raise BridgeError("The debugger request is too large.")
|
|
|
|
with self._command_lock:
|
|
with self._state_lock:
|
|
client = self._socket
|
|
generation = self._generation
|
|
connected = self._connected
|
|
if client is None or not connected:
|
|
raise BridgeError("Not connected to a SharpEmu debugger.")
|
|
|
|
try:
|
|
with self._send_lock:
|
|
client.sendall(wire_data)
|
|
except OSError as exc:
|
|
self._connection_lost(generation, f"Connection lost while sending: {exc}")
|
|
raise BridgeError("The debugger connection was lost while sending.") from exc
|
|
|
|
self.journal.append("request", command, payload)
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
self.disconnect(f"Debugger request '{command}' timed out.")
|
|
raise BridgeError(f"Debugger request '{command}' timed out.")
|
|
try:
|
|
response_generation, item = self._responses.get(timeout=remaining)
|
|
except queue.Empty as exc:
|
|
self.disconnect(f"Debugger request '{command}' timed out.")
|
|
raise BridgeError(f"Debugger request '{command}' timed out.") from exc
|
|
if response_generation != generation:
|
|
continue
|
|
if isinstance(item, BaseException):
|
|
raise BridgeError(str(item)) from item
|
|
response = item
|
|
break
|
|
|
|
self._apply_response(response)
|
|
if response.get("ok"):
|
|
self.journal.append("response", f"{command} succeeded", response)
|
|
else:
|
|
self.journal.append("error", response.get("error", f"{command} failed"), response)
|
|
return response
|
|
|
|
def snapshot(self, cursor: int = 0) -> dict[str, Any]:
|
|
messages, next_cursor = self.journal.since(max(0, cursor))
|
|
with self._state_lock:
|
|
return {
|
|
"connected": self._connected,
|
|
"endpoint": self._endpoint,
|
|
"defaultEndpoint": {
|
|
"host": self.default_host,
|
|
"port": self.default_port,
|
|
},
|
|
"protocol": self._protocol,
|
|
"state": self._target_state,
|
|
"lastStop": copy.deepcopy(self._last_stop),
|
|
"registers": copy.deepcopy(self._registers),
|
|
"breakpoints": copy.deepcopy(self._breakpoints),
|
|
"messages": messages,
|
|
"cursor": next_cursor,
|
|
}
|
|
|
|
def _read_loop(
|
|
self,
|
|
generation: int,
|
|
client: socket.socket,
|
|
reader: Any,
|
|
) -> None:
|
|
reason = "Debugger closed the connection."
|
|
try:
|
|
while True:
|
|
line = reader.readline()
|
|
if line == "":
|
|
break
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
message = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
self.journal.append("error", "Debugger sent malformed JSON", line)
|
|
continue
|
|
if not isinstance(message, dict):
|
|
self.journal.append("error", "Debugger sent a non-object message", message)
|
|
continue
|
|
|
|
if "event" in message:
|
|
self._apply_event(message)
|
|
event_name = str(message.get("event", "event"))
|
|
self.journal.append("event", event_name, message)
|
|
else:
|
|
self._responses.put((generation, message))
|
|
except (OSError, ValueError) as exc:
|
|
reason = f"Debugger connection lost: {exc}"
|
|
finally:
|
|
try:
|
|
reader.close()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
client.close()
|
|
except OSError:
|
|
pass
|
|
self._connection_lost(generation, reason)
|
|
|
|
def _connection_lost(self, generation: int, reason: str) -> None:
|
|
with self._state_lock:
|
|
if generation != self._generation:
|
|
return
|
|
self._socket = None
|
|
self._connected = False
|
|
self._protocol = None
|
|
self._target_state = "Disconnected"
|
|
self._last_stop = None
|
|
self._registers = {}
|
|
self._breakpoints = []
|
|
self._generation += 1
|
|
self._responses.put((generation, BridgeError(reason)))
|
|
self.journal.append("error", reason)
|
|
|
|
def _apply_event(self, message: dict[str, Any]) -> None:
|
|
event = str(message.get("event", "")).lower()
|
|
with self._state_lock:
|
|
if event == "hello":
|
|
self._protocol = str(message.get("protocol", "unknown"))
|
|
self._target_state = str(message.get("state", "Connected"))
|
|
self._hello_event.set()
|
|
elif event == "stopped":
|
|
self._target_state = "Paused"
|
|
self._last_stop = _attach_stop_analysis({
|
|
key: copy.deepcopy(value) for key, value in message.items() if key != "event"
|
|
})
|
|
registers = message.get("registers")
|
|
if isinstance(registers, dict):
|
|
self._registers = copy.deepcopy(registers)
|
|
elif event == "resumed":
|
|
self._target_state = "Running"
|
|
elif event == "terminated":
|
|
self._target_state = "Terminated"
|
|
|
|
def _apply_response(self, response: dict[str, Any]) -> None:
|
|
if not response.get("ok"):
|
|
return
|
|
command = str(response.get("command", "")).lower()
|
|
data = response.get("data")
|
|
if not isinstance(data, dict):
|
|
return
|
|
with self._state_lock:
|
|
if command in {"status", "info"}:
|
|
self._target_state = str(data.get("state", self._target_state))
|
|
last_stop = data.get("lastStop")
|
|
if isinstance(last_stop, dict):
|
|
self._last_stop = _attach_stop_analysis(last_stop)
|
|
registers = last_stop.get("registers")
|
|
if isinstance(registers, dict):
|
|
self._registers = copy.deepcopy(registers)
|
|
elif command == "state":
|
|
self._target_state = str(data.get("state", self._target_state))
|
|
elif command in {"registers", "regs"}:
|
|
registers = data.get("registers")
|
|
if isinstance(registers, dict):
|
|
self._registers = copy.deepcopy(registers)
|
|
elif command in {"list-breakpoints", "breakpoints"}:
|
|
breakpoints = data.get("breakpoints")
|
|
if isinstance(breakpoints, list):
|
|
self._breakpoints = copy.deepcopy(breakpoints)
|
|
|
|
def _drain_responses(self) -> None:
|
|
while True:
|
|
try:
|
|
self._responses.get_nowait()
|
|
except queue.Empty:
|
|
return
|
|
|
|
|
|
class FrontendRequestHandler(BaseHTTPRequestHandler):
|
|
"""Serves static UI assets and the local bridge API."""
|
|
|
|
bridge: DebuggerBridge
|
|
process_manager: EmulatorProcessManager
|
|
verbose = False
|
|
|
|
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
|
parsed = urlparse(self.path)
|
|
if parsed.path == "/api/snapshot":
|
|
values = parse_qs(parsed.query)
|
|
try:
|
|
cursor = int(values.get("since", ["0"])[0])
|
|
except ValueError:
|
|
cursor = 0
|
|
self._send_json(HTTPStatus.OK, self._combined_snapshot(cursor))
|
|
return
|
|
if parsed.path == "/api/health":
|
|
self._send_json(HTTPStatus.OK, {
|
|
"ok": True,
|
|
"application": APPLICATION_ID,
|
|
"pid": os.getpid(),
|
|
})
|
|
return
|
|
|
|
asset_map = {
|
|
"/": ("index.html", "text/html; charset=utf-8"),
|
|
"/index.html": ("index.html", "text/html; charset=utf-8"),
|
|
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
|
|
"/styles.css": ("styles.css", "text/css; charset=utf-8"),
|
|
"/sharpemu-logo.webp": ("sharpemu-logo.webp", "image/webp"),
|
|
}
|
|
asset = asset_map.get(parsed.path)
|
|
if asset is None:
|
|
self.send_error(HTTPStatus.NOT_FOUND)
|
|
return
|
|
file_name, content_type = asset
|
|
try:
|
|
body = (WEB_ROOT / file_name).read_bytes()
|
|
except OSError:
|
|
self.send_error(HTTPStatus.NOT_FOUND)
|
|
return
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.send_header(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; connect-src 'self'; img-src 'self' data:; "
|
|
"script-src 'self'; style-src 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
)
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
|
parsed = urlparse(self.path)
|
|
try:
|
|
body = self._read_json_body()
|
|
if parsed.path == "/api/connect":
|
|
host = str(body.get("host", self.bridge.default_host)).strip()
|
|
port = int(body.get("port", self.bridge.default_port))
|
|
self.bridge.connect(host, port)
|
|
self._prime_snapshot()
|
|
self._send_json(HTTPStatus.OK, self._combined_snapshot())
|
|
return
|
|
if parsed.path == "/api/disconnect":
|
|
self.bridge.disconnect()
|
|
self._send_json(HTTPStatus.OK, self._combined_snapshot())
|
|
return
|
|
if parsed.path == "/api/select-eboot":
|
|
selected = self.process_manager.select_eboot(body.get("initialPath"))
|
|
self._send_json(HTTPStatus.OK, {"path": selected})
|
|
return
|
|
if parsed.path == "/api/launch":
|
|
eboot_path = body.get("ebootPath")
|
|
if not isinstance(eboot_path, str) or not eboot_path.strip():
|
|
raise BridgeError("Choose an eboot file before launching.")
|
|
debug_port = int(body.get("debugPort", self.bridge.default_port))
|
|
emulator_path = body.get("emulatorPath")
|
|
if emulator_path is not None and not isinstance(emulator_path, str):
|
|
raise BridgeError("'emulatorPath' must be a string.")
|
|
self.bridge.disconnect(log=False)
|
|
try:
|
|
self.process_manager.launch(eboot_path, debug_port, emulator_path)
|
|
self.process_manager.wait_for_debugger(debug_port)
|
|
self.bridge.connect("127.0.0.1", debug_port)
|
|
self._prime_snapshot()
|
|
except BridgeError:
|
|
self.process_manager.stop(log=False)
|
|
raise
|
|
self._send_json(HTTPStatus.OK, self._combined_snapshot())
|
|
return
|
|
if parsed.path == "/api/stop-emulator":
|
|
self.bridge.disconnect(log=False)
|
|
self.process_manager.stop()
|
|
self._send_json(HTTPStatus.OK, self._combined_snapshot())
|
|
return
|
|
if parsed.path == "/api/shutdown":
|
|
self.bridge.journal.append("system", "A replacement frontend requested shutdown.")
|
|
self._send_json(HTTPStatus.OK, {"ok": True, "pid": os.getpid()})
|
|
threading.Thread(
|
|
target=self._shutdown_http_server,
|
|
name="sharpemu-frontend-shutdown",
|
|
daemon=True,
|
|
).start()
|
|
return
|
|
if parsed.path == "/api/command":
|
|
request = body.get("request")
|
|
if not isinstance(request, dict):
|
|
raise BridgeError("Expected a JSON object in 'request'.")
|
|
response = self.bridge.request(request)
|
|
self._send_json(HTTPStatus.OK, {"response": response})
|
|
return
|
|
self._send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown API endpoint."})
|
|
except (BridgeError, ValueError, TypeError) as exc:
|
|
self._send_json(HTTPStatus.BAD_GATEWAY, {"error": str(exc)})
|
|
except json.JSONDecodeError:
|
|
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "Request body is not valid JSON."})
|
|
|
|
def _prime_snapshot(self) -> None:
|
|
try:
|
|
self.bridge.request({"command": "status"})
|
|
self.bridge.request({"command": "list-breakpoints"})
|
|
except BridgeError as exc:
|
|
self.bridge.journal.append("error", f"Initial refresh failed: {exc}")
|
|
|
|
def _combined_snapshot(self, cursor: int = 0) -> dict[str, Any]:
|
|
snapshot = self.bridge.snapshot(cursor)
|
|
snapshot["emulator"] = self.process_manager.snapshot()
|
|
return snapshot
|
|
|
|
def _shutdown_http_server(self) -> None:
|
|
self.server.shutdown()
|
|
self.server.server_close()
|
|
|
|
def _read_json_body(self) -> dict[str, Any]:
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
except ValueError as exc:
|
|
raise BridgeError("Invalid Content-Length header.") from exc
|
|
if length < 0 or length > MAX_REQUEST_BYTES:
|
|
raise BridgeError("HTTP request body is too large.")
|
|
raw = self.rfile.read(length)
|
|
value = json.loads(raw.decode("utf-8") if raw else "{}")
|
|
if not isinstance(value, dict):
|
|
raise BridgeError("Expected a JSON object request body.")
|
|
return value
|
|
|
|
def _send_json(self, status: HTTPStatus, payload: Any) -> None:
|
|
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
try:
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
pass
|
|
|
|
def log_message(self, format_string: str, *args: Any) -> None:
|
|
if self.verbose:
|
|
super().log_message(format_string, *args)
|
|
|
|
|
|
class FrontendHttpServer(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
allow_reuse_address = True
|
|
|
|
|
|
def create_frontend_server(
|
|
listen_address: str,
|
|
port: int,
|
|
handler_type: type[FrontendRequestHandler],
|
|
) -> FrontendHttpServer:
|
|
"""Binds the UI server, replacing an older frontend on the same port."""
|
|
|
|
try:
|
|
return FrontendHttpServer((listen_address, port), handler_type)
|
|
except OSError as exc:
|
|
if exc.errno != errno.EADDRINUSE or port == 0:
|
|
raise SystemExit(f"Could not start the frontend on {listen_address}:{port}: {exc}") from None
|
|
|
|
if not request_existing_frontend_shutdown(listen_address, port):
|
|
raise SystemExit(
|
|
f"Port {port} is already in use by another application. "
|
|
"Choose a different port with --ui-port."
|
|
)
|
|
|
|
deadline = time.monotonic() + 10.0
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
return FrontendHttpServer((listen_address, port), handler_type)
|
|
except OSError as exc:
|
|
if exc.errno != errno.EADDRINUSE:
|
|
raise SystemExit(f"Could not start the frontend on {listen_address}:{port}: {exc}") from None
|
|
time.sleep(0.15)
|
|
raise SystemExit(f"The previous SharpEmu frontend did not release port {port} in time.")
|
|
|
|
|
|
def request_existing_frontend_shutdown(listen_address: str, port: int) -> bool:
|
|
"""Stops only a verified SharpEmu frontend listening on the target port."""
|
|
|
|
probe_host = "127.0.0.1" if listen_address in {"0.0.0.0", "localhost"} else listen_address
|
|
url_host = f"[{probe_host}]" if ":" in probe_host and not probe_host.startswith("[") else probe_host
|
|
base_url = f"http://{url_host}:{port}"
|
|
try:
|
|
with urlopen(f"{base_url}/api/health", timeout=0.75) as response:
|
|
health = json.load(response)
|
|
except (OSError, URLError, ValueError, json.JSONDecodeError):
|
|
return False
|
|
|
|
if isinstance(health, dict) and health.get("application") == APPLICATION_ID:
|
|
request = Request(
|
|
f"{base_url}/api/shutdown",
|
|
data=b"{}",
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urlopen(request, timeout=1.5) as response:
|
|
result = json.load(response)
|
|
return isinstance(result, dict) and result.get("ok") is True
|
|
except (OSError, URLError, ValueError, json.JSONDecodeError):
|
|
return False
|
|
|
|
# Frontends created before the shutdown API only returned {"ok": true}.
|
|
# Verify both their page identity and the exact owning process before asking
|
|
# that legacy instance to exit through its normal Ctrl+C cleanup path.
|
|
if not isinstance(health, dict) or health.get("ok") is not True:
|
|
return False
|
|
try:
|
|
with urlopen(f"{base_url}/", timeout=0.75) as response:
|
|
page = response.read(128 * 1024)
|
|
except (OSError, URLError):
|
|
return False
|
|
if b"<title>SharpEmu Debugger</title>" not in page:
|
|
return False
|
|
legacy_pid = find_legacy_frontend_pid(port)
|
|
if legacy_pid is None:
|
|
return False
|
|
try:
|
|
os.kill(legacy_pid, signal.SIGINT)
|
|
return True
|
|
except (OSError, ProcessLookupError):
|
|
return False
|
|
|
|
|
|
def find_legacy_frontend_pid(port: int) -> int | None:
|
|
"""Finds the verified legacy frontend owning a Linux listening socket."""
|
|
|
|
if platform.system() != "Linux" or shutil.which("ss") is None:
|
|
return None
|
|
try:
|
|
result = subprocess.run(
|
|
["ss", "-ltnp", f"( sport = :{port} )"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return None
|
|
script_path = str(Path(__file__).resolve())
|
|
for match in re.finditer(r"pid=(\d+)", result.stdout):
|
|
pid = int(match.group(1))
|
|
if pid == os.getpid():
|
|
continue
|
|
try:
|
|
command_line = Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode("utf-8", "replace")
|
|
except OSError:
|
|
continue
|
|
if script_path in command_line:
|
|
return pid
|
|
return None
|
|
|
|
|
|
def build_argument_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="SharpEmu live debugger web frontend")
|
|
parser.add_argument("--debug-host", default="127.0.0.1", help="SharpEmu debugger host")
|
|
parser.add_argument("--debug-port", type=int, default=5714, help="SharpEmu debugger port")
|
|
parser.add_argument("--listen", default="127.0.0.1", help="Frontend HTTP bind address")
|
|
parser.add_argument("--ui-port", type=int, default=8765, help="Frontend HTTP port (0 chooses a free port)")
|
|
parser.add_argument("--no-connect", action="store_true", help="Do not connect to SharpEmu on startup")
|
|
parser.add_argument("--no-browser", action="store_true", help="Do not open the frontend in a browser")
|
|
parser.add_argument("--verbose", action="store_true", help="Print HTTP request logs")
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = build_argument_parser().parse_args(argv)
|
|
if args.debug_port < 1 or args.debug_port > 65535:
|
|
raise SystemExit("--debug-port must be between 1 and 65535")
|
|
if args.ui_port < 0 or args.ui_port > 65535:
|
|
raise SystemExit("--ui-port must be between 0 and 65535")
|
|
|
|
bridge = DebuggerBridge(args.debug_host, args.debug_port)
|
|
process_manager = EmulatorProcessManager(bridge.journal)
|
|
handler_type = type(
|
|
"ConfiguredFrontendRequestHandler",
|
|
(FrontendRequestHandler,),
|
|
{"bridge": bridge, "process_manager": process_manager, "verbose": args.verbose},
|
|
)
|
|
server = create_frontend_server(args.listen, args.ui_port, handler_type)
|
|
actual_port = server.server_address[1]
|
|
browser_host = args.listen if args.listen not in {"0.0.0.0", "::"} else "127.0.0.1"
|
|
url = f"http://{browser_host}:{actual_port}/"
|
|
|
|
print(f"SharpEmu Debugger Frontend: {url}")
|
|
print(f"Debugger endpoint: {args.debug_host}:{args.debug_port}")
|
|
print("Press Ctrl+C to stop.")
|
|
if args.listen not in {"127.0.0.1", "localhost", "::1"}:
|
|
print("Warning: the frontend is listening beyond loopback; no authentication is provided.")
|
|
|
|
if not args.no_connect:
|
|
def connect_default() -> None:
|
|
try:
|
|
bridge.connect(args.debug_host, args.debug_port)
|
|
bridge.request({"command": "status"})
|
|
bridge.request({"command": "list-breakpoints"})
|
|
except BridgeError as exc:
|
|
bridge.journal.append("system", f"Start SharpEmu with --debug-server, then reconnect: {exc}")
|
|
|
|
threading.Thread(target=connect_default, name="sharpemu-auto-connect", daemon=True).start()
|
|
|
|
if not args.no_browser:
|
|
threading.Timer(0.35, lambda: webbrowser.open(url)).start()
|
|
|
|
try:
|
|
server.serve_forever(poll_interval=0.25)
|
|
except KeyboardInterrupt:
|
|
print("\nStopping frontend...")
|
|
finally:
|
|
bridge.disconnect(log=False)
|
|
process_manager.stop(log=False)
|
|
server.shutdown()
|
|
server.server_close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|