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:
2026-07-28 15:25:00 +02:00
co-authored by Claude Sonnet 5
parent 60b8f63ad0
commit 3332553cb5
6 changed files with 148 additions and 57 deletions
+17 -13
View File
@@ -35,30 +35,34 @@ There's no automated way to exercise `scripts/server.sh`, `scripts/setup_server.
- `${GAMES_HOME}` (`/opt/games`) is created and declared as a `VOLUME` — it's meant to be backed by a named volume at `docker run` time (`run.sh` uses `-v dosbox-games:/opt/games`) so installed games survive `--rm` container restarts instead of being re-fetched every time.
- `ENTRYPOINT` is `/opt/scripts/server_start.sh /opt/scripts/server.sh` — the signal-handling wrapper invoked with the real startup script as its argument.
**Runtime env vars** (set at `docker run` time, not baked into the image): `DISPLAY_NUM` (X display number, default 99 → `DISPLAY=:99`), `SCREEN_W`, `SCREEN_H` (framebuffer geometry).
**Runtime env vars** (set at `docker run` time, not baked into the image): `DISPLAY_NUM` (only used to compute the setup port, see below — default 99), `SCREEN_W`, `SCREEN_H` (framebuffer geometry, shared by every game's Xvnc instance).
**Port scheme**, all derived from `DISPLAY_NUM`:
- `59${DISPLAY_NUM}` — raw VNC (RFB), only bound to localhost, not published directly.
- `80${DISPLAY_NUM}` — noVNC over HTTPS/WSS. This is the port users connect to from a browser to see/play the game.
- `60${DISPLAY_NUM}``EXPOSE`d for consistency with the other noVNC-family projects, but nothing in `server.sh` actually bridges X11-over-TCP here (no `socat` call) — this project doesn't wire that up, unlike `docker-xserver-novnc`/`docker-sdr-novnc`.
There is **no single shared desktop/display** — unlike the other noVNC-family projects, this one doesn't run one always-on `Xvnc`+`fluxbox`+`websockify` trio for the whole container lifetime. `server.sh` starts nothing but `setup_server.py`; every game gets its own ephemeral X session, created when it's started and torn down when it's stopped (see below).
**Port scheme**:
- `70${DISPLAY_NUM}` — the game **setup UI** (`scripts/setup_server.py`), plain HTTP. `run.sh` publishes this as `7099:7099`.
- `5990`-`5999` — per-game raw VNC (RFB), one port per concurrent game slot, not published (matches the "RFB stays localhost-only" convention elsewhere in the noVNC family).
- `8090`-`8099` — per-game noVNC over HTTPS/WSS, one port per concurrent game slot (`MAX_CONCURRENT_GAMES = 10` in `setup_server.py`). `run.sh` publishes the whole range (`-p 8090-8099:8090-8099`) up front, since Docker can't add port mappings to an already-running container — a game's actual assigned port within that range is only known once it's started.
- `60${DISPLAY_NUM}``EXPOSE`d for consistency with the other noVNC-family projects, but nothing wires up X11-over-TCP here (no `socat` call) — this project doesn't do that, unlike `docker-xserver-novnc`/`docker-sdr-novnc`.
`run.sh` hardcodes `DISPLAY_NUM=99` (implicitly, via the image default) and the `7099` port mapping; update it if `DISPLAY_NUM` ever changes. Note `run.sh` does *not* currently publish the `59xx`/`80xx` noVNC ports to the host — only `7099` (setup) is mapped, plus whatever the container's on the same Docker network can reach directly.
`run.sh` hardcodes `DISPLAY_NUM=99` (implicitly, via the image default), only relevant to the `7099` setup port; update it if `DISPLAY_NUM` ever changes.
**Game installation & lifecycle** (`scripts/setup_server.py`), the key thing that differs from the other noVNC-family projects:
- A stdlib-only Python `ThreadingHTTPServer` (no third-party deps, no pip installs) started in the background by `server.sh` alongside Xvnc/fluxbox/websockify.
- A stdlib-only Python `ThreadingHTTPServer` (no third-party deps, no pip installs) — the *only* thing `server.sh` starts. No Xvnc/fluxbox/websockify run until a game is actually started.
- Games are **not** hardcoded anywhere in the image or discovered from raw zip files. Each game is described by its own `<name>.json` manifest living in the `Games\dosbox\` catalog directory on the SMB share, with `name`/`title`/`zip_path`/`release_date`/`publisher`/`start_cmd` fields — see the module docstring in `setup_server.py` for the exact schema. `discover_games()` lists `*.json` under `Games\dosbox\` (`smbclient -N ... -c 'ls Games\dosbox\*.json'`), `smbclient get`s each one into `/tmp/manifests/`, and parses it — so dropping a new `<name>.json` on the share is enough to make a game appear, no image rebuild needed. `zip_path` is a full path relative to the share root (e.g. `Games/Monkey Island/The Secret of Monkey Island.zip`), **not** assumed to live next to its manifest — manifests always live in the `Games\dosbox\` catalog regardless of where the actual game data sits on the share (the SCUMM games' zips live in their own folders, e.g. `Games\Monkey Island\`, `Games\Indiana Jones\`). `release_date`/`publisher` are static declared metadata (historical facts, can't be derived from the share); install size deliberately is **not** a manifest field — `get_zip_sizes()` looks it up live via a batched `smbclient ls` (one `ls "<path>"` per game in a single connection, results split on each command's `N blocks ... available` trailer) so it can't go stale if a zip is replaced.
- `GET /` renders one row per discovered manifest (title/release/publisher/size/status/actions) plus an "Open noVNC Screen" link at the top. That link's `href` is set by a few lines of inline client-side JS (the only JS on the page) reading `window.location.hostname` at render time and pointing at `${NOVNC_PORT}` (passed into `setup_server.py` as an env var by `server.sh`, alongside `SETUP_PORT`) — has to be client-side since the container may be reached via different hostnames/IPs and the noVNC port differs from the setup port, so a fixed server-rendered URL would be wrong. noVNC's `index.html` already redirects to `vnc.html?autoconnect=true`, so the link just points at the port root.
- `GET /` renders one row per discovered manifest (title/release/publisher/size/status/actions) plus a `"{used}/{MAX_CONCURRENT_GAMES} game slots in use"` line at the top.
- `POST /install`: `smbget -au` (guest auth) the manifest's `zip_path` into a `/tmp/setup-<name>` scratch dir, then `unzip -o -d ${GAMES_HOME}`. `smbget -a` (guest) cannot be combined with `-o` (output-file) — they conflict on the underlying `-U` option — so the download `cd`s into the scratch dir and lets `smbget` save under its default filename instead. Installed-ness is just "non-empty `${GAMES_HOME}/<name>` directory exists"; a zip is expected to contain one top-level folder matching its own basename (this happens to already match ScummVM's own game-target IDs for the SCUMM titles, e.g. `monkey2.zip``monkey2/` — convenient, not enforced by the code).
- `POST /uninstall`: `rm -rf ${GAMES_HOME}/<name>`. Refused while the game is running (stop it first) — there's no auto-stop-then-delete.
- `POST /start`: `subprocess.Popen(manifest["start_cmd"], cwd=GAMES_HOME/<name>)`, tracked in an in-process dict keyed by game name (`running_procs`). This inherits `setup_server.py`'s own environment, including `$DISPLAY`, so the launched process renders onto the same Xvnc display fluxbox/noVNC are already serving — no separate X setup needed.
- `POST /stop`: `terminate()`s the tracked process, escalating to `kill()` after a 5s grace period.
- Per-game install progress (`install_state`) and the running-process table (`running_procs`) are both guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page does a `<meta http-equiv="refresh">` every 3s while any install is in flight; the only client-side JS is the noVNC link's `href` (above).
- **`POST /start`: each game gets its own ephemeral X session, not a shared one.** `_free_display_num()` picks an unused display from `GAME_DISPLAY_NUMS` (`:90`-`:99`, one per `MAX_CONCURRENT_GAMES = 10` slot); if none is free, the attempt is refused and `game_errors[name]` is set to a message shown on that game's row (no slot ever gets allocated for it). Otherwise: clean any stale `/tmp/.X{N}-lock`/`/tmp/.X11-unix/X{N}` for that display (the same reason the other noVNC-family projects do this on startup — a prior Xvnc for that display might not have cleaned up after itself), then start `Xvnc :{N}`, `fluxbox`, and `websockify` (bridging `59{N}``80{N}`, no `-D`/daemonize flag — needs to stay a normal foreground child so its `Popen` handle actually reflects whether it's still alive, unlike the old shared-desktop `server.sh` which didn't care), wait ~1s for Xvnc to bind, then `subprocess.Popen(manifest["start_cmd"], cwd=GAMES_HOME/<name>, env={..., "DISPLAY": f":{N}"})`. All four `Popen` handles plus the display/port numbers are tracked together in `running_procs[name]`.
- `POST /stop`: tears down all four processes for that game (game first, then websockify/fluxbox/Xvnc), each `terminate()`d then escalated to `kill()` after a 5s grace period, and cleans up the stale-lock files for that display.
- `is_running(name)` does the same full teardown **lazily** if the game process exited on its own (quit or crash) — it won't leave an idle Xvnc/websockify pair holding a slot forever just because nobody clicked Stop. `render_page()` calls this for every tracked game on every load, so the slot count and each row's status stay accurate without polling.
- Each running game's row shows its own "Open Screen" link (only while `Running`), pointing at that game's specific noVNC port. Its `href` is set by a few lines of inline client-side JS (the only JS on the page) reading `window.location.hostname` at render time — has to be client-side since the container may be reached via different hostnames/IPs and each game's port is only known once it's actually started. noVNC's `index.html` already redirects to `vnc.html?autoconnect=true`, so the link just points at the port root.
- Per-game install progress (`install_state`), start errors (`game_errors`), and the running-process table (`running_procs`) are all guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page auto-refreshes every 3s while any install is in flight or any game is running, so a game that crashed shows its slot freed up on the next load without the user having to do anything.
- **Manifests on the share today**: `stuntcar` (DOSBox), `monkey`/`monkey2`/`atlantis`/`indy3`/`tentacle` (ScummVM). `t7g` still has no manifest and won't appear on the setup page until one's added.
**Playing a game — two overlapping paths right now**:
1. The setup page's Start/Stop buttons (above) — the current way, works for any manifest-driven game.
2. `game.sh <name>``docker exec -it dosbox-novnc start_game.sh <name>`, running *inside* the already-running container. `scripts/start_game.sh` hardcodes `DOSBOX_VERSION="0.74-3"` and the `run.bat`/`dosbox-<version>.conf` convention — this predates the manifest system and was deliberately left as-is during the pilot. The two paths don't know about each other (e.g. a game started via `game.sh` won't show as "Running" on the setup page). Worth reconciling once manifests cover every game — see `TODO.md`.
1. The setup page's Start/Stop buttons (above) — the current way, works for any manifest-driven game, gives it its own screen, and is capped at 10 concurrent.
2. `game.sh <name>``docker exec -it dosbox-novnc start_game.sh <name>`, running *inside* the already-running container. `scripts/start_game.sh` hardcodes `DOSBOX_VERSION="0.74-3"` and the `run.bat`/`dosbox-<version>.conf` convention, and — since there's no more a shared default display at all — has no `$DISPLAY` to render onto unless one happens to be set in the shell's environment. This path predates both the manifest system and the per-game-screen rework and was deliberately left as-is; it's increasingly out of step with the current architecture and worth removing or reworking once every remaining game has a manifest — see `TODO.md`.
**Signal handling** (`scripts/server_start.sh` + `scripts/signals.sh`): identical pattern to the other noVNC-family projects — Docker sends `SIGTERM` to PID 1 on `docker stop`, `server_start.sh` traps it, forwards it to the child process tree, waits for the descendants to exit, and re-raises `128 + signal number` as its own exit code. Generic (`$APP` passed as an argument), not specific to `server.sh`.