Files
minesweeper/sidecar/session.ts
T
pastilhasandClaude Opus 5 41020b1b65 build the wine prefix after the display exists, not before
A cold install could never work. `winetricks -q vb6run` runs Microsoft's
redistributable, and that installer creates a window even when told to be
quiet — so with no DISPLAY wine cannot load a graphics driver, the installer
dies, and winetricks reports `returned status 43`. That message names neither
wine nor a display, and reads like a corrupt download.

ensurePrefix ran before Xvfb started, because building a prefix looks like
preparation rather than something that needs a screen.

It never showed up in testing here: this machine's prefix was seeded from the
pre-extraction one, so the build path only ever ran against a prefix that was
already complete. The first genuine cold install — alpha — failed on the first
Start. Diagnosed from `err:winediag:nodrv_CreateWindow` in the wine output,
which is the only line that says what is actually wrong.

Order is now display, prefix, game. ensurePrefix takes the display explicitly
rather than reading the ambient environment, so it cannot silently run without
one again, and a failed build now tears the display down instead of leaving an
Xvfb behind.

Verified by moving this machine's prefix aside and starting cold: prefix built
from nothing, msvbvm60.dll installed, session up with the window at 152x214,
Z: confined, socket mode 700, and RFB 003.008 through the platform relay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 20:21:32 +00:00

270 lines
11 KiB
TypeScript

import type { Subprocess } from 'bun';
import { existsSync, rmSync } from 'node:fs';
import { GAME_EXE, RUN_DIR, VNC_SOCKET, ensureDirs } from './paths';
import { WINE_ENV, confineAndVerify, ensurePrefix, 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;
};
const SCREEN_W = 800;
const SCREEN_H = 600;
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;
}
/** The game's window. wine also creates a 1x1 owner stub, so the larger of the two is the real one. */
export async function windowGeometry(): Promise<WindowGeometry | null> {
if (!display) return null;
const search = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY: display }, 5_000);
if (!search.ok) return null;
let best: WindowGeometry | null = null;
for (const id of search.stdout.trim().split('\n').filter(Boolean)) {
const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY: display }, 5_000);
if (!geom.ok) continue;
const read = (key: string) => Number(new RegExp(`^${key}=(\\d+)$`, 'm').exec(geom.stdout)?.[1] ?? NaN);
const candidate = { width: read('WIDTH'), height: read('HEIGHT'), x: read('X'), y: read('Y') };
if (Object.values(candidate).some(Number.isNaN)) continue;
if (!best || candidate.width * candidate.height > best.width * best.height) best = candidate;
}
return best && best.width > 1 ? best : null;
}
/**
* 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): Proc => {
const proc = Bun.spawn(cmd, { env: { ...process.env, ...env }, 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)) {
log('launching Minesweeper X under wine');
game = spawn(['wine', GAME_EXE], env, 'wine');
const drew = await waitFor(async () => !!(await windowGeometry()), 30_000);
if (!drew) {
lastError = 'the game started but never drew a window';
await stop();
return status();
}
// Park the window at the framebuffer origin. Everything downstream then crops by using a canvas
// smaller than the framebuffer, with no offset arithmetic and nothing to keep in sync when the board
// resizes with the difficulty.
const found = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY: display! }, 5_000);
for (const id of found.stdout.trim().split('\n').filter(Boolean)) {
await run(['xdotool', 'windowmove', id, '0', '0'], { DISPLAY: display! }, 5_000);
}
}
// 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.
['x11vnc', '-display', display!, '-unixsock', VNC_SOCKET, '-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();
}
}
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> {
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();
}
export const runDir = RUN_DIR;
export const prefixIsReady = prefixReady;