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
This commit is contained in:
Claude committed 2026-04-03 13:34:37 +00:00
1 parent e5fd35da32
commit 9e45193e2c
3 files changed
+57 -29

No files matched your search

+16 -1
View File
@@ -87,13 +87,26 @@ export class TS3Client extends EventEmitter {
); );
} else { } else {
this.logger.info({ addr }, "Detecting server protocol (TS3/TS6)..."); this.logger.info({ addr }, "Detecting server protocol (TS3/TS6)...");
const detection = await detectServerProtocol(this.options.host, this.options.port); const detection = await detectServerProtocol(
this.options.host,
this.options.port,
3000,
{ ts3QueryPort: 10011, ts6HttpPort: 10080 },
);
this.detectedProtocol = detection.protocol; 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( this.logger.info(
{ addr, protocol: this.detectedProtocol, queryPort: detection.queryPort }, { addr, protocol: this.detectedProtocol, queryPort: detection.queryPort },
`Server protocol detected: ${this.detectedProtocol.toUpperCase()}`, `Server protocol detected: ${this.detectedProtocol.toUpperCase()}`,
); );
} }
}
// Set up TS6 HTTP Query if applicable // Set up TS6 HTTP Query if applicable
if (this.detectedProtocol === "ts6") { if (this.detectedProtocol === "ts6") {
@@ -270,6 +283,8 @@ export class TS3Client extends EventEmitter {
}); });
} }
this.clientId = 0; this.clientId = 0;
this.httpQuery = null;
this.detectedProtocol = "unknown";
this.logger.info("Disconnected from TeamSpeak server"); this.logger.info("Disconnected from TeamSpeak server");
} }
} }
+1
View File
@@ -82,6 +82,7 @@ export class TS6HttpQuery extends EventEmitter {
let data = ""; let data = "";
res.setEncoding("utf-8"); res.setEncoding("utf-8");
res.on("data", (chunk: string) => (data += chunk)); res.on("data", (chunk: string) => (data += chunk));
res.on("error", reject);
res.on("end", () => { res.on("end", () => {
let parsed: unknown; let parsed: unknown;
try { try {
+40 -28
View File
@@ -11,6 +11,13 @@ export interface ProtocolDetectResult {
voicePort: number; 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. * Probe a TeamSpeak server to determine if it's running TS3 or TS6.
* *
@@ -23,17 +30,22 @@ export async function detectServerProtocol(
host: string, host: string,
voicePort = 9987, voicePort = 9987,
timeoutMs = 3000, timeoutMs = 3000,
options?: DetectOptions,
): Promise<ProtocolDetectResult> { ): Promise<ProtocolDetectResult> {
const ts3Port = options?.ts3QueryPort ?? 10011;
const ts6Port = options?.ts6HttpPort ?? 10080;
const [ts3, ts6] = await Promise.allSettled([ const [ts3, ts6] = await Promise.allSettled([
probeTS3Query(host, 10011, timeoutMs), probeTS3Query(host, ts3Port, timeoutMs),
probeTS6HttpQuery(host, 10080, timeoutMs), probeTS6HttpQuery(host, ts6Port, timeoutMs),
]); ]);
// Prefer TS3 if both somehow respond (shouldn't happen in practice)
if (ts3.status === "fulfilled" && ts3.value) { if (ts3.status === "fulfilled" && ts3.value) {
return { protocol: "ts3", queryPort: 10011, voicePort }; return { protocol: "ts3", queryPort: ts3Port, voicePort };
} }
if (ts6.status === "fulfilled" && ts6.value) { if (ts6.status === "fulfilled" && ts6.value) {
return { protocol: "ts6", queryPort: 10080, voicePort }; return { protocol: "ts6", queryPort: ts6Port, voicePort };
} }
return { protocol: "unknown", queryPort: null, voicePort }; return { protocol: "unknown", queryPort: null, voicePort };
@@ -44,49 +56,50 @@ export async function detectServerProtocol(
*/ */
function probeTS3Query(host: string, port: number, timeoutMs: number): Promise<boolean> { function probeTS3Query(host: string, port: number, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const socket = net.createConnection({ host, port, timeout: timeoutMs }); let resolved = false;
let banner = ""; const done = (value: boolean) => {
if (resolved) return;
const cleanup = () => { resolved = true;
socket.removeAllListeners(); socket.removeAllListeners();
socket.destroy(); socket.destroy();
resolve(value);
}; };
const socket = net.createConnection({ host, port, timeout: timeoutMs });
let banner = "";
socket.setTimeout(timeoutMs); socket.setTimeout(timeoutMs);
socket.on("data", (data: Buffer) => { socket.on("data", (data: Buffer) => {
banner += data.toString("utf-8"); banner += data.toString("utf-8");
if (banner.includes("TS3")) { if (banner.includes("TS3")) {
cleanup(); done(true);
resolve(true);
} }
}); });
socket.on("connect", () => { socket.on("connect", () => {
// Wait briefly for banner // Wait briefly for banner after TCP connect
setTimeout(() => { setTimeout(() => done(banner.includes("TS3")), 500);
cleanup();
resolve(banner.includes("TS3"));
}, 500);
}); });
socket.on("error", () => { socket.on("error", () => done(false));
cleanup(); socket.on("timeout", () => done(false));
resolve(false);
});
socket.on("timeout", () => {
cleanup();
resolve(false);
});
}); });
} }
/** /**
* Probe TS6 HTTP Query by sending GET / and checking for a valid response. * 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<boolean> { function probeTS6HttpQuery(host: string, port: number, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
let resolved = false;
const done = (value: boolean) => {
if (resolved) return;
resolved = true;
resolve(value);
};
const req = http.request( const req = http.request(
{ {
hostname: host, hostname: host,
@@ -97,16 +110,15 @@ function probeTS6HttpQuery(host: string, port: number, timeoutMs: number): Promi
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
}, },
(res) => { (res) => {
// TS6 HTTP Query returns some response (even 401/403 is valid — it means the service exists)
res.resume(); res.resume();
resolve(res.statusCode !== undefined); done(res.statusCode !== undefined);
}, },
); );
req.on("error", () => resolve(false)); req.on("error", () => done(false));
req.on("timeout", () => { req.on("timeout", () => {
req.destroy(); req.destroy();
resolve(false); done(false);
}); });
req.end(); req.end();