Give each running game its own screen, capped at 10 concurrent
Previously every game shared one always-on Xvnc/fluxbox/websockify desktop, so starting two games at once meant they fought over focus on the same screen and the same ALSA device. Now: - server.sh no longer starts a shared desktop at all - it just runs setup_server.py. There's no default display anymore. - start_game() allocates a free display from GAME_DISPLAY_NUMS (:90-:99, one per MAX_CONCURRENT_GAMES=10 slot), spins up a fresh Xvnc+fluxbox+websockify for it, and launches the game with DISPLAY set to that display. All four processes are tracked together per game. - stop_game() tears down all four; is_running() does the same lazily if the game exited on its own (crash/quit), so a slot doesn't stay stuck just because nobody clicked Stop. - Starting past the 10-slot cap is refused with an error shown on that game's row instead of silently failing. - Each running game's row gets its own "Open Screen" link (client-side JS, since the port is only known once the game is actually started) instead of one global noVNC link. - run.sh publishes the whole 8090-8099 noVNC port range up front, since Docker can't add port mappings to an already-running container. Verified end-to-end: two different games running concurrently get fully independent Xvnc/fluxbox/websockify/game process sets and noVNC endpoints; stopping one leaves the other untouched; the capacity guard correctly refuses a start at the limit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
This commit is contained in:
+108
-30
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTML setup routine for DOSBox games.
|
||||
"""HTML setup routine for DOSBox/ScummVM games.
|
||||
|
||||
Nothing about which games exist is hardcoded here: available games are
|
||||
discovered by listing *.json manifests on the SMB share, one per game,
|
||||
@@ -23,6 +23,11 @@ the process tracked from the last start_cmd). Manifests themselves always live
|
||||
in SMB_DIR (the catalog directory), even though the zip_path they point at may
|
||||
not. Install size isn't a manifest field - it's looked up live from the share
|
||||
(via `smbclient ls`) so it can't go stale.
|
||||
|
||||
Each running game gets its own ephemeral X session (Xvnc + fluxbox +
|
||||
websockify on their own display/ports) instead of sharing one screen, so up
|
||||
to MAX_CONCURRENT_GAMES can run side by side without fighting over focus or
|
||||
being invisible to each other. Starting past that cap is refused.
|
||||
"""
|
||||
|
||||
import html
|
||||
@@ -31,6 +36,7 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
@@ -39,11 +45,18 @@ SMB_SHARE = "software"
|
||||
SMB_DIR = r"Games\dosbox"
|
||||
GAMES_HOME = os.environ["GAMES_HOME"]
|
||||
SETUP_PORT = int(os.environ.get("SETUP_PORT", "8080"))
|
||||
NOVNC_PORT = os.environ.get("NOVNC_PORT")
|
||||
SCREEN_W = os.environ.get("SCREEN_W", "1024")
|
||||
SCREEN_H = os.environ.get("SCREEN_H", "768")
|
||||
NOVNC_CERT = "/tmp/novnc.pem"
|
||||
NOVNC_KEY = "/tmp/novnc.key"
|
||||
|
||||
MAX_CONCURRENT_GAMES = 10
|
||||
GAME_DISPLAY_NUMS = list(range(90, 90 + MAX_CONCURRENT_GAMES)) # :90..:99, one per game slot
|
||||
|
||||
state_lock = threading.RLock()
|
||||
install_state = {} # name -> "downloading" | "extracting" | "done" | "error: ..."
|
||||
running_procs = {} # name -> subprocess.Popen
|
||||
game_errors = {} # name -> error message from the last failed start attempt
|
||||
running_procs = {} # name -> {game, xvnc, fluxbox, websockify: Popen, display_num, novnc_port}
|
||||
|
||||
|
||||
def smb_run(*commands):
|
||||
@@ -100,13 +113,46 @@ def is_installed(name):
|
||||
return os.path.isdir(path) and os.listdir(path)
|
||||
|
||||
|
||||
def _free_display_num():
|
||||
used = {info["display_num"] for info in running_procs.values()}
|
||||
for d in GAME_DISPLAY_NUMS:
|
||||
if d not in used:
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def _clean_display(display_num):
|
||||
subprocess.run(
|
||||
["rm", "-f", f"/tmp/.X{display_num}-lock", f"/tmp/.X11-unix/X{display_num}"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _teardown(info):
|
||||
for key in ("game", "websockify", "fluxbox", "xvnc"):
|
||||
proc = info[key]
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
for key in ("game", "websockify", "fluxbox", "xvnc"):
|
||||
proc = info[key]
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
_clean_display(info["display_num"])
|
||||
|
||||
|
||||
def is_running(name):
|
||||
with state_lock:
|
||||
proc = running_procs.get(name)
|
||||
if proc is None:
|
||||
info = running_procs.get(name)
|
||||
if info is None:
|
||||
return False
|
||||
if proc.poll() is None:
|
||||
if info["game"].poll() is None:
|
||||
return True
|
||||
# The game exited on its own (quit/crash) - tear down its X session too
|
||||
# instead of leaving an idle Xvnc/websockify pair holding the slot.
|
||||
_teardown(info)
|
||||
del running_procs[name]
|
||||
return False
|
||||
|
||||
@@ -153,21 +199,46 @@ def start_game(name, manifest):
|
||||
with state_lock:
|
||||
if is_running(name):
|
||||
return
|
||||
proc = subprocess.Popen(manifest["start_cmd"], cwd=os.path.join(GAMES_HOME, name))
|
||||
running_procs[name] = proc
|
||||
display_num = _free_display_num()
|
||||
if display_num is None:
|
||||
game_errors[name] = f"no free game slot ({MAX_CONCURRENT_GAMES}/{MAX_CONCURRENT_GAMES} running)"
|
||||
return
|
||||
game_errors.pop(name, None)
|
||||
|
||||
_clean_display(display_num)
|
||||
display = f":{display_num}"
|
||||
rfb_port = f"59{display_num}"
|
||||
novnc_port = int(f"80{display_num}")
|
||||
game_env = dict(os.environ, DISPLAY=display)
|
||||
|
||||
xvnc = subprocess.Popen(
|
||||
["Xvnc", display, "-geometry", f"{SCREEN_W}x{SCREEN_H}", "-depth", "24",
|
||||
"+xinerama", "-securitytypes", "none"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
fluxbox = subprocess.Popen(
|
||||
["fluxbox"], env=game_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
websockify = subprocess.Popen(
|
||||
["websockify", "--web=/usr/share/novnc/", f"--cert={NOVNC_CERT}", f"--key={NOVNC_KEY}",
|
||||
str(novnc_port), f"localhost:{rfb_port}"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
time.sleep(1) # give Xvnc a moment to bind before the game connects
|
||||
game_proc = subprocess.Popen(manifest["start_cmd"], cwd=os.path.join(GAMES_HOME, name), env=game_env)
|
||||
|
||||
running_procs[name] = {
|
||||
"game": game_proc, "xvnc": xvnc, "fluxbox": fluxbox, "websockify": websockify,
|
||||
"display_num": display_num, "novnc_port": novnc_port,
|
||||
}
|
||||
|
||||
|
||||
def stop_game(name):
|
||||
with state_lock:
|
||||
proc = running_procs.get(name)
|
||||
if proc is None:
|
||||
info = running_procs.get(name)
|
||||
if info is None:
|
||||
return
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
_teardown(info)
|
||||
with state_lock:
|
||||
running_procs.pop(name, None)
|
||||
|
||||
@@ -177,9 +248,14 @@ def render_page():
|
||||
sizes = get_zip_sizes(games)
|
||||
with state_lock:
|
||||
pending = dict(install_state)
|
||||
errors = dict(game_errors)
|
||||
for name in list(running_procs):
|
||||
is_running(name) # lazy cleanup of any game that exited on its own
|
||||
novnc_ports = {name: info["novnc_port"] for name, info in running_procs.items()}
|
||||
slots_used = len(novnc_ports)
|
||||
|
||||
any_pending = any(v in ("downloading", "extracting") for v in pending.values())
|
||||
refresh_tag = '<meta http-equiv="refresh" content="3">' if any_pending else ""
|
||||
refresh_tag = '<meta http-equiv="refresh" content="3">' if any_pending or slots_used else ""
|
||||
|
||||
def button(action, name, label):
|
||||
return (
|
||||
@@ -196,8 +272,15 @@ def render_page():
|
||||
size = sizes.get(name)
|
||||
size_html = f"{size / 1_000_000:.1f} MB" if size else "?"
|
||||
status = pending.get(name)
|
||||
if is_running(name):
|
||||
status_html = '<span class="status busy">Running</span>'
|
||||
if name in novnc_ports:
|
||||
port = novnc_ports[name]
|
||||
link_id = f"novnc-{name}"
|
||||
status_html = (
|
||||
f'<span class="status busy">Running</span> '
|
||||
f'<a id="{link_id}" href="#" target="_blank">Open Screen</a>'
|
||||
f"<script>document.getElementById('{link_id}').href = "
|
||||
f"'https://' + window.location.hostname + ':{port}/';</script>"
|
||||
)
|
||||
actions = button("stop", name, "Stop")
|
||||
elif status in ("downloading", "extracting"):
|
||||
status_html = f'<span class="status busy">{status}...</span>'
|
||||
@@ -207,7 +290,10 @@ def render_page():
|
||||
actions = button("install", name, "Retry")
|
||||
elif is_installed(name):
|
||||
status_html = '<span class="status ok">Installed</span>'
|
||||
actions = button("start", name, "Start") + button("uninstall", name, "Uninstall")
|
||||
if name in errors:
|
||||
status_html += f'<br><span class="status err">{html.escape(errors[name])}</span>'
|
||||
start_button = button("start", name, "Start") if slots_used < MAX_CONCURRENT_GAMES else ""
|
||||
actions = start_button + button("uninstall", name, "Uninstall")
|
||||
else:
|
||||
status_html = "Not installed"
|
||||
actions = button("install", name, "Install")
|
||||
@@ -216,14 +302,6 @@ def render_page():
|
||||
f"<td>{size_html}</td><td>{status_html}</td><td>{actions}</td></tr>"
|
||||
)
|
||||
|
||||
novnc_link = ""
|
||||
if NOVNC_PORT:
|
||||
novnc_link = f"""<a id="novnc-link" href="#" target="_blank">Open noVNC Screen</a>
|
||||
<script>
|
||||
document.getElementById('novnc-link').href =
|
||||
'https://' + window.location.hostname + ':{NOVNC_PORT}/';
|
||||
</script>"""
|
||||
|
||||
return f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -238,11 +316,11 @@ button {{ padding: 0.3em 0.8em; margin-right: 0.3em; }}
|
||||
.status.ok {{ color: #2a2; font-weight: bold; }}
|
||||
.status.busy {{ color: #a70; font-weight: bold; }}
|
||||
.status.err {{ color: #c22; }}
|
||||
.novnc-link {{ margin-bottom: 1.5em; display: block; }}
|
||||
.slots {{ margin-bottom: 1.5em; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="novnc-link">{novnc_link}</div>
|
||||
<p class="slots">{slots_used}/{MAX_CONCURRENT_GAMES} game slots in use</p>
|
||||
<h1>DOSBox games</h1>
|
||||
<table>
|
||||
<tr><th>Game</th><th>Release</th><th>Publisher</th><th>Size</th><th>Status</th><th>Actions</th></tr>
|
||||
|
||||
Reference in New Issue
Block a user