Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 e4410aaec2 fix(mcp): keep managed stdio fail-fast when the bridge drops
A managed tool call that races bridge shutdown was still in flight, so
the socket error rejected it with "TCP connection lost". Managed
children do not reread discovery; report that race as not connected
and leave external in-flight wording unchanged.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 04:08:16 +00:00
2 changed files with 87 additions and 11 deletions

No files matched your search

@@ -128,6 +128,7 @@ async function connectClient(entry, env) {
return {
client,
stderr,
pid: transport.pid,
async close() {
await client.close();
},
@@ -149,6 +150,21 @@ async function callEnvironment(client) {
return client.callTool({ name: "get_environment", arguments: {} });
}
async function waitForStoppedChild(pid) {
const statusPath = `/proc/${pid}/status`;
const deadline = Date.now() + 1000;
while (Date.now() < deadline) {
try {
if (/^State:\s+T/m.test(fs.readFileSync(statusPath, "utf8"))) return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 30));
return;
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error(`child ${pid} did not stop`);
}
test("external stdio bridge reconnects with refreshed discovery after the app endpoint changes", async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-reconnect-"));
const discoveryPath = path.join(tmp, "discovery.json");
@@ -344,3 +360,58 @@ test("managed stdio children fail fast without rereading discovery", async (t) =
assert.match(errorText(stillAlive), /Not connected to Netcatty/);
assert.equal(second.connections, 0);
});
test("managed stdio calls that race bridge shutdown stay not-connected", {
skip: process.platform === "win32" ? "SIGSTOP is POSIX-only" : false,
timeout: 10000,
}, async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-reconnect-"));
const discoveryPath = path.join(tmp, "discovery.json");
const first = startBridge({ token: "managed-token" });
const firstPort = await first.ready;
const child = await connectClient(MANAGED_SERVER, childEnv({
NETCATTY_MCP_PORT: String(firstPort),
NETCATTY_MCP_TOKEN: "managed-token",
}));
let second = null;
const resumeChild = () => {
if (!child.pid) return;
try { process.kill(child.pid, "SIGCONT"); } catch { /* already gone */ }
};
t.after(async () => {
resumeChild();
await child.close().catch(() => {});
await first.close().catch(() => {});
if (second) await second.close().catch(() => {});
fs.rmSync(tmp, { recursive: true, force: true });
});
const live = await callEnvironment(child.client);
assert.notEqual(live.isError, true, errorText(live));
assert.ok(child.pid);
// Stop the child before the bridge socket is destroyed so the next tool
// call is queued against a connection the process has not observed yet.
// Loaded CI hit this ordering and reported "TCP connection lost".
process.kill(child.pid, "SIGSTOP");
await waitForStoppedChild(child.pid);
await first.close();
second = startBridge({ token: "token-restarted" });
const secondPort = await second.ready;
writeDiscovery(discoveryPath, { port: secondPort, token: "token-restarted" });
const pending = callEnvironment(child.client);
await new Promise((resolve) => setTimeout(resolve, 100));
const started = Date.now();
resumeChild();
const failed = await pending;
assert.ok(Date.now() - started < 1000, "managed disconnect must not wait on discovery refresh");
assert.equal(failed.isError, true);
assert.match(errorText(failed), /Not connected to Netcatty/);
assert.equal(second.connections, 0);
const stillAlive = await callEnvironment(child.client);
assert.equal(stillAlive.isError, true);
assert.match(errorText(stillAlive), /Not connected to Netcatty/);
assert.equal(second.connections, 0);
});
+16 -11
View File
@@ -126,6 +126,17 @@ let pendingRequests = new Map(); // id -> { resolve, reject }
let nextRpcId = 1;
let tcpBuffer = "";
function rejectPendingOnSocket(sock, externalMessage) {
if (tcpSocket && tcpSocket !== sock) return;
const managed = !externalDiscoveryPath;
if (managed && tcpSocket === sock) tcpSocket = null;
const message = managed ? "Not connected to Netcatty" : externalMessage;
for (const { reject: rej } of pendingRequests.values()) {
rej(new Error(message));
}
pendingRequests.clear();
}
function connectTcp() {
return new Promise((resolve, reject) => {
const sock = net.createConnection({ host: "127.0.0.1", port: NETCATTY_MCP_PORT }, () => {
@@ -172,20 +183,14 @@ function connectTcp() {
sock.on("error", (err) => {
debugLog("TCP socket error", { message: err?.message || String(err) });
reject(err);
if (tcpSocket && tcpSocket !== sock) return;
// Reject in-flight calls only. They are not replayed after reconnect.
for (const { reject: rej } of pendingRequests.values()) {
rej(new Error("TCP connection lost"));
}
pendingRequests.clear();
// External in-flight calls stay rejected and are not replayed.
// Managed children do not reread discovery, so a call that races the
// dead socket fails as not-connected rather than "TCP connection lost".
rejectPendingOnSocket(sock, "TCP connection lost");
});
sock.on("close", () => {
debugLog("TCP socket closed");
if (tcpSocket && tcpSocket !== sock) return;
for (const { reject: rej } of pendingRequests.values()) {
rej(new Error("TCP connection closed"));
}
pendingRequests.clear();
rejectPendingOnSocket(sock, "TCP connection closed");
if (tcpSocket === sock) tcpSocket = null;
});
});