Fix corner cases in connect/disconnect lifecycle and resource cleanup

1. Move TS6 handler patch to after client.connect() — the library's
   connect() internally replaces handler via #S(), discarding any
   patch applied beforehand. Patching after connect() is safe because
   clientinit is sent in async message callbacks after Init1 round-trips.

2. Preserve detectedProtocol across reconnect — disconnect() resets it
   to "unknown", causing the TS6 patch to be skipped on reconnect.

3. Prevent double "disconnected" event in BotInstance — disconnect()
   emitted it directly AND the async TS3Client disconnect triggered
   another through the event chain.

4. Guard playNext() against running after disconnect — check connected
   flag to avoid ghost queue processing.

5. Fix UDP error timer leak — clear previous timer before setting a new
   one to prevent accumulation.

6. Fix isPortFree() FD leak — close the test server on error path.

https://claude.ai/code/session_01QzvMLUT3UkhsffShcY1qzD
This commit is contained in:
Claude committed 2026-04-04 15:26:41 +00:00
1 parent 7ad9559653
commit b00b6637d2
3 files changed
+51 -28

No files matched your search

+8 -3
View File
@@ -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<void> {
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)) {
+3 -1
View File
@@ -17,7 +17,9 @@ export interface ApiServerManager {
function isPortFree(port: number): Promise<boolean> {
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));
});
+40 -24
View File
@@ -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;