mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-02 11:52:35 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fece2c8ab9 | ||
|
|
0ebba5e4de | ||
|
|
29e65e21e2 | ||
|
|
37db908ccd | ||
|
|
f83784d363 | ||
|
|
bdf1c2c664 | ||
|
|
8e5f002238 |
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");
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user