Fix 7 corner cases found in second review pass

Critical:
- Persist serverProtocol/ts6ApiKey in database schema so TS6 config
  survives restarts (added columns + manager save/load)

Medium:
- Clear udpErrorTimer on disconnect to prevent memory leak and stale
  log messages from firing after teardown
- Guard against double connect() by disconnecting old client first
- Add settled guard in TS6HttpQuery.request() to prevent double
  reject when both res error and req error fire
- Add res.on("error") handler to TS6HttpQuery response stream

Low:
- Cap probeTS3Query banner buffer at 256 bytes to prevent memory abuse
  from non-TS3 services sending large data on port 10011
- Remove unnecessary EventEmitter inheritance from TS6HttpQuery
- Update database test fixtures with new serverProtocol/ts6ApiKey fields

https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
This commit is contained in:
Claude committed 2026-04-03 13:38:49 +00:00
1 parent 9e45193e2c
commit 6184f38330
6 files changed
+54 -15

No files matched your search

+15 -3
View File
@@ -53,6 +53,7 @@ export class TS3Client extends EventEmitter {
private disconnecting = false;
private detectedProtocol: ServerProtocol = "unknown";
private httpQuery: TS6HttpQuery | null = null;
private udpErrorTimer: ReturnType<typeof setTimeout> | null = null;
constructor(private options: TS3ClientOptions, logger: Logger) {
super();
@@ -118,6 +119,14 @@ export class TS3Client extends EventEmitter {
});
}
// Guard against calling connect() while already connected
if (this.client) {
this.logger.warn("connect() called while already connected, disconnecting first");
this.disconnect();
// Give the old client a moment to tear down
await new Promise((r) => setTimeout(r, 100));
}
this.logger.info(
{ addr, protocol: this.detectedProtocol },
"Connecting to TeamSpeak server (full client protocol)",
@@ -125,19 +134,18 @@ export class TS3Client extends EventEmitter {
// Throttle repeated "udp send error" warnings (fires every 20ms during playback if UDP breaks)
let udpErrorCount = 0;
let udpErrorTimer: ReturnType<typeof setTimeout> | null = null;
const throttledWarn = (msg: string, ...args: unknown[]) => {
if (typeof msg === "string" && msg.includes("udp send error")) {
udpErrorCount++;
if (udpErrorCount === 1) {
this.logger.warn(msg);
// After 2 seconds, log a summary and reset
udpErrorTimer = setTimeout(() => {
this.udpErrorTimer = setTimeout(() => {
if (udpErrorCount > 1) {
this.logger.warn(`udp send error (repeated ${udpErrorCount} times, connection may be lost)`);
}
udpErrorCount = 0;
udpErrorTimer = null;
this.udpErrorTimer = null;
}, 2000);
}
return;
@@ -285,6 +293,10 @@ export class TS3Client extends EventEmitter {
this.clientId = 0;
this.httpQuery = null;
this.detectedProtocol = "unknown";
if (this.udpErrorTimer) {
clearTimeout(this.udpErrorTimer);
this.udpErrorTimer = null;
}
this.logger.info("Disconnected from TeamSpeak server");
}
}
+13 -6
View File
@@ -1,6 +1,5 @@
import http from "node:http";
import https from "node:https";
import { EventEmitter } from "node:events";
export interface HttpQueryOptions {
host: string;
@@ -31,11 +30,10 @@ export interface HttpQueryResult {
* GET /1/channellist?sid={sid} → list channels
* POST /1/clientupdate → update client properties
*/
export class TS6HttpQuery extends EventEmitter {
export class TS6HttpQuery {
private options: Required<HttpQueryOptions>;
constructor(options: HttpQueryOptions) {
super();
this.options = {
host: options.host,
port: options.port,
@@ -68,6 +66,13 @@ export class TS6HttpQuery extends EventEmitter {
}
return new Promise((resolve, reject) => {
let settled = false;
const fail = (err: Error) => {
if (settled) return;
settled = true;
reject(err);
};
const req = transport.request(
{
hostname: host,
@@ -82,8 +87,10 @@ export class TS6HttpQuery extends EventEmitter {
let data = "";
res.setEncoding("utf-8");
res.on("data", (chunk: string) => (data += chunk));
res.on("error", reject);
res.on("error", fail);
res.on("end", () => {
if (settled) return;
settled = true;
let parsed: unknown;
try {
parsed = JSON.parse(data);
@@ -98,10 +105,10 @@ export class TS6HttpQuery extends EventEmitter {
},
);
req.on("error", reject);
req.on("error", fail);
req.on("timeout", () => {
req.destroy();
reject(new Error("TS6 HTTP Query timeout"));
fail(new Error("TS6 HTTP Query timeout"));
});
if (bodyStr) {
+2
View File
@@ -67,11 +67,13 @@ function probeTS3Query(host: string, port: number, timeoutMs: number): Promise<b
const socket = net.createConnection({ host, port, timeout: timeoutMs });
let banner = "";
const MAX_BANNER = 256; // TS3 banner is ~50 bytes; cap to avoid memory abuse
socket.setTimeout(timeoutMs);
socket.on("data", (data: Buffer) => {
banner += data.toString("utf-8");
if (banner.length > MAX_BANNER) banner = banner.slice(0, MAX_BANNER);
if (banner.includes("TS3")) {
done(true);
}