From 20fa3707ffe84ed74570aeb4ef659d77bb8557f6 Mon Sep 17 00:00:00 2001 From: nvms Date: Wed, 26 Mar 2025 16:51:03 -0400 Subject: [PATCH] fix: improve connection reliability and add comprehensive tests - Make connect() methods return Promises for better async control - Remove automatic connections in constructors to prevent race conditions - Handle ECONNRESET errors gracefully during disconnection - Add comprehensive test suite covering reconnection, timeouts, and concurrency --- packages/duplex/bun.lockb | Bin 77157 -> 99213 bytes packages/duplex/package.json | 6 +- packages/duplex/src/client/commandclient.ts | 139 +++++++--- packages/duplex/src/example/client.ts | 23 +- packages/duplex/src/example/server.ts | 5 + packages/duplex/src/server/commandserver.ts | 55 +++- packages/duplex/src/server/ids.ts | 8 +- packages/duplex/tests/advanced.test.ts | 282 ++++++++++++++++++++ packages/duplex/tests/commandclient.test.ts | 119 +++------ 9 files changed, 486 insertions(+), 151 deletions(-) create mode 100644 packages/duplex/tests/advanced.test.ts diff --git a/packages/duplex/bun.lockb b/packages/duplex/bun.lockb index e080ae2adc94290c657ed240bf69ee0de52368d1..30705fac4a9f4b729cc6a0509cb16309c5738efa 100755 GIT binary patch delta 26781 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 delta 13825 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 nFX6kpO|dxZs6a5Mf7@05+d diff --git a/packages/duplex/package.json b/packages/duplex/package.json index 755fc52..3060d25 100644 --- a/packages/duplex/package.json +++ b/packages/duplex/package.json @@ -15,13 +15,15 @@ "license": "Apache-2.0", "scripts": { "build": "tsup", - "release": "bumpp package.json && npm publish --access public" + "release": "bumpp package.json && npm publish --access public", + "test": "vitest" }, "type": "module", "devDependencies": { "@types/node": "^22.4.1", "bumpp": "^9.5.1", "tsup": "^8.2.4", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vitest": "^2.1.4" } } diff --git a/packages/duplex/src/client/commandclient.ts b/packages/duplex/src/client/commandclient.ts index be385de..27f244e 100644 --- a/packages/duplex/src/client/commandclient.ts +++ b/packages/duplex/src/client/commandclient.ts @@ -9,9 +9,10 @@ import { Status } from "../common/status"; import { IdManager } from "../server/ids"; import { Queue } from "./queue"; -export type TokenClientOptions = tls.ConnectionOptions & net.NetConnectOpts & { - secure: boolean; -}; +export type TokenClientOptions = tls.ConnectionOptions & + net.NetConnectOpts & { + secure: boolean; + }; class TokenClient extends EventEmitter { public options: TokenClientOptions; @@ -23,27 +24,38 @@ class TokenClient extends EventEmitter { constructor(options: TokenClientOptions) { super(); this.options = options; - this.connect(); + this.status = Status.OFFLINE; // Initialize status but don't connect yet } - connect(callback?: () => void) { + connect(callback?: () => void): Promise { if (this.status >= Status.CLOSED) { - return false; + return Promise.resolve(); } - this.hadError = false; - this.status = Status.CONNECTING; + return new Promise((resolve, reject) => { + this.hadError = false; + this.status = Status.CONNECTING; - if (this.options.secure) { - this.socket = tls.connect(this.options, callback); - } else { - this.socket = net.connect(this.options, callback); - } + const onConnect = () => { + if (callback) callback(); + resolve(); + }; - this.connection = null; - this.applyListeners(); + if (this.options.secure) { + this.socket = tls.connect(this.options, onConnect); + } else { + this.socket = net.connect(this.options, onConnect); + } - return true; + this.socket.once("error", (err) => { + if (this.status === Status.CONNECTING) { + reject(err); + } + }); + + this.connection = null; + this.applyListeners(); + }); } close(callback?: () => void) { @@ -69,7 +81,11 @@ class TokenClient extends EventEmitter { private applyListeners() { this.socket.on("error", (error) => { this.hadError = true; - this.emit("error", error); + + // Don't emit ECONNRESET errors during normal disconnection scenarios + if (error.code !== "ECONNRESET" || this.status !== Status.CLOSED) { + this.emit("error", error); + } }); this.socket.on("close", () => { @@ -123,11 +139,17 @@ class QueueClient extends TokenClient { private applyEvents() { this.on("connect", () => { - while (!this.queue.isEmpty) { - const item = this.queue.pop(); + this.processQueue(); + }); + } + + private processQueue() { + while (!this.queue.isEmpty) { + const item = this.queue.pop(); + if (item) { this.sendBuffer(item.value, item.expiresIn); } - }); + } } close() { @@ -136,9 +158,9 @@ class QueueClient extends TokenClient { } export class CommandClient extends QueueClient { - private ids = new IdManager(0xFFFF); + private ids = new IdManager(0xffff); private callbacks: { - [id: number]: (error: Error | null, result?: any) => void + [id: number]: (result: any, error?: Error) => void; } = {}; constructor(options: TokenClientOptions) { @@ -154,9 +176,9 @@ export class CommandClient extends QueueClient { if (this.callbacks[data.id]) { if (data.command === 255) { const error = ErrorSerializer.deserialize(data.payload); - this.callbacks[data.id](error, undefined); + this.callbacks[data.id](undefined, error); } else { - this.callbacks[data.id](null, data.payload); + this.callbacks[data.id](data.payload, null); } } } catch (error) { @@ -165,13 +187,39 @@ export class CommandClient extends QueueClient { }); } - async command(command: number, payload: any, expiresIn: number = 30_000, callback: (result: any, error: CodeError | Error | null) => void | undefined = undefined) { + async command( + command: number, + payload: any, + expiresIn: number = 30_000, + callback: ( + result: any, + error: CodeError | Error | null, + ) => void | undefined = undefined, + ) { if (command === 255) { - throw new CodeError("Command 255 is reserved.", "ERESERVED", "CommandError"); + throw new CodeError( + "Command 255 is reserved.", + "ERESERVED", + "CommandError", + ); + } + + // Ensure we're connected before sending commands + if (this.status < Status.ONLINE) { + try { + await this.connect(); + } catch (err) { + if (typeof callback === "function") { + callback(undefined, err as Error); + return; + } else { + throw err; + } + } } const id = this.ids.reserve(); - const buffer = Command.toBuffer({ id, command, payload }) + const buffer = Command.toBuffer({ id, command, payload }); this.sendBuffer(buffer, expiresIn); @@ -189,10 +237,18 @@ export class CommandClient extends QueueClient { const ret = await Promise.race([response, timeout]); try { - callback(ret, undefined); - } catch (callbackError) { /* */ } - } catch (error) { - callback(undefined, error); + if (ret.error) { + callback(undefined, ret.error); + } else { + callback(ret.result, undefined); + } + // callback(ret, undefined); + } catch (callbackError) { + /* */ + } + } catch (error: unknown) { + const err = error as { result: any; error: any }; + callback(undefined, err.error); } } else { return Promise.race([response, timeout]); @@ -200,27 +256,34 @@ export class CommandClient extends QueueClient { } private createTimeoutPromise(id: number, expiresIn: number) { - return new Promise((resolve, reject) => { + return new Promise<{ error: any; result: any }>((_, reject) => { setTimeout(() => { this.ids.release(id); delete this.callbacks[id]; - reject(new CodeError("Command timed out.", "ETIMEOUT", "CommandError")); + reject({ + error: new CodeError( + "Command timed out.", + "ETIMEOUT", + "CommandError", + ), + result: null, + }); }, expiresIn); }); } private createResponsePromise(id: number) { - return new Promise((resolve, reject) => { - this.callbacks[id] = (error: Error | null, result?: any) => { + return new Promise<{ error: any; result: any }>((resolve, reject) => { + this.callbacks[id] = (result: any, error?: Error) => { this.ids.release(id); delete this.callbacks[id]; if (error) { - reject(error); + reject({ error, result: null }); } else { - resolve(result); + resolve({ result, error: null }); } - } + }; }); } diff --git a/packages/duplex/src/example/client.ts b/packages/duplex/src/example/client.ts index 158dc44..71deba1 100644 --- a/packages/duplex/src/example/client.ts +++ b/packages/duplex/src/example/client.ts @@ -10,18 +10,23 @@ const client = new CommandClient({ const payload = { things: "stuff", numbers: [1, 2, 3] }; async function main() { - const callback = (result: any, error: CodeError) => { - if (error) { - console.log("ERR [0]", error.code); - return; - } + try { + await client.connect(); - console.log("RECV [0]", result); - client.close(); - }; + const callback = (result: any, error: CodeError) => { + if (error) { + console.log("ERR [0]", error.code); + return; + } - client.command(0, payload, 10, callback); + console.log("RECV [0]", result); + client.close(); + }; + client.command(0, payload, 10, callback); + } catch (err) { + console.error("Connection error:", err); + } } main(); diff --git a/packages/duplex/src/example/server.ts b/packages/duplex/src/example/server.ts index d4b5a83..57f35fb 100644 --- a/packages/duplex/src/example/server.ts +++ b/packages/duplex/src/example/server.ts @@ -8,6 +8,11 @@ const server = new CommandServer({ secure: false, }); +server.connect().catch((err) => { + console.error("Failed to start server:", err); + process.exit(1); +}); + server.command(0, async (payload: any, connection: Connection) => { console.log("RECV [0]:", payload); return { ok: "OK" }; diff --git a/packages/duplex/src/server/commandserver.ts b/packages/duplex/src/server/commandserver.ts index 2c89fad..c3cd66e 100644 --- a/packages/duplex/src/server/commandserver.ts +++ b/packages/duplex/src/server/commandserver.ts @@ -7,9 +7,11 @@ import { Connection } from "../common/connection"; import { ErrorSerializer } from "../common/errorserializer"; import { Status } from "../common/status"; -export type TokenServerOptions = tls.TlsOptions & net.ListenOptions & net.SocketConstructorOpts & { - secure?: boolean; -}; +export type TokenServerOptions = tls.TlsOptions & + net.ListenOptions & + net.SocketConstructorOpts & { + secure?: boolean; + }; export class TokenServer extends EventEmitter { connections: Connection[] = []; @@ -24,13 +26,14 @@ export class TokenServer extends EventEmitter { super(); this.options = options; + this.status = Status.OFFLINE; if (this.options.secure) { this.server = tls.createServer(this.options, function (clientSocket) { clientSocket.on("error", (err) => { this.emit("clientError", err); }); - }) + }); } else { this.server = net.createServer(this.options, function (clientSocket) { clientSocket.on("error", (err) => { @@ -40,18 +43,24 @@ export class TokenServer extends EventEmitter { } this.applyListeners(); - this.connect(); + // Don't automatically connect in constructor } - connect(callback?: () => void) { - if (this.status >= Status.CONNECTING) return false; + connect(callback?: () => void): Promise { + if (this.status >= Status.CONNECTING) return Promise.resolve(); this.hadError = false; this.status = Status.CONNECTING; - this.server.listen(this.options, () => { - if (callback) callback(); + + return new Promise((resolve) => { + this.server.listen(this.options, () => { + // Wait a small tick to ensure the server socket is fully bound + setImmediate(() => { + if (callback) callback(); + resolve(); + }); + }); }); - return true; } close(callback?: () => void) { @@ -129,7 +138,7 @@ type CommandFn = (payload: any, connection: Connection) => Promise; export class CommandServer extends TokenServer { private commands: { - [command: number]: CommandFn + [command: number]: CommandFn; } = {}; constructor(options: TokenServerOptions) { @@ -157,10 +166,26 @@ export class CommandServer extends TokenServer { this.commands[command] = fn; } - private async runCommand(id: number, command: number, payload: any, connection: Connection) { + private async runCommand( + id: number, + command: number, + payload: any, + connection: Connection, + ) { try { if (!this.commands[command]) { - throw new CodeError(`Command (${command}) not found.`, "ENOTFOUND", "CommandError"); + connection.send( + Command.toBuffer({ + command: 255, + id, + payload: new CodeError( + `Command (${command}) not found.`, + "ENOTFOUND", + "CommandError", + ), + }), + ); + return; } const result = await this.commands[command](payload, connection); @@ -169,7 +194,9 @@ export class CommandServer extends TokenServer { // we respond with a simple "OK". const payloadResult = result === undefined ? "OK" : result; - connection.send(Command.toBuffer({ command, id, payload: payloadResult })); + connection.send( + Command.toBuffer({ command, id, payload: payloadResult }), + ); } catch (error) { const payload = ErrorSerializer.serialize(error); diff --git a/packages/duplex/src/server/ids.ts b/packages/duplex/src/server/ids.ts index ca1cbae..1d0d252 100644 --- a/packages/duplex/src/server/ids.ts +++ b/packages/duplex/src/server/ids.ts @@ -9,7 +9,9 @@ export class IdManager { release(id: number) { if (id < 0 || id > this.maxIndex) { - throw new TypeError(`ID must be between 0 and ${this.maxIndex}. Got ${id}.`); + throw new TypeError( + `ID must be between 0 and ${this.maxIndex}. Got ${id}.`, + ); } this.ids[id] = false; } @@ -33,7 +35,9 @@ export class IdManager { } if (this.index === startIndex) { - throw new Error(`All IDs are reserved. Make sure to release IDs when they are no longer used.`); + throw new Error( + `All IDs are reserved. Make sure to release IDs when they are no longer used.`, + ); } } } diff --git a/packages/duplex/tests/advanced.test.ts b/packages/duplex/tests/advanced.test.ts new file mode 100644 index 0000000..12b0b3e --- /dev/null +++ b/packages/duplex/tests/advanced.test.ts @@ -0,0 +1,282 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { CommandClient, CommandServer, Status } from "../src/index"; + +describe("Advanced CommandClient and CommandServer Tests", () => { + const serverOptions = { host: "localhost", port: 8125, secure: false }; + const clientOptions = { host: "localhost", port: 8125, secure: false }; + let server: CommandServer; + let client: CommandClient; + + beforeEach(() => { + server = new CommandServer(serverOptions); + server.command(100, async (payload) => { + return `Echo: ${payload}`; + }); + + client = new CommandClient(clientOptions); + }); + + afterEach(async () => { + if (client.status === Status.ONLINE) { + await new Promise((resolve) => { + client.once("close", () => resolve()); + client.close(); + }); + } + + if (server.status === Status.ONLINE) { + await new Promise((resolve) => { + server.once("close", () => resolve()); + server.close(); + }); + } + }); + + test("client reconnects after server restart", async () => { + await server.connect(); + await client.connect(); + + // Verify initial connection + expect(client.status).toBe(Status.ONLINE); + + // First close the client gracefully + await new Promise((resolve) => { + client.once("close", () => resolve()); + client.close(); + }); + + // Then close the server + await new Promise((resolve) => { + server.once("close", () => resolve()); + server.close(); + }); + + // Restart server + await server.connect(); + + // Reconnect client + await client.connect(); + + // Verify reconnection worked + expect(client.status).toBe(Status.ONLINE); + + // Verify functionality after reconnection + return new Promise((resolve, reject) => { + client.command(100, "After Reconnect", 5000, (result, error) => { + try { + expect(error).toBeUndefined(); + expect(result).toBe("Echo: After Reconnect"); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }, 5000); + + test("command times out when server doesn't respond", async () => { + await server.connect(); + await client.connect(); + + // A command that never responds + server.command(101, async () => { + return new Promise(() => {}); + }); + + // Expect it to fail after a short timeout + await expect( + new Promise((resolve, reject) => { + client.command(101, "Should timeout", 500, (result, error) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }), + ).rejects.toHaveProperty("code", "ETIMEOUT"); + }, 2000); + + test("server errors are properly serialized to client", async () => { + await server.connect(); + await client.connect(); + + server.command(102, async () => { + const error = new Error("Custom server error") as any; + error.code = "ECUSTOM"; + error.name = "CustomError"; + throw error; + }); + + // Expect to receive this error + await expect( + new Promise((resolve, reject) => { + client.command(102, "Will error", 1000, (result, error) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }), + ).rejects.toMatchObject({ + message: "Custom server error", + name: "CustomError", + code: "ECUSTOM", + }); + }, 2000); + + test("commands are queued when client is offline and sent when reconnected", async () => { + // Start with server but no client connection + await server.connect(); + + // Create client but don't connect yet + const queuedClient = new CommandClient(clientOptions); + + // Queue a command while offline + const commandPromise = new Promise((resolve, reject) => { + queuedClient.command(100, "Queued Message", 5000, (result, error) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); + + // Now connect the client - the queued command should be sent + await queuedClient.connect(); + + // Verify the queued command was processed + await expect(commandPromise).resolves.toBe("Echo: Queued Message"); + + // Clean up + await new Promise((resolve) => { + queuedClient.once("close", () => resolve()); + queuedClient.close(); + }); + }, 3000); + + test("multiple concurrent commands are handled correctly", async () => { + await server.connect(); + await client.connect(); + + // Register commands with different delays + server.command(103, async (payload) => { + await new Promise((r) => setTimeout(r, 50)); + return `Fast: ${payload}`; + }); + + server.command(104, async (payload) => { + await new Promise((r) => setTimeout(r, 150)); + return `Slow: ${payload}`; + }); + + // Send multiple commands concurrently + const results = await Promise.all([ + new Promise((resolve, reject) => { + client.command(103, "First", 1000, (result, error) => { + if (error) reject(error); + else resolve(result); + }); + }), + new Promise((resolve, reject) => { + client.command(104, "Second", 1000, (result, error) => { + if (error) reject(error); + else resolve(result); + }); + }), + new Promise((resolve, reject) => { + client.command(100, "Third", 1000, (result, error) => { + if (error) reject(error); + else resolve(result); + }); + }), + ]); + + // Verify all commands completed successfully + expect(results).toEqual(["Fast: First", "Slow: Second", "Echo: Third"]); + }, 3000); + + test("handles large payloads correctly", async () => { + await server.connect(); + await client.connect(); + + const largeData = { + array: Array(1000) + .fill(0) + .map((_, i) => `item-${i}`), + nested: { + deep: { + object: { + with: "lots of data", + }, + }, + }, + }; + + const result = await new Promise((resolve, reject) => { + client.command(100, largeData, 5000, (result, error) => { + if (error) reject(error); + else resolve(result); + }); + }); + + // Verify the response contains the expected prefix + expect(typeof result).toBe("string"); + expect((result as string).startsWith("Echo: ")).toBe(true); + }, 10000); + + test("server handles multiple client connections", async () => { + await server.connect(); + + // Create multiple clients + const clients = Array(5) + .fill(0) + .map(() => new CommandClient(clientOptions)); + + // Connect all clients + await Promise.all(clients.map((client) => client.connect())); + + // Send a command from each client + const results = await Promise.all( + clients.map( + (client, i) => + new Promise((resolve, reject) => { + client.command(100, `Client ${i}`, 1000, (result, error) => { + if (error) reject(error); + else resolve(result); + }); + }), + ), + ); + + // Verify all commands succeeded + results.forEach((result, i) => { + expect(result).toBe(`Echo: Client ${i}`); + }); + + // Clean up + await Promise.all( + clients.map( + (client) => + new Promise((resolve) => { + client.once("close", () => resolve()); + client.close(); + }), + ), + ); + }, 5000); + + test("command returns promise when no callback provided", async () => { + await server.connect(); + await client.connect(); + + // Use the promise-based API + const result = await client.command(100, "Promise API"); + + // Verify the result + expect(result).toHaveProperty("result", "Echo: Promise API"); + expect(result).toHaveProperty("error", null); + }, 2000); +}); diff --git a/packages/duplex/tests/commandclient.test.ts b/packages/duplex/tests/commandclient.test.ts index a810700..3664718 100644 --- a/packages/duplex/tests/commandclient.test.ts +++ b/packages/duplex/tests/commandclient.test.ts @@ -1,8 +1,6 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { CommandClient, CommandServer } from "../src/index"; -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - describe("CommandClient and CommandServer", () => { const serverOptions = { host: "localhost", port: 8124, secure: false }; const clientOptions = { host: "localhost", port: 8124, secure: false }; @@ -18,100 +16,49 @@ describe("CommandClient and CommandServer", () => { client = new CommandClient(clientOptions); }); - afterEach(() => { - if (client.status === 3) { // ONLINE - client.close(); + afterEach(async () => { + if (client.status === 3) { + // ONLINE + await new Promise((resolve) => { + client.once("close", () => resolve()); + client.close(); + }); } - if (server.status === 3) { // ONLINE - server.close(); + + if (server.status === 3) { + // ONLINE + await new Promise((resolve) => { + server.once("close", () => resolve()); + server.close(); + }); } }); test("client-server connection should be online", async () => { - await new Promise((resolve) => { - server.once("listening", () => { - client.once("connect", () => { - expect(client.status).toBe(3); // ONLINE - resolve(); - }); - }); - server.connect(); - }); - }, 5000); + await server.connect(); + await client.connect(); + expect(client.status).toBe(3); // ONLINE + }, 1000); test("simple echo command", async () => { - await new Promise((resolve) => { - server.once("listening", () => { - client.once("connect", () => { - client.command(100, "Hello", 5000, (result, error) => { + try { + await server.connect(); + + await client.connect(); + + return new Promise((resolve, reject) => { + client.command(100, "Hello", 5000, (result, error) => { + try { expect(error).toBeUndefined(); expect(result).toBe("Echo: Hello"); resolve(); - }); + } catch (e) { + reject(e); + } }); }); - server.connect(); - }); - }, 5000); - - // test("handle unknown command", async () => { - // await sleep(1000); - // await new Promise((resolve) => { - // server.once("listening", () => { - // console.log("Listening! (unknown command)"); - // client.once("connect", () => { - // console.log("Client connected, sending command."); - // client.command(55, "Hello", 1000, (result, error) => { - // console.log("Client callback CALLED! with result", result, "and error", error); - // expect(result).toBeUndefined(); - // // expect(error).toBeDefined(); - // // expect(error.code).toBe("ENOTFOUND"); - // resolve(); - // }); - // }); - // }); - // server.connect(); - // }); - // }, 2000); // Increased timeout - - // test("command should timeout without server response", async () => { - // await new Promise((resolve) => { - // server.once("listening", () => { - // client.once("connect", () => { - // client.command(101, "No response", 1000, (result, error) => { - // expect(result).toBeUndefined(); - // expect(error).toBeInstanceOf(CodeError); - // expect(error.code).toBe("ETIMEOUT"); - // resolve(); - // }); - // }); - // }); - // server.connect(); - // }); - // }, 10000); // Increased timeout - - // test("client should handle server close event", async () => { - // await new Promise((resolve) => { - // let errorEmitted = false; - // client.once("error", () => { - // errorEmitted = true; - // }); - // - // client.once("close", () => { - // expect(errorEmitted).toBe(false); - // expect(client.status).toBe(0); // OFFLINE - // resolve(); - // }); - // - // server.once("listening", () => { - // client.once("connect", () => { - // server.close(() => { - // setTimeout(() => client.close(), 200); - // }); - // }); - // }); - // - // server.connect(); - // }); - // }, 10000); // Increased timeout + } catch (err) { + throw err; + } + }, 1000); });