refactor: 拆分 claude-dev-stack 为 windows-dev-stack 和 wsl-dev-stack
将原 claude-dev-stack 目录拆分为独立的 Windows 和 WSL 部署栈,便于分别维护和使用。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { randomUUID } from "crypto";
|
||||
import { GodotConnectionError, GodotCommandError, TimeoutError, } from "./utils/errors.js";
|
||||
const BASE_PORT = 6505;
|
||||
const MAX_PORT = 6509;
|
||||
const COMMAND_TIMEOUT_MS = 30000;
|
||||
const HEARTBEAT_INTERVAL_MS = 10000;
|
||||
const HEARTBEAT_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 3;
|
||||
const TCP_KEEPALIVE_DELAY_MS = 5000;
|
||||
export class GodotConnection {
|
||||
wss = null;
|
||||
client = null;
|
||||
port;
|
||||
fixedPort;
|
||||
basePort;
|
||||
maxPort;
|
||||
pendingRequests = new Map();
|
||||
heartbeatTimer = null;
|
||||
lastPongAt = 0;
|
||||
constructor(port = BASE_PORT, fixedPort = false, options = {}) {
|
||||
this.port = port;
|
||||
this.fixedPort = fixedPort;
|
||||
this.basePort = options.basePort ?? BASE_PORT;
|
||||
this.maxPort = options.maxPort ?? MAX_PORT;
|
||||
}
|
||||
/** Start WebSocket server, retrying on the next port if the first bind races. */
|
||||
async connect() {
|
||||
if (this.wss)
|
||||
return;
|
||||
const candidates = this.fixedPort
|
||||
? [this.port]
|
||||
: Array.from({ length: this.maxPort - this.basePort + 1 }, (_, i) => this.basePort + i);
|
||||
let lastError = null;
|
||||
for (const port of candidates) {
|
||||
try {
|
||||
const wss = await this.bindWebSocketServer(port);
|
||||
this.wss = wss;
|
||||
this.port = port;
|
||||
this.attachConnectionHandler(wss);
|
||||
console.error(`[MCP] WebSocket server listening on ws://127.0.0.1:${port}`);
|
||||
return;
|
||||
}
|
||||
catch (err) {
|
||||
lastError = err;
|
||||
// EADDRINUSE means another MCP server (likely a parallel Claude session)
|
||||
// won the bind race. Silently try the next port. Other errors are
|
||||
// logged so we don't swallow real config problems.
|
||||
if (err.code !== "EADDRINUSE") {
|
||||
console.error(`[MCP] Bind failed on port ${port}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const range = this.fixedPort
|
||||
? String(this.port)
|
||||
: `${this.basePort}-${this.maxPort}`;
|
||||
const hint = this.fixedPort
|
||||
? "Try removing GODOT_MCP_PORT from your client config to enable auto-scanning, or kill the process holding the port."
|
||||
: "All ports are occupied — likely too many parallel Claude Code sessions or stale node MCP processes.";
|
||||
throw new GodotConnectionError(`Failed to bind WebSocket server on port range ${range}. ` +
|
||||
`Last error: ${lastError?.message ?? "unknown"}. ${hint}`);
|
||||
}
|
||||
/** Try to bind a single WebSocketServer. Resolves once 'listening' fires, rejects on bind error. */
|
||||
bindWebSocketServer(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wss = new WebSocketServer({ port, host: "127.0.0.1" });
|
||||
const onError = (err) => {
|
||||
wss.off("listening", onListening);
|
||||
wss.close();
|
||||
reject(err);
|
||||
};
|
||||
const onListening = () => {
|
||||
wss.off("error", onError);
|
||||
// Re-attach a runtime error handler now that the server is live.
|
||||
// Pre-bind errors fail the connect attempt; post-bind errors are logged.
|
||||
wss.on("error", (err) => {
|
||||
console.error("[MCP] WebSocket server error:", err.message);
|
||||
});
|
||||
resolve(wss);
|
||||
};
|
||||
wss.once("error", onError);
|
||||
wss.once("listening", onListening);
|
||||
});
|
||||
}
|
||||
attachConnectionHandler(wss) {
|
||||
wss.on("connection", (ws) => {
|
||||
console.error("[MCP] Godot editor connected");
|
||||
// Enable OS-level TCP keepalive so half-open sockets surface faster
|
||||
// than the Windows default (~2 hours). Application-level heartbeat
|
||||
// below is still the primary detection mechanism.
|
||||
const sock = ws._socket;
|
||||
sock?.setKeepAlive?.(true, TCP_KEEPALIVE_DELAY_MS);
|
||||
if (this.client) {
|
||||
this.client.close(1000, "Replaced by new connection");
|
||||
}
|
||||
this.client = ws;
|
||||
this.lastPongAt = Date.now();
|
||||
this.startHeartbeat();
|
||||
ws.on("message", (data) => {
|
||||
this.handleMessage(data.toString());
|
||||
});
|
||||
ws.on("close", () => {
|
||||
console.error("[MCP] Godot editor disconnected");
|
||||
if (this.client === ws) {
|
||||
this.client = null;
|
||||
this.stopHeartbeat();
|
||||
this.rejectAllPending(new GodotConnectionError("Godot disconnected"));
|
||||
}
|
||||
});
|
||||
ws.on("error", (err) => {
|
||||
console.error("[MCP] WebSocket error:", err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
disconnect() {
|
||||
this.stopHeartbeat();
|
||||
if (this.client) {
|
||||
this.client.close(1000, "Server shutting down");
|
||||
this.client = null;
|
||||
}
|
||||
if (this.wss) {
|
||||
this.wss.close();
|
||||
this.wss = null;
|
||||
}
|
||||
this.rejectAllPending(new GodotConnectionError("Server shut down"));
|
||||
}
|
||||
isConnected() {
|
||||
return this.client?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
getPort() {
|
||||
return this.port;
|
||||
}
|
||||
async sendCommand(method, params = {}) {
|
||||
if (!this.isConnected()) {
|
||||
throw new GodotConnectionError("Godot editor is not connected. Make sure the Godot MCP Pro plugin is enabled and the editor is running.");
|
||||
}
|
||||
const id = randomUUID();
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
method,
|
||||
params,
|
||||
id,
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new TimeoutError(method, COMMAND_TIMEOUT_MS));
|
||||
}, COMMAND_TIMEOUT_MS);
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve,
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
this.client.send(JSON.stringify(request));
|
||||
});
|
||||
}
|
||||
handleMessage(data) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(data);
|
||||
}
|
||||
catch {
|
||||
console.error("[MCP] Failed to parse message from Godot:", data);
|
||||
return;
|
||||
}
|
||||
const method = msg.method;
|
||||
if (method === "pong") {
|
||||
this.lastPongAt = Date.now();
|
||||
return;
|
||||
}
|
||||
// Godot may also send unsolicited pings — reply so its inactivity timer resets
|
||||
if (method === "ping") {
|
||||
this.lastPongAt = Date.now();
|
||||
if (this.isConnected()) {
|
||||
this.client.send(JSON.stringify({ jsonrpc: "2.0", method: "pong", params: {} }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!msg.id)
|
||||
return;
|
||||
const pending = this.pendingRequests.get(msg.id);
|
||||
if (!pending)
|
||||
return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingRequests.delete(msg.id);
|
||||
if (msg.error) {
|
||||
pending.reject(new GodotCommandError(msg.error.code, msg.error.message, msg.error.data));
|
||||
}
|
||||
else {
|
||||
pending.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
rejectAllPending(error) {
|
||||
for (const [, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
startHeartbeat() {
|
||||
this.stopHeartbeat();
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (!this.isConnected())
|
||||
return;
|
||||
// If Godot has been silent for too long, the socket is likely half-open.
|
||||
// terminate() forcibly destroys the TCP socket (vs close() which waits
|
||||
// for a FIN ack that will never arrive on a dead link).
|
||||
if (Date.now() - this.lastPongAt > HEARTBEAT_TIMEOUT_MS) {
|
||||
console.error(`[MCP] Heartbeat timeout (no pong for ${HEARTBEAT_TIMEOUT_MS}ms) — terminating dead connection`);
|
||||
const dead = this.client;
|
||||
this.client = null;
|
||||
this.stopHeartbeat();
|
||||
this.rejectAllPending(new GodotConnectionError("Heartbeat timeout — Godot connection lost"));
|
||||
dead?.terminate();
|
||||
return;
|
||||
}
|
||||
this.client.send(JSON.stringify({ jsonrpc: "2.0", method: "ping", params: {} }));
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=godot-connection.js.map
|
||||
Reference in New Issue
Block a user