fix(@projects/@magic-civilization): 🐛 render MCP client — connect-first + longer boot grace (p2-86)

The first in-session magic_civ_screenshot timed out: windowed Godot spawned by
the MCP (a throttled background GUI process under Claude Code) inits far slower
than a terminal launch, exceeding the 45s connect timeout — though the driver
DID bind the port (verified 8787 open). Fixes:

- connect-first: if a rendered driver is already listening (e.g. a foreground
  `MC_MCP_RENDER=1 ./run play`), connect to it directly and skip the slow,
  throttled background spawn entirely.
- start timeout 45s -> 150s for the spawn fallback.
- a failed start now clears its cached promise + reaps the child so the next
  tool call respawns instead of replaying the rejection until an MCP restart.

Re-verified end-to-end through the built RenderClient (spawn path): ping {ok},
screenshot -> a real 3420x1923 PNG of the live world map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-19 09:11:20 -05:00
parent 0349a4e8fd
commit 6310433cc5

View file

@ -45,7 +45,10 @@ export class RenderClient {
this.opts = {
host: "127.0.0.1",
env: {},
startTimeoutMs: 45_000,
// Windowed Godot spawned by the MCP (background GUI process under Claude
// Code) is much slower to init + bind than a terminal launch — the driver
// port can take well over a minute to open. Be generous.
startTimeoutMs: 150_000,
requestTimeoutMs: 30_000,
...opts,
};
@ -57,12 +60,35 @@ export class RenderClient {
private ensureReady(): Promise<void> {
if (this.readyPromise === null) {
this.readyPromise = this.start();
// On a failed start (e.g. boot timeout), clear the cached promise and
// reap the child so the NEXT tool call respawns instead of replaying the
// rejection forever (which would otherwise need an MCP restart).
this.readyPromise = this.start().catch((err: unknown) => {
this.readyPromise = null;
if (this.child !== null) {
this.child.kill();
this.child = null;
}
throw err instanceof Error ? err : new Error(String(err));
});
}
return this.readyPromise;
}
private start(): Promise<void> {
// First: if a rendered driver is ALREADY listening (game launched
// separately, e.g. foreground `MC_MCP_RENDER=1 ./run play`), connect to it.
// This dodges the slow, window-server-throttled background spawn entirely.
return this.connectOnce().then(
() => undefined,
() => {
this.spawnGame();
return this.connectWithRetry();
},
);
}
private spawnGame(): void {
this.child = spawn(this.opts.serverScript, [], {
env: {
...process.env,
@ -76,30 +102,47 @@ export class RenderClient {
this.closed = true;
this.failAll(new Error("rendered game process exited"));
});
return this.connectWithRetry();
}
private attachSocket(s: Socket): void {
this.sock = s;
s.setEncoding("utf8");
s.on("data", (chunk: string) => this.onData(chunk));
s.on("close", () => {
this.sock = null;
});
}
private connectOnce(): Promise<void> {
return new Promise<void>((resolve, reject) => {
const s = connect({ host: this.opts.host, port: this.opts.port });
const timer = setTimeout(() => {
s.destroy();
reject(new Error("connect attempt timed out"));
}, 2500);
s.once("connect", () => {
clearTimeout(timer);
this.attachSocket(s);
resolve();
});
s.once("error", () => {
clearTimeout(timer);
s.destroy();
reject(new Error("connect attempt failed"));
});
});
}
private connectWithRetry(): Promise<void> {
const deadline = Date.now() + this.opts.startTimeoutMs;
return new Promise<void>((resolve, reject) => {
const attempt = (): void => {
const s = connect({ host: this.opts.host, port: this.opts.port });
s.once("connect", () => {
this.sock = s;
s.setEncoding("utf8");
s.on("data", (chunk: string) => this.onData(chunk));
s.on("close", () => {
this.sock = null;
});
resolve();
});
s.once("error", () => {
s.destroy();
this.connectOnce().then(resolve, () => {
if (Date.now() > deadline) {
reject(new Error("render driver TCP port never opened"));
return;
}
setTimeout(attempt, 500);
setTimeout(attempt, 750);
});
};
attempt();