From 9e45193e2c49628fc507dff27db84b12f123f188 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 13:34:37 +0000 Subject: [PATCH] Fix corner cases in protocol detection and connection cleanup - Fix double-resolve race in probeTS3Query (data event vs connect timer) by guarding with a resolved flag - Fix double-resolve in probeTS6HttpQuery similarly - Support custom query ports in detectServerProtocol via DetectOptions - Clean up httpQuery and detectedProtocol on disconnect() - Add res.on("error") handler in HTTP Query client to avoid unhandled errors - Improve logging: warn with actionable message when protocol is unknown https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh --- src/ts-protocol/client.ts | 25 ++++++++--- src/ts-protocol/http-query.ts | 1 + src/ts-protocol/protocol-detect.ts | 68 ++++++++++++++++++------------ 3 files changed, 61 insertions(+), 33 deletions(-) diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index 3b771d8..5bded0d 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -87,12 +87,25 @@ export class TS3Client extends EventEmitter { ); } else { this.logger.info({ addr }, "Detecting server protocol (TS3/TS6)..."); - const detection = await detectServerProtocol(this.options.host, this.options.port); - this.detectedProtocol = detection.protocol; - this.logger.info( - { addr, protocol: this.detectedProtocol, queryPort: detection.queryPort }, - `Server protocol detected: ${this.detectedProtocol.toUpperCase()}`, + const detection = await detectServerProtocol( + this.options.host, + this.options.port, + 3000, + { ts3QueryPort: 10011, ts6HttpPort: 10080 }, ); + this.detectedProtocol = detection.protocol; + if (this.detectedProtocol === "unknown") { + this.logger.warn( + { addr }, + "Could not detect server protocol (query ports 10011/10080 unreachable). " + + "Will attempt voice connection anyway. Use serverProtocol option to force TS3 or TS6.", + ); + } else { + this.logger.info( + { addr, protocol: this.detectedProtocol, queryPort: detection.queryPort }, + `Server protocol detected: ${this.detectedProtocol.toUpperCase()}`, + ); + } } // Set up TS6 HTTP Query if applicable @@ -270,6 +283,8 @@ export class TS3Client extends EventEmitter { }); } this.clientId = 0; + this.httpQuery = null; + this.detectedProtocol = "unknown"; this.logger.info("Disconnected from TeamSpeak server"); } } diff --git a/src/ts-protocol/http-query.ts b/src/ts-protocol/http-query.ts index 06c2946..d483265 100644 --- a/src/ts-protocol/http-query.ts +++ b/src/ts-protocol/http-query.ts @@ -82,6 +82,7 @@ export class TS6HttpQuery extends EventEmitter { let data = ""; res.setEncoding("utf-8"); res.on("data", (chunk: string) => (data += chunk)); + res.on("error", reject); res.on("end", () => { let parsed: unknown; try { diff --git a/src/ts-protocol/protocol-detect.ts b/src/ts-protocol/protocol-detect.ts index 53269c8..7ef9475 100644 --- a/src/ts-protocol/protocol-detect.ts +++ b/src/ts-protocol/protocol-detect.ts @@ -11,6 +11,13 @@ export interface ProtocolDetectResult { voicePort: number; } +export interface DetectOptions { + /** TS3 ServerQuery probe port (default: 10011) */ + ts3QueryPort?: number; + /** TS6 HTTP Query probe port (default: 10080) */ + ts6HttpPort?: number; +} + /** * Probe a TeamSpeak server to determine if it's running TS3 or TS6. * @@ -23,17 +30,22 @@ export async function detectServerProtocol( host: string, voicePort = 9987, timeoutMs = 3000, + options?: DetectOptions, ): Promise { + const ts3Port = options?.ts3QueryPort ?? 10011; + const ts6Port = options?.ts6HttpPort ?? 10080; + const [ts3, ts6] = await Promise.allSettled([ - probeTS3Query(host, 10011, timeoutMs), - probeTS6HttpQuery(host, 10080, timeoutMs), + probeTS3Query(host, ts3Port, timeoutMs), + probeTS6HttpQuery(host, ts6Port, timeoutMs), ]); + // Prefer TS3 if both somehow respond (shouldn't happen in practice) if (ts3.status === "fulfilled" && ts3.value) { - return { protocol: "ts3", queryPort: 10011, voicePort }; + return { protocol: "ts3", queryPort: ts3Port, voicePort }; } if (ts6.status === "fulfilled" && ts6.value) { - return { protocol: "ts6", queryPort: 10080, voicePort }; + return { protocol: "ts6", queryPort: ts6Port, voicePort }; } return { protocol: "unknown", queryPort: null, voicePort }; @@ -44,49 +56,50 @@ export async function detectServerProtocol( */ function probeTS3Query(host: string, port: number, timeoutMs: number): Promise { return new Promise((resolve) => { - const socket = net.createConnection({ host, port, timeout: timeoutMs }); - let banner = ""; - - const cleanup = () => { + let resolved = false; + const done = (value: boolean) => { + if (resolved) return; + resolved = true; socket.removeAllListeners(); socket.destroy(); + resolve(value); }; + const socket = net.createConnection({ host, port, timeout: timeoutMs }); + let banner = ""; + socket.setTimeout(timeoutMs); socket.on("data", (data: Buffer) => { banner += data.toString("utf-8"); if (banner.includes("TS3")) { - cleanup(); - resolve(true); + done(true); } }); socket.on("connect", () => { - // Wait briefly for banner - setTimeout(() => { - cleanup(); - resolve(banner.includes("TS3")); - }, 500); + // Wait briefly for banner after TCP connect + setTimeout(() => done(banner.includes("TS3")), 500); }); - socket.on("error", () => { - cleanup(); - resolve(false); - }); - - socket.on("timeout", () => { - cleanup(); - resolve(false); - }); + socket.on("error", () => done(false)); + socket.on("timeout", () => done(false)); }); } /** * Probe TS6 HTTP Query by sending GET / and checking for a valid response. + * Any HTTP status (including 401/403) confirms the TS6 HTTP Query exists. */ function probeTS6HttpQuery(host: string, port: number, timeoutMs: number): Promise { return new Promise((resolve) => { + let resolved = false; + const done = (value: boolean) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + const req = http.request( { hostname: host, @@ -97,16 +110,15 @@ function probeTS6HttpQuery(host: string, port: number, timeoutMs: number): Promi headers: { Accept: "application/json" }, }, (res) => { - // TS6 HTTP Query returns some response (even 401/403 is valid — it means the service exists) res.resume(); - resolve(res.statusCode !== undefined); + done(res.statusCode !== undefined); }, ); - req.on("error", () => resolve(false)); + req.on("error", () => done(false)); req.on("timeout", () => { req.destroy(); - resolve(false); + done(false); }); req.end();