diff --git a/src/bot/instance.ts b/src/bot/instance.ts index b436a4e..920c3e4 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -105,6 +105,9 @@ export class BotInstance extends EventEmitter { }); this.tsClient.on("disconnected", () => { + // Avoid duplicate event if disconnect() was called explicitly + // (it already set connected = false and emitted "disconnected"). + if (!this.connected) return; this.connected = false; this.player.stop(); this.emit("disconnected"); @@ -119,8 +122,10 @@ export class BotInstance extends EventEmitter { disconnect(): void { this.player.stop(); - this.tsClient.disconnect(); + // Set connected = false BEFORE tsClient.disconnect() so that the + // "disconnected" event handler (setupTsEvents) won't emit a duplicate. this.connected = false; + this.tsClient.disconnect(); this.emit("disconnected"); } @@ -488,7 +493,7 @@ export class BotInstance extends EventEmitter { } private async playNext(): Promise { - if (this.isAdvancing) return; + if (this.isAdvancing || !this.connected) return; this.isAdvancing = true; try { this.voteSkipUsers.clear(); @@ -497,7 +502,7 @@ export class BotInstance extends EventEmitter { let started = await this.resolveAndPlay(next); if (!started) { // Skip to next if URL resolve fails (up to 3 retries) - for (let i = 0; i < 3; i++) { + for (let i = 0; i < 3 && this.connected; i++) { const retry = this.queue.next(); if (!retry) break; if (await this.resolveAndPlay(retry)) { diff --git a/src/music/api-server.ts b/src/music/api-server.ts index f517ea5..05fe3fc 100644 --- a/src/music/api-server.ts +++ b/src/music/api-server.ts @@ -17,7 +17,9 @@ export interface ApiServerManager { function isPortFree(port: number): Promise { return new Promise((resolve) => { const server = net.createServer(); - server.once("error", () => resolve(false)); + server.once("error", () => { + server.close(() => resolve(false)); + }); server.once("listening", () => { server.close(() => resolve(true)); }); diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index 2436480..0ac1303 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -119,10 +119,15 @@ export class TS3Client extends EventEmitter { }); } - // Guard against calling connect() while already connected + // Guard against calling connect() while already connected. + // Save detectedProtocol first because disconnect() resets it. if (this.client) { this.logger.warn("connect() called while already connected, disconnecting first"); + const savedProtocol = this.detectedProtocol; + const savedHttpQuery = this.httpQuery; this.disconnect(); + this.detectedProtocol = savedProtocol; + this.httpQuery = savedHttpQuery; // Give the old client a moment to tear down await new Promise((r) => setTimeout(r, 100)); } @@ -139,7 +144,9 @@ export class TS3Client extends EventEmitter { udpErrorCount++; if (udpErrorCount === 1) { this.logger.warn(msg); - // After 2 seconds, log a summary and reset + // After 2 seconds, log a summary and reset. + // Clear any previous timer to avoid leaking it. + if (this.udpErrorTimer) clearTimeout(this.udpErrorTimer); this.udpErrorTimer = setTimeout(() => { if (udpErrorCount > 1) { this.logger.warn(`udp send error (repeated ${udpErrorCount} times, connection may be lost)`); @@ -162,28 +169,6 @@ export class TS3Client extends EventEmitter { }, }); - // Patch handler.sendPacket to intercept clientinit during handshake for TS6. - // The library's commandMiddleware only applies to post-connection commands - // (sendCommandNoWait/execCommand), but the handshake's clientinit is sent - // directly via handler.sendPacket, bypassing middleware entirely. - // This monkey-patch ensures the version is upgraded for TS6 servers. - if (this.detectedProtocol === "ts6") { - this.logger.info("Applying TS6 compatibility: upgrading client_version to 3.6.2"); - const origSendPacket = this.client.handler.sendPacket.bind(this.client.handler); - this.client.handler.sendPacket = (pType: number, data: Uint8Array, flags: number) => { - // PacketType.Command = 2 - if (pType === 2) { - const str = Buffer.from(data).toString("utf-8"); - if (str.startsWith("clientinit ")) { - const patched = patchClientInitVersion(str); - origSendPacket(pType, Buffer.from(patched), flags); - return; - } - } - origSendPacket(pType, data, flags); - }; - } - this.client.on("textMessage", (msg: TextMessage) => { const tsMsg: TS3TextMessage = { invokerName: msg.invokerName, @@ -209,6 +194,37 @@ export class TS3Client extends EventEmitter { }); await this.client.connect(); + + // Patch handler.sendPacket to intercept clientinit during handshake for TS6. + // + // Why after connect(): The library's connect() internally calls #S() which + // replaces this.handler with a fresh PacketHandler. Any patch applied before + // connect() would be discarded. Patching here is safe because connect() only + // establishes the UDP socket and sends the first Init1 packet — the actual + // clientinit command is sent later in async message callbacks (after Init1 + // round-trips complete), which cannot fire before this sync continuation. + // + // Why not commandMiddleware: The library's middleware chain only applies to + // post-connection commands (sendCommandNoWait/execCommand). The handshake's + // clientinit is sent directly via handler.sendPacket(), bypassing middleware. + if (this.detectedProtocol === "ts6") { + this.logger.info("Applying TS6 compatibility: upgrading client_version to 3.6.2"); + const handler = this.client.handler; + const origSendPacket = handler.sendPacket.bind(handler); + handler.sendPacket = (pType: number, data: Uint8Array, flags: number) => { + // PacketType.Command = 2 + if (pType === 2) { + const str = Buffer.from(data).toString("utf-8"); + if (str.startsWith("clientinit ")) { + const patched = patchClientInitVersion(str); + origSendPacket(pType, Buffer.from(patched), flags); + return; + } + } + origSendPacket(pType, data, flags); + }; + } + await this.client.waitConnected(); this.clientId = this.client.clientID(); this.voiceFramesSent = 0;