Files
minesweeper/sidecar/session.ts
T
pastilhasandClaude Opus 5 035c374563 quit the game cleanly on stop, so best times survive
Where they live: `HKCU\Software\Brightsoft\Minesweeper X` in the prefix's
`user.reg`. `Time1/2/3` are beginner, intermediate and expert; `Name1/2/3` are
the names, which is why the name you type sticks as the default. Nothing is
stored beside the executable except the recordings.

Why they were always empty: the game holds that whole key in memory and commits
it ONLY on a clean exit. `stop()` sent SIGTERM. So every restart of this sidecar
threw away every record set since the last one — six wins in one evening left a
Best Times table reading 9999.99, which is what started this.

`stop()` now drives Game > Exit and waits for the process to go before reaping.
It runs FIRST, ahead of even reaping x11vnc, because it needs the display alive
and it is racing PM2's clock.

── how it was pinned down ──

Not by reading. `user.reg` grew by 848 bytes at the exact moment the game
exited, which named the file. Writing `Time1="12.34"` / `Name1="Pastilhas"`
into it by hand and restarting made the game display them, which proved the key
and the load path. Then switching difficulty and stopping cleanly wrote
Width/Height/Mines through from 30/16/99 to 8/8/10, which proved the write path
carries in-memory state — beginner is 8x8 here, not the 9x9 I had assumed.

── the part this does NOT fix ──

`pm2 restart` will usually still cut it short: PM2 SIGKILLs 1600ms after
SIGTERM by default and a full stop takes about 5.6 seconds. That is not new —
this sidecar's teardown has always exceeded that budget — but it does mean the
fix only reliably applies to an explicit POST /stop. Raising it needs a
`kill_timeout` on `Pm2App` in the platform's plugins/ecosystem.ts, which has no
such field. Left alone rather than decided quietly; it is in the README.

Also reverted: the 12.34 "Pastilhas" beginner record was my probe, not a game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 23:50:37 +00:00

474 lines
21 KiB
TypeScript

import type { Subprocess } from 'bun';
import { existsSync, rmSync } from 'node:fs';
import { GAME_DIR, GAME_EXE, GAME_IN_PREFIX, RUN_DIR, VNC_SOCKET, ensureDirs } from './paths';
import { WINE_ENV, confineAndVerify, ensurePrefix, installGame, prefixReady } from './prefix';
import { run, waitFor } from './run';
/**
* The Minesweeper session: a virtual display, one Windows game on it, and x11vnc serving that display over
* a private unix socket.
*
* ── The sidecar owns these processes ──
*
* They are spawned as its children, so PM2's tree-kill reaches them and a `pm2 restart officer-minesweeper`
* genuinely restarts the session. The version this replaced had the PLATFORM spawn a detached shell script:
* the game outlived every platform restart, nothing supervised it, and a crashed run left wine processes
* holding the prefix so the next start hung rather than failed. That last one was diagnosed the hard way —
* four `winedevice` processes survived a stop that reported success.
*
* ── Stopping wine properly ──
*
* Killing the game is not enough: `wineserver` and `winedevice` outlive it and keep the prefix locked. The
* script this replaces reached for `pkill -f wineserver`, which would also kill every OTHER wine prefix on
* the machine — someone's actual application, killed by a game of Minesweeper. `wineserver -k` with
* `WINEPREFIX` set is the scoped equivalent and is what wine provides for exactly this.
*/
type Proc = Subprocess<'ignore', 'pipe', 'pipe'>;
export type WindowGeometry = { width: number; height: number; x: number; y: number };
export type SessionStatus = {
/** The game is present and the host has what it needs to run it. */
installed: boolean;
running: boolean;
/** Null until the game has drawn its window. */
window: WindowGeometry | null;
/** Set when the last start failed, so the panel can say why instead of showing an empty canvas. */
error?: string;
};
/**
* The stage: a fixed framebuffer, with the game centred in it.
*
* The game window changes size with the difficulty — 152x214 on beginner, 504x342 on expert, larger
* again on a custom board — and the whole framebuffer is what gets streamed, so the panel sees one
* stable rectangle whatever is being played. The alternative, and what this replaced, was parking the
* window at the origin and having the client crop to its size: that made the panel resize under the
* player mid-game, and it was wrong by the 3x29 wine puts between the root origin and the window, which
* showed as a black strip on top and a row of cut-off cells at the bottom.
*
* ── Why it is this small ──
*
* The stage is what gets scaled to fill the panel, so every pixel of empty surround is panel the game
* does not get. The client scales by whole numbers only — fractional scaling makes a 16px grid uneven —
* so the size here decides which step it lands on, and the steps are far apart. In a 1866-device-pixel
* panel: 800 wide gives 2x, 560 wide gives 3x. Same panel, half again as much game.
*
* 512x384. The width is expert (504x342, measured) plus four pixels a side; the HEIGHT is not, and the
* extra 42 is load-bearing.
*
* At 512x352 — expert plus ten pixels of height — the game refuses to open a replay with "Your screen
* cannot fit a window of that size", even though the replay window is exactly the 504x342 it just
* measured. It is not a positional check: from a centred beginner window the expert window would run
* 236px off the right edge and the game repositions it happily. It is a minimum-screen check with slack
* the game never spends, and the slack it wants is roughly a title bar it does not have here. 384 was
* verified by opening an expert replay; 352 was verified refusing one.
*
* Growing the stage costs nothing at the sizes people use, which is why the number is generous rather
* than minimal: the panel caps the scale at 2x, so anything up to about 726x451 draws the game at exactly
* the same size. The stage only starts costing pixels once `fit` drops below the cap.
*
* Two things are still clipped here, both deliberate. A CUSTOM board larger than the stage loses its
* right and bottom edges — `centre` clamps at zero, so the menu bar stays reachable. And Extras → Auto
* Options is 328 wide and opens 25px right of the game window, so it needs a 554-wide stage to fit whole
* from beginner. That dialog cannot be closed from the panel anyway (no window manager, so no title bar,
* and it has no OK button) — see the README. Fix that first, then revisit the width.
*/
const SCREEN_W = 512;
const SCREEN_H = 384;
/**
* How often to check whether the board changed size.
*
* Nothing announces a resize — not RFB, which keeps the framebuffer, and not X without a window manager
* to listen through. The game only resizes when somebody picks a difficulty, so this is idle almost
* always; one chained `xdotool` per second is the price of the re-centre feeling immediate when it is
* not.
*/
const CENTRE_INTERVAL_MS = 1_000;
/** The desktop behind the game. Windows 95's own, because that is what this is a picture of. */
const STAGE_COLOUR = '#008080';
let xvfb: Proc | null = null;
let game: Proc | null = null;
let vnc: Proc | null = null;
let display: string | null = null;
let lastError: string | null = null;
let starting: Promise<SessionStatus> | null = null;
const alive = (p: Proc | null): boolean => !!p && p.exitCode === null && p.signalCode === null;
/**
* A display number nothing else is using.
*
* An X display IS a unix socket at `/tmp/.X11-unix/X<n>`, so this is a real check rather than a guess. The
* version this replaces hardcoded `:7`, which silently attaches to whatever is already there — including
* the owner's own session if they happen to run one on that number.
*/
function pickDisplay(): string | null {
for (let n = 20; n < 100; n++) {
if (!existsSync(`/tmp/.X11-unix/X${n}`)) return `:${n}`;
}
return null;
}
type FoundWindow = WindowGeometry & { id: string };
/**
* The game's window, with the id needed to move it.
*
* wine creates several 1x1 owner stubs under the same name, so the largest candidate is the real one.
*
* One `xdotool` process, not one per candidate: `search … getwindowgeometry --shell %@` chains the two
* commands and prints a block per window. That matters because this now runs on a timer rather than once
* at startup, and the old shape spawned five processes a tick.
*/
export async function findWindow(): Promise<FoundWindow | null> {
if (!display) return null;
const found = await run(
['xdotool', 'search', '--name', 'Minesweeper X', 'getwindowgeometry', '--shell', '%@'],
{ DISPLAY: display },
5_000,
);
if (!found.ok) return null;
// WINDOW= opens each block; the keys after it belong to that window until the next one.
const blocks: Record<string, string>[] = [];
for (const line of found.stdout.split('\n')) {
const match = /^(\w+)=(.+)$/.exec(line.trim());
if (!match) continue;
const [, key, value] = match;
if (key === undefined || value === undefined) continue; // the regex guarantees both; the compiler does not
if (key === 'WINDOW') blocks.push({});
const block = blocks[blocks.length - 1];
if (block) block[key] = value;
}
let best: FoundWindow | null = null;
for (const block of blocks) {
const id = block.WINDOW;
const candidate = {
width: Number(block.WIDTH),
height: Number(block.HEIGHT),
x: Number(block.X),
y: Number(block.Y),
};
if (!id || Object.values(candidate).some(Number.isNaN)) continue;
if (!best || candidate.width * candidate.height > best.width * best.height) best = { id, ...candidate };
}
return best && best.width > 1 ? best : null;
}
/** The game's window geometry, for `/status`. Nothing in the panel needs it — it is a diagnostic. */
export async function windowGeometry(): Promise<WindowGeometry | null> {
const found = await findWindow();
return found ? { width: found.width, height: found.height, x: found.x, y: found.y } : null;
}
/**
* Put the game in the middle of the stage.
*
* Clamped at zero so a board wider or taller than the framebuffer loses its right and bottom edges
* rather than its menu bar — a negative origin would push the part you need to click off the top.
*/
async function centre(): Promise<void> {
const win = await findWindow();
if (!win || !display) return;
const x = Math.max(0, Math.round((SCREEN_W - win.width) / 2));
const y = Math.max(0, Math.round((SCREEN_H - win.height) / 2));
if (win.x === x && win.y === y) return;
await run(['xdotool', 'windowmove', win.id, String(x), String(y)], { DISPLAY: display }, 5_000);
}
let centring: ReturnType<typeof setTimeout> | null = null;
/**
* Re-centre whenever the board changes size, for as long as the session is up.
*
* Self-scheduling rather than `setInterval`: a tick spawns a process and awaits it, and an interval that
* fires faster than that completes would stack ticks on a slow machine.
*/
function watchForResize(): void {
if (centring) return;
const tick = async () => {
if (!isRunning()) {
centring = null;
return;
}
await centre().catch(() => undefined);
centring = setTimeout(tick, CENTRE_INTERVAL_MS);
};
centring = setTimeout(tick, CENTRE_INTERVAL_MS);
}
/**
* Drive the game's own UI with the window held still.
*
* Anything that opens a menu clicks twice — once on the menu bar, once on the item — and the re-centring
* watcher runs between those two clicks often enough to matter. If the window moves after the first
* click, the second lands wherever the item used to be, which on this game means somewhere on the BOARD.
* A stray click on a live board reveals a cell, and can lose a game outright, so this is not a tidiness
* fix.
*
* Re-centres before handing the watcher back, so the window is not left off-centre for up to a second
* after a difficulty change resizes it.
*/
export async function withStableWindow<T>(fn: () => Promise<T>): Promise<T> {
const wasWatching = centring !== null;
if (centring) {
clearTimeout(centring);
centring = null;
}
try {
return await fn();
} finally {
await centre().catch(() => undefined);
if (wasWatching) watchForResize();
}
}
/**
* Whether the session is up, decided by the socket file plus a live x11vnc.
*
* Deliberately NOT by connecting. An earlier version opened a connection and dropped it, which wedged the
* server: libvncserver peeks at every new connection to decide whether it is a WebSocket handshake, and
* that peek blocks its main loop until the client speaks or the peek times out. A health check that
* connects and says nothing is therefore a small stall — and the panel polls status on a timer, so the
* stalls overlapped, the accept backlog filled, and the real viewer stopped getting in.
*
* The rule that cost: a liveness check must not be indistinguishable from a client.
*/
export const isRunning = (): boolean => alive(vnc) && alive(xvfb) && existsSync(VNC_SOCKET);
export async function status(): Promise<SessionStatus> {
const installed = existsSync(GAME_EXE) && !!Bun.which('wine') && !!Bun.which('Xvfb') && !!Bun.which('x11vnc');
const running = isRunning();
return {
installed,
running,
window: running ? await windowGeometry() : null,
...(lastError && !running ? { error: lastError } : {}),
};
}
const spawn = (cmd: string[], env: Record<string, string>, logName: string, cwd?: string): Proc => {
const proc = Bun.spawn(cmd, { env: { ...process.env, ...env }, cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
// Wine and x11vnc both say useful things only when something is wrong. Prefixed so `pm2 logs
// officer-minesweeper` shows which process is complaining.
for (const stream of [proc.stdout, proc.stderr]) {
void (async () => {
for await (const chunk of stream as ReadableStream<Uint8Array>) {
const text = new TextDecoder().decode(chunk).trimEnd();
if (text) console.log(`[minesweeper:${logName}] ${text}`);
}
})().catch(() => {
/* stream closed with the process */
});
}
return proc as Proc;
};
async function startInner(log: (line: string) => void): Promise<SessionStatus> {
ensureDirs();
lastError = null;
if (!existsSync(GAME_EXE)) {
lastError = 'the game executable is missing from the plugin directory';
return status();
}
// ── the display ──
if (!alive(xvfb)) {
display = pickDisplay();
if (!display) {
lastError = 'no free X display number between :20 and :99';
return status();
}
log(`Xvfb on ${display} at ${SCREEN_W}x${SCREEN_H}`);
// `-nolisten tcp` — the display is reached through its unix socket and has no business on a port.
xvfb = spawn(['Xvfb', display, '-screen', '0', `${SCREEN_W}x${SCREEN_H}x24`, '-nolisten', 'tcp'], {}, 'xvfb');
const up = await waitFor(async () => (await run(['xdpyinfo', '-display', display!], {}, 5_000)).ok, 15_000);
if (!up) {
lastError = 'the virtual display never became usable';
await stop();
return status();
}
}
// The prefix is built HERE, after the display exists and before the game needs it. Not earlier: the VB6
// installer opens a window, so a prefix built without a display fails in a way that names neither.
const built = await ensurePrefix(log, display!);
if (!built.ok) {
lastError = built.detail;
await stop();
return status();
}
const env = { ...WINE_ENV, DISPLAY: display! };
// ── the game ──
if (!alive(game)) {
// Into the prefix first. Run from the plugin directory the game has no DOS path of its own, and the
// first thing that needs one is saving a replay — which happens at the END of a game, so the failure
// arrives after somebody has played rather than when they start.
await installGame();
log('launching Minesweeper X under wine');
// cwd as well as the exe path: without it wine reports "could not open working directory" and starts
// the process in C:\windows, so anything the game writes relatively lands there instead.
game = spawn(['wine', GAME_IN_PREFIX], env, 'wine', GAME_DIR);
const drew = await waitFor(async () => !!(await windowGeometry()), 30_000);
if (!drew) {
lastError = 'the game started but never drew a window';
await stop();
return status();
}
await centre();
}
// Wine has now booted the prefix, and booting it is what can restore `Z:`. The confinement in
// `ensurePrefix` happens before that, so on its own it would be undone by exactly the event it exists to
// survive. See `confineAndVerify`.
confineAndVerify(log);
// ── the display server ──
if (!alive(vnc)) {
// A socket left by a crashed run refuses to bind. Removing it is safe: nothing else may write here.
rmSync(VNC_SOCKET, { force: true });
log('x11vnc on a private unix socket');
/*
`-nopw` with `-unixsock`, and the pair is the whole security argument.
There is no listening port. The only way in is opening a socket file inside RUN_DIR, which is mode
0700 and owned by whoever runs this sidecar — so the kernel refuses every other account before
x11vnc is asked anything. A password would be a second lock on a door only one person can reach, and
one more secret to store, rotate and leak.
The version this replaces bound `127.0.0.1:5907` with `-nopw`, which is a different claim entirely:
every local account could open it and take the display. Survivable on a single-user machine, and not
once members have Linux accounts. A fixed port is squattable too — anything can bind 5907 first and
then be relayed to, by the platform, on this plugin's behalf.
*/
vnc = spawn(
// -forever: survive a viewer disconnecting. -shared: more than one viewer. -noxdamage: the damage
// extension reports nothing useful on Xvfb and costs a poll that misses updates.
//
// THE THREE PORT FLAGS ARE WHAT MAKE THE PARAGRAPH ABOVE TRUE, AND ALL THREE ARE LOAD-BEARING.
//
// `-unixsock` ADDS a unix socket; it does not remove the TCP one. So the first version of this
// listened on 0.0.0.0:5900 AND [::]:5900 with -nopw, while its own comment said there was no port
// to authenticate against — anything on the tailnet, anything on the LAN, and every other account
// on the machine could take the display. Found with `ss -lntp` on the running session. Reading the
// flags would never have shown it, because the flag that mattered was the one nobody had written.
//
// `-rfbport 0` alone leaves `[::]:5900` up; `-rfbportv6 0` is what closes it. Verified by `ss` on
// the real process both times, which is the only check that has been right about this so far —
// running the same argv by hand showed no IPv6 listener at all, almost certainly because the
// session under PM2 was still holding [::]:5900 and the hand-run copy silently failed to bind it.
//
// Do not drop one of these because a check looks clean. Check the RUNNING process.
//
// -solid: the stage is bigger than the game, so the surround is now something people look at. Teal
// rather than the root's black, which reads as a broken stream rather than a desktop. x11vnc sets it
// itself on a bare display, so this costs no new host binary.
//
// -nocursor: the browser already draws a cursor over the canvas, and it is the accurate one. The
// client asks for Raw and CopyRect and nothing else — no cursor pseudo-encoding — so x11vnc has
// nowhere to send a cursor SHAPE and composites it into the framebuffer instead. That put a second
// pointer in the picture, lagging the real one by a round trip and, on a bare X server with no
// window manager, drawn as the big black X of the root cursor. Hiding it server-side is the fix;
// adding the pseudo-encoding to the client would be a worse one, since the answer is not to draw
// the remote cursor better but not to draw it at all.
['x11vnc', '-display', display!, '-unixsock', VNC_SOCKET, '-rfbport', '0', '-rfbportv6', '0', '-noipv6', '-solid', STAGE_COLOUR, '-nocursor', '-forever', '-shared', '-nopw', '-noxdamage', '-wait', '5', '-defer', '5'],
{ DISPLAY: display! },
'x11vnc',
);
const listening = await waitFor(() => existsSync(VNC_SOCKET), 15_000);
if (!listening) {
lastError = 'x11vnc never created its socket';
await stop();
return status();
}
}
watchForResize();
log('ready');
return status();
}
/** Idempotent, and safe to call concurrently — two panels opening at once must not race two sessions up. */
export function start(log: (line: string) => void): Promise<SessionStatus> {
if (isRunning()) return status();
if (starting) return starting;
starting = startInner(log).finally(() => {
starting = null;
});
return starting;
}
async function reap(proc: Proc | null, name: string): Promise<void> {
if (!alive(proc)) return;
proc!.kill('SIGTERM');
const gone = await waitFor(() => !alive(proc), 3_000, 100);
if (!gone) {
console.log(`[minesweeper] ${name} ignored SIGTERM; killing`);
proc!.kill('SIGKILL');
}
}
export async function stop(): Promise<SessionStatus> {
if (centring) {
clearTimeout(centring);
centring = null;
}
// FIRST, before anything else is torn down. A clean exit is the only time the game writes its settings
// key — best times included — so `stop()` used to discard every record set since the last start. Six
// wins in one evening left a Best Times table reading 9999.99.
//
// First for two reasons. It needs the display and the game both alive, and it is racing a clock: PM2
// sends SIGTERM and SIGKILLs 1600ms later by default, while a full stop here takes about 5.6 seconds.
// Spending any of that budget on reaping x11vnc first is spending it on the part that does not matter.
//
// Even so, `pm2 restart` will usually cut this short — see the README. An explicit POST /stop always
// completes, and that is the path the platform uses when a panel asks.
//
// Imported dynamically on purpose: `menu.ts` imports from this file, and a static import would close the
// cycle. The dynamic form keeps the dependency one-way at module load, where it matters.
if (display && alive(game)) {
const { quitGame } = await import('./menu');
const quit = await quitGame(display, () => alive(game));
if (!quit) console.log('[minesweeper] the game did not quit cleanly; settings since the last start are lost');
}
await reap(vnc, 'x11vnc');
vnc = null;
rmSync(VNC_SOCKET, { force: true });
await reap(game, 'wine');
game = null;
// Scoped to THIS prefix. `wineserver` and `winedevice` are not our children and outlive the game, holding
// the prefix so the next start hangs; `-k` is wine's own way to end them, and unlike `pkill -f wineserver`
// it cannot touch another prefix somebody else is using.
if (Bun.which('wineserver')) await run(['wineserver', '-k'], WINE_ENV, 10_000).catch(() => undefined);
await reap(xvfb, 'Xvfb');
xvfb = null;
display = null;
return status();
}
/** The display the session is on, for anything that has to drive the game's own UI. Null when it is down. */
export const activeDisplay = (): string | null => display;
export const runDir = RUN_DIR;
export const prefixIsReady = prefixReady;