Compare commits

...
Author SHA1 Message Date
Cursor AgentandGOUKI9999 fece2c8ab9 fix(cli): drop rare transport-error retry machinery
Keep the discovery-file and stale-token restart retry. A failed write
and a status-probe failure after auth are outside that fix, so getStatus
stays best-effort and WRITE_FAILED is not a separate retry path.

Co-authored-by: GOUKI9999 <11640942+GOUKI9999@users.noreply.github.com>
2026-10-20 07:24:21 +00:00
Cursor AgentandGOUKI9999 0ebba5e4de fix(cli): retry discovery refresh after a failed bridge write
Treat WRITE_FAILED like the other transport losses during auth/verify
and the status probe, so an app exit mid-write rereads discovery inside
the same budget. The original write error is kept when the identity
does not change.

Co-authored-by: GOUKI9999 <11640942+GOUKI9999@users.noreply.github.com>
2026-10-20 07:21:30 +00:00
Cursor AgentandGOUKI9999 29e65e21e2 fix(cli): keep polling a stale discovery through the full budget
An unchanged discovery identity no longer ends the retry early. The
remaining delay still runs without another TCP attempt, so a rewrite
inside the 1.2s window is observed. A transport loss during the status
probe is retried the same way; other status failures still keep the
default RPC timeout.

Co-authored-by: GOUKI9999 <11640942+GOUKI9999@users.noreply.github.com>
2026-10-20 07:17:05 +00:00
Cursor AgentandGOUKI9999 37db908ccd fix(cli): poll a missing startup discovery and auth transport loss
Include the initial discovery read in the existing 300ms + 900ms budget
so a restart gap with no file yet can heal, and retry when auth/verify
loses the socket. A later read failure does not replace an earlier
connect error.

Co-authored-by: GOUKI9999 <11640942+GOUKI9999@users.noreply.github.com>
2026-10-20 07:12:04 +00:00
Cursor AgentandGOUKI9999 f83784d363 fix(cli): keep discovery retries through the restart window
Treat a missing, unreadable, or unparseable discovery file as transient
during the bounded retry, and decide that discovery changed by port,
token, and pid. A stale-token auth rejection refreshes discovery inside
the same 300ms + 900ms budget. The original error text is unchanged
when every attempt fails.

Co-authored-by: GOUKI9999 <11640942+GOUKI9999@users.noreply.github.com>
2026-10-20 06:43:15 +00:00
GOUKI9999 bdf1c2c664 test(ai): cover pid-drift and invalid-JSON discovery self-heal
The self-heal sweep already rewrites discovery.json when the file is
missing, unreadable, fails to parse, or its port/token/pid no longer
match the current bridge (isCliDiscoveryFileCurrent). Cover the
pid-drift and invalid-JSON paths explicitly, and assert a current file
is left untouched so a rewrite-happy regression would be caught.
2026-10-20 06:35:11 +00:00
GOUKI9999 8e5f002238 fix(cli): retry bridge connection with refreshed discovery
The TCP bridge listens on an ephemeral port (server.listen(0)) and
rewrites discovery.json on every app launch, so a CLI that reads the
discovery file during an app restart can hit a dead port and fail with
CONNECT_FAILED until the chat is reopened (#3443).

On TCP connect failure, re-read the discovery file and retry, bounded
to two extra attempts (300ms, 900ms). If the re-read yields the same
port nothing changed, stop immediately instead of hammering the dead
port. If the file disappeared mid-retry, surface the original connect
error. Final error text is unchanged.
2026-10-20 06:35:10 +00:00
3 changed files with 808 additions and 20 deletions

No files matched your search

@@ -49,6 +49,60 @@ test("self-heals a deleted discovery file while the TCP host stays alive", async
assert.equal(fixed.token, original.token);
});
test("self-heal rewrites files whose pid drifted and files that are invalid JSON", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-discovery-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
bridge.cleanup();
fs.rmSync(dir, { recursive: true, force: true });
});
bridge.init({ cliDiscoveryFilePath: discoveryPath });
const port = await bridge.getOrCreateHost();
const current = readDiscovery(discoveryPath);
// Correct port/token but a pid from a previous app instance: the file is
// stale from the CLI's perspective and must be rewritten.
fs.writeFileSync(discoveryPath, JSON.stringify({
port,
token: current.token,
pid: process.pid + 1,
permissionMode: current.permissionMode,
updatedAt: "stale",
}));
bridge.ensureCliDiscoveryFile();
const repid = readDiscovery(discoveryPath);
assert.equal(repid.pid, process.pid);
assert.equal(repid.port, port);
// A file that cannot be parsed is treated as missing and rewritten.
fs.writeFileSync(discoveryPath, "{not json");
bridge.ensureCliDiscoveryFile();
const reparsed = readDiscovery(discoveryPath);
assert.equal(reparsed.port, port);
assert.equal(reparsed.token, current.token);
assert.equal(reparsed.pid, process.pid);
});
test("self-heal leaves a current discovery file untouched", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-discovery-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
bridge.cleanup();
fs.rmSync(dir, { recursive: true, force: true });
});
bridge.init({ cliDiscoveryFilePath: discoveryPath });
const port = await bridge.getOrCreateHost();
const before = fs.readFileSync(discoveryPath, "utf8");
bridge.ensureCliDiscoveryFile();
const after = fs.readFileSync(discoveryPath, "utf8");
assert.equal(after, before);
assert.equal(JSON.parse(after).port, port);
});
test("ensureCliDiscoveryFile does nothing while the host is down", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-discovery-test-"));
const discoveryPath = path.join(dir, "discovery.json");
+123 -20
View File
@@ -49,8 +49,64 @@ function loadDiscovery() {
return parsed;
}
async function connectClient() {
const discovery = loadDiscovery();
// Restart-window race (#3443): the TCP bridge listens on an ephemeral port
// (`server.listen(0, "127.0.0.1")` in electron/bridges/mcpServerBridge.cjs)
// and rewrites the discovery file on every app launch. A CLI that reads the
// discovery file while the app is restarting can connect to a port that is
// already dead, or to a reused port whose token is stale, and fail until the
// chat is reopened.
//
// Retry a bounded number of times by re-reading the discovery file, including
// the initial read. The old host removes the file before the new one writes
// it, so a missing, unreadable, or unparseable file at startup is the same
// transient gap as a later re-read. Identity is port + token + pid (the same
// triple `isCliDiscoveryFileCurrent` uses), because the OS may hand the new
// bridge the same ephemeral port. A socket that closes or resets while
// auth/verify is in flight is retried the same way: the process that accepted
// the handshake may already be gone. An unchanged identity is not connected
// again, but the remaining delays still
// elapse so a rewrite later in the budget is observed. At most two extra
// attempts (300ms + 900ms, ~1.2s). When every attempt fails, the original
// error is surfaced unchanged.
const CONNECT_RETRY_DELAYS_MS = [300, 900];
function discoveryIdentity(record) {
return {
port: record?.port,
token: record?.token,
pid: record?.pid,
};
}
function sameDiscoveryIdentity(left, right) {
return left?.port === right?.port
&& left?.token === right?.token
&& left?.pid === right?.pid;
}
// Stale token: the bridge returns `{ ok: false }` (AUTH_FAILED) or, in the
// live host, RPC -32001 "Authentication required. Send auth/verify with valid
// token first." A socket that closes or resets before that response arrives
// is the same restart. Other RPC failures mean this bridge is current.
function isRetryableConnectError(err) {
if (
err?.code === "CONNECT_FAILED"
|| err?.code === "AUTH_FAILED"
|| err?.code === "CONNECTION_CLOSED"
|| err?.code === "CONNECTION_ERROR"
) {
return true;
}
return err?.code === "RPC_ERROR"
&& typeof err.message === "string"
&& err.message.includes("Authentication required");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function establishConnection(discovery) {
const socket = await new Promise((resolve, reject) => {
const sock = net.createConnection({ host: "127.0.0.1", port: discovery.port }, () => resolve(sock));
sock.setEncoding("utf8");
@@ -76,27 +132,74 @@ async function connectClient() {
},
});
const authResult = await client.call("auth/verify", { token: discovery.token });
if (!authResult?.ok) {
throw createError("AUTH_FAILED", "Failed to authenticate to Netcatty TCP bridge.");
}
try {
const statusResult = await client.call("netcatty/getStatus", {});
client.ingestBridgeStatus(statusResult);
} catch {
// Keep the default RPC timeout when bridge status cannot be fetched.
const authResult = await client.call("auth/verify", { token: discovery.token });
if (!authResult?.ok) {
throw createError("AUTH_FAILED", "Failed to authenticate to Netcatty TCP bridge.");
}
try {
const statusResult = await client.call("netcatty/getStatus", {});
client.ingestBridgeStatus(statusResult);
} catch {
// Keep the default RPC timeout when bridge status cannot be fetched.
}
return {
discovery,
async call(method, params) {
return await client.call(method, params);
},
close() {
client.close();
},
};
} catch (err) {
client.close();
throw err;
}
}
async function connectClient() {
const discoveryPath = getCliDiscoveryFilePath();
let lastError = null;
let attemptedIdentity = null;
// Bounded retry: at most CONNECT_RETRY_DELAYS_MS.length extra attempts
// (adds at most ~1.2s of latency in the worst case). The initial read is
// inside the loop so a restart gap with no discovery file yet can heal.
for (let attempt = 0; attempt <= CONNECT_RETRY_DELAYS_MS.length; attempt += 1) {
if (attempt > 0) {
if (!discoveryPath) break;
await sleep(CONNECT_RETRY_DELAYS_MS[attempt - 1]);
}
let discovery = null;
try {
discovery = loadDiscovery();
} catch (err) {
// Missing, unreadable, or still being written. Use the next delay.
// A connect or auth error from an earlier attempt stays the one we
// surface; this load error matters only when no bridge was reached.
if (!lastError) lastError = err;
continue;
}
if (attemptedIdentity && sameDiscoveryIdentity(discovery, attemptedIdentity)) {
// Do not reconnect to the bridge that just failed. Keep the remaining
// delays so a later rewrite inside the budget is still observed.
continue;
}
attemptedIdentity = discoveryIdentity(discovery);
try {
return await establishConnection(discovery);
} catch (err) {
lastError = err;
if (!isRetryableConnectError(err)) break;
}
}
return {
discovery,
async call(method, params) {
return await client.call(method, params);
},
close() {
client.close();
},
};
throw lastError;
}
module.exports = {
+631
View File
@@ -0,0 +1,631 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { connectClient } = require("./netcattyRpcClient.cjs");
const { TOOL_CLI_DISCOVERY_ENV_VAR } = require("./discoveryPath.cjs");
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const STALE_AUTH_MESSAGE = "Authentication required. Send auth/verify with valid token first.";
// Minimal NDJSON bridge stand-in. acceptToken rejects every other token the
// way the live host does (RPC -32001, then close). authResult "not-ok"
// returns `{ ok: false }` instead, which connectClient maps to AUTH_FAILED.
function startFakeBridge({
port = 0,
acceptToken = null,
authResult = "ok",
closeDuringAuth = null,
} = {}) {
let authAttempts = 0;
return new Promise((resolve, reject) => {
const server = net.createServer((socket) => {
let buffer = "";
socket.on("error", () => {});
socket.setEncoding("utf8");
socket.on("data", (chunk) => {
buffer += chunk;
let newlineIndex;
while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
if (!line.trim()) continue;
let message;
try {
message = JSON.parse(line);
} catch {
continue;
}
if (message?.id == null) continue;
if (message.method === "auth/verify") {
authAttempts += 1;
if (closeDuringAuth && authAttempts === 1) {
if (closeDuringAuth === "reset") {
socket.destroy(Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }));
} else {
socket.destroy();
}
return;
}
const presented = message.params?.token;
const accepted = acceptToken == null || presented === acceptToken;
if (!accepted && authResult === "not-ok") {
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { ok: false } })}\n`);
continue;
}
if (!accepted) {
socket.write(`${JSON.stringify({
jsonrpc: "2.0",
id: message.id,
error: { code: -32001, message: STALE_AUTH_MESSAGE },
})}\n`);
return;
}
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { ok: true } })}\n`);
continue;
}
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: {} })}\n`);
}
});
});
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve({ server, port: server.address().port }));
});
}
// Reserve a port and release it so connections to it are refused, simulating
// a bridge that died after the CLI read the discovery file.
function getDeadPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
server.close(() => resolve(port));
});
});
}
function writeDiscoveryFile(discoveryPath, port, token, pid = 424242) {
fs.writeFileSync(discoveryPath, `${JSON.stringify({ port, token, pid })}\n`);
}
function setupDiscoveryEnv(t, discoveryPath) {
const previous = process.env[TOOL_CLI_DISCOVERY_ENV_VAR];
process.env[TOOL_CLI_DISCOVERY_ENV_VAR] = discoveryPath;
t.after(() => {
if (previous === undefined) {
delete process.env[TOOL_CLI_DISCOVERY_ENV_VAR];
} else {
process.env[TOOL_CLI_DISCOVERY_ENV_VAR] = previous;
}
});
}
function countConnectAttempts(t) {
const original = net.createConnection;
let attempts = 0;
net.createConnection = (...args) => {
attempts += 1;
return original(...args);
};
t.after(() => {
net.createConnection = original;
});
return () => attempts;
}
function countDiscoveryReads(t, discoveryPath, onRead) {
const original = fs.readFileSync;
let reads = 0;
fs.readFileSync = function (filePath, ...args) {
if (filePath === discoveryPath) {
reads += 1;
if (onRead) {
const stub = onRead(reads);
if (stub !== undefined) return stub;
}
}
return original.call(this, filePath, ...args);
};
t.after(() => {
fs.readFileSync = original;
});
return () => reads;
}
function isOriginalConnectError(err) {
return err?.code === "CONNECT_FAILED"
&& typeof err?.message === "string"
&& err.message.startsWith("Failed to connect to Netcatty TCP bridge: ");
}
test("connectClient retries with a refreshed discovery file after a stale-port failure", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const deadPort = await getDeadPort();
const { server, port: bridgePort } = await startFakeBridge();
t.after(() => {
server.close();
});
const token = "test-token";
writeDiscoveryFile(discoveryPath, deadPort, token);
// Simulate the app finishing its restart: discovery is rewritten with the
// new ephemeral port while the CLI is inside its first retry delay.
const refresh = (async () => {
await sleep(50);
writeDiscoveryFile(discoveryPath, bridgePort, token);
})();
const client = await connectClient();
await refresh;
assert.equal(client.discovery.port, bridgePort);
assert.equal(getAttempts(), 2);
// The connection is usable end-to-end.
await client.call("netcatty/getStatus", {});
client.close();
await sleep(20);
});
test("connectClient stops retrying and surfaces the original error when discovery identity is unchanged", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const getReads = countDiscoveryReads(t, discoveryPath);
const deadPort = await getDeadPort();
writeDiscoveryFile(discoveryPath, deadPort, "test-token");
const startedAt = Date.now();
await assert.rejects(connectClient(), isOriginalConnectError);
const elapsed = Date.now() - startedAt;
// Same port, token, and pid: do not open another TCP connection, but keep
// both delays so a later rewrite is still visible.
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 3);
assert.ok(elapsed >= 1100, `unchanged identity should keep the remaining delay, elapsed ${elapsed}ms`);
});
test("connectClient keeps polling when discovery disappears and surfaces the original connect error", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const getReads = countDiscoveryReads(t, discoveryPath);
const deadPort = await getDeadPort();
writeDiscoveryFile(discoveryPath, deadPort, "test-token");
const refresh = (async () => {
await sleep(50);
fs.rmSync(discoveryPath, { force: true });
})();
const startedAt = Date.now();
await assert.rejects(connectClient(), isOriginalConnectError);
const elapsed = Date.now() - startedAt;
await refresh;
// Missing file is transient: both bounded delays are consumed, and the
// original connect error is what the caller sees.
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 3);
assert.ok(elapsed >= 1100, `missing discovery should consume both delays, elapsed ${elapsed}ms`);
});
test("connectClient connects after a transient missing discovery file is rewritten", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const deadPort = await getDeadPort();
const { server, port: bridgePort } = await startFakeBridge();
t.after(() => {
server.close();
});
writeDiscoveryFile(discoveryPath, deadPort, "stale-token", 1);
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => {
if (reads === 2) {
const err = new Error("ENOENT: no such file or directory");
err.code = "ENOENT";
throw err;
}
if (reads >= 3) {
return `${JSON.stringify({ port: bridgePort, token: "fresh-token", pid: 2 })}\n`;
}
return undefined;
});
const client = await connectClient();
assert.equal(client.discovery.port, bridgePort);
assert.equal(client.discovery.token, "fresh-token");
assert.equal(client.discovery.pid, 2);
assert.equal(getAttempts(), 2);
assert.equal(getReads(), 3);
client.close();
await sleep(20);
});
test("connectClient treats unparseable discovery as transient and keeps the original connect error", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const deadPort = await getDeadPort();
writeDiscoveryFile(discoveryPath, deadPort, "test-token");
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => (
reads > 1 ? "{not json" : undefined
));
const startedAt = Date.now();
await assert.rejects(connectClient(), isOriginalConnectError);
const elapsed = Date.now() - startedAt;
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 3);
assert.ok(elapsed >= 1100, `invalid discovery should consume both delays, elapsed ${elapsed}ms`);
});
test("connectClient retries when a restarted bridge reuses the port with a new token and pid", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const { server, port } = await startFakeBridge({ acceptToken: "fresh-token" });
t.after(() => {
server.close();
});
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => {
const record = reads === 1
? { port, token: "stale-token", pid: 1 }
: { port, token: "fresh-token", pid: 2 };
return `${JSON.stringify(record)}\n`;
});
// The replacement bridge already owns the same ephemeral port. The first
// TCP attempt still fails because the CLI is holding the pre-restart record.
const originalConnect = net.createConnection;
let attempts = 0;
net.createConnection = (...args) => {
attempts += 1;
if (attempts === 1) {
const socket = new net.Socket();
process.nextTick(() => {
const err = new Error("connect ECONNREFUSED 127.0.0.1");
err.code = "ECONNREFUSED";
socket.emit("error", err);
});
return socket;
}
return originalConnect(...args);
};
t.after(() => {
net.createConnection = originalConnect;
});
const client = await connectClient();
assert.equal(client.discovery.port, port);
assert.equal(client.discovery.token, "fresh-token");
assert.equal(client.discovery.pid, 2);
assert.equal(attempts, 2);
assert.equal(getReads(), 2);
await client.call("netcatty/getStatus", {});
client.close();
await sleep(20);
});
test("connectClient refreshes discovery after a stale-token auth rejection on a reused port", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const { server, port } = await startFakeBridge({ acceptToken: "fresh-token" });
t.after(() => {
server.close();
});
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => {
const record = reads === 1
? { port, token: "stale-token", pid: 11 }
: { port, token: "fresh-token", pid: 22 };
return `${JSON.stringify(record)}\n`;
});
const client = await connectClient();
assert.equal(client.discovery.port, port);
assert.equal(client.discovery.token, "fresh-token");
assert.equal(client.discovery.pid, 22);
assert.equal(getAttempts(), 2);
assert.equal(getReads(), 2);
await client.call("netcatty/getStatus", {});
client.close();
await sleep(20);
});
test("connectClient surfaces the original auth error when a stale token's discovery identity is unchanged", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const getReads = countDiscoveryReads(t, discoveryPath);
const { server, port } = await startFakeBridge({ acceptToken: "fresh-token", authResult: "not-ok" });
t.after(() => {
server.close();
});
writeDiscoveryFile(discoveryPath, port, "stale-token", 11);
await assert.rejects(
connectClient(),
(err) => err?.code === "AUTH_FAILED"
&& err.message === "Failed to authenticate to Netcatty TCP bridge.",
);
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 3);
});
test("connectClient surfaces the bridge auth error when the stale token's discovery identity is unchanged", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const getReads = countDiscoveryReads(t, discoveryPath);
const { server, port } = await startFakeBridge({ acceptToken: "fresh-token" });
t.after(() => {
server.close();
});
writeDiscoveryFile(discoveryPath, port, "stale-token", 11);
await assert.rejects(
connectClient(),
(err) => err?.code === "RPC_ERROR" && err.message === STALE_AUTH_MESSAGE,
);
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 3);
});
test("connectClient polls an initially missing discovery file and then connects", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const { server, port } = await startFakeBridge();
t.after(() => {
server.close();
});
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => {
if (reads === 1) {
const err = new Error("ENOENT: no such file or directory");
err.code = "ENOENT";
throw err;
}
return `${JSON.stringify({ port, token: "fresh-token", pid: 7 })}\n`;
});
const client = await connectClient();
assert.equal(client.discovery.port, port);
assert.equal(client.discovery.token, "fresh-token");
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 2);
client.close();
await sleep(20);
});
test("connectClient retries when the initial discovery file is unreadable", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const { server, port } = await startFakeBridge();
t.after(() => {
server.close();
});
countDiscoveryReads(t, discoveryPath, (reads) => {
if (reads === 1) {
const err = new Error("EACCES: permission denied");
err.code = "EACCES";
throw err;
}
return `${JSON.stringify({ port, token: "fresh-token", pid: 8 })}\n`;
});
const client = await connectClient();
assert.equal(client.discovery.port, port);
assert.equal(getAttempts(), 1);
client.close();
await sleep(20);
});
test("connectClient treats an initially partial discovery file as transient", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const { server, port } = await startFakeBridge();
t.after(() => {
server.close();
});
countDiscoveryReads(t, discoveryPath, (reads) => (
reads === 1
? "{not json"
: `${JSON.stringify({ port, token: "fresh-token", pid: 9 })}\n`
));
const client = await connectClient();
assert.equal(client.discovery.port, port);
assert.equal(getAttempts(), 1);
client.close();
await sleep(20);
});
test("connectClient surfaces the original APP_NOT_RUNNING error when discovery never appears", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const getReads = countDiscoveryReads(t, discoveryPath);
const startedAt = Date.now();
await assert.rejects(
connectClient(),
(err) => err?.code === "APP_NOT_RUNNING"
&& err.message.startsWith(`Netcatty discovery file is missing at ${discoveryPath}.`),
);
const elapsed = Date.now() - startedAt;
assert.equal(getAttempts(), 0);
assert.equal(getReads(), 3);
assert.ok(elapsed >= 1100, `missing startup discovery should consume both delays, elapsed ${elapsed}ms`);
});
test("connectClient retries when the socket closes during auth and discovery changes", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const { server, port } = await startFakeBridge({ closeDuringAuth: "close" });
t.after(() => {
server.close();
});
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => {
const record = reads === 1
? { port, token: "token", pid: 1 }
: { port, token: "token", pid: 2 };
return `${JSON.stringify(record)}\n`;
});
const client = await connectClient();
assert.equal(client.discovery.pid, 2);
assert.equal(getAttempts(), 2);
assert.equal(getReads(), 2);
await client.call("netcatty/getStatus", {});
client.close();
await sleep(20);
});
test("connectClient surfaces the original transport error when auth is reset and discovery is unchanged", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const getReads = countDiscoveryReads(t, discoveryPath);
const { server, port } = await startFakeBridge({ closeDuringAuth: "reset" });
t.after(() => {
server.close();
});
writeDiscoveryFile(discoveryPath, port, "token", 4);
await assert.rejects(
connectClient(),
(err) => (err?.code === "CONNECTION_CLOSED" || err?.code === "CONNECTION_ERROR")
&& typeof err?.message === "string"
&& (
err.message === "Connection to Netcatty TCP bridge closed."
|| err.message.startsWith("Connection to Netcatty TCP bridge failed: ")
),
);
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 3);
});
test("connectClient observes a discovery rewrite after an unchanged reread", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const deadPort = await getDeadPort();
const { server, port: bridgePort } = await startFakeBridge();
t.after(() => {
server.close();
});
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => {
if (reads < 3) {
return `${JSON.stringify({ port: deadPort, token: "stale-token", pid: 1 })}\n`;
}
return `${JSON.stringify({ port: bridgePort, token: "fresh-token", pid: 2 })}\n`;
});
const startedAt = Date.now();
const client = await connectClient();
const elapsed = Date.now() - startedAt;
assert.equal(client.discovery.port, bridgePort);
assert.equal(client.discovery.pid, 2);
assert.equal(getAttempts(), 2);
assert.equal(getReads(), 3);
assert.ok(elapsed >= 1100, `later rewrite should wait out both delays, elapsed ${elapsed}ms`);
client.close();
await sleep(20);
});