aqualink
Version:
An Lavalink/Nodelink client, focused in pure performance and features
426 lines (383 loc) • 12.5 kB
JavaScript
const { AqualinkEvents } = require('./AqualinkEvents')
const { reportSuppressedError } = require('./Reporting')
class ConnectionRecovery {
constructor(connection, deps) {
this.connection = connection
this._functions = deps._functions
this.STATE = deps.STATE
this.RECONNECT_DELAY = deps.RECONNECT_DELAY
this.MAX_RECONNECT_ATTEMPTS = deps.MAX_RECONNECT_ATTEMPTS
this.RESUME_BACKOFF_MAX = deps.RESUME_BACKOFF_MAX
}
setServerUpdate(data) {
const conn = this.connection
if (conn._destroyed || !data?.token) return
const endpoint =
typeof data.endpoint === 'string' ? data.endpoint.trim() : ''
if (!endpoint) return
if (conn._lastEndpoint === endpoint && conn.token === data.token) return
if (data.txId && data.txId < conn.txId) return
conn._stateGeneration++
if (conn._lastEndpoint !== endpoint) {
conn.sequence = 0
conn._lastEndpoint = endpoint
conn._reconnectAttempts = 0
conn._consecutiveFailures = 0
conn._regionMigrationAttempted = false
}
conn.endpoint = endpoint
conn.region = this._functions.extractRegion(endpoint)
conn.token = data.token
conn.channelId = data.channel_id || conn.channelId || conn.voiceChannel
conn._lastVoiceDataUpdate = Date.now()
if (conn._aqua?.debugTrace) {
conn._aqua._trace('connection.serverUpdate', {
guildId: conn._guildId,
endpoint: conn.endpoint,
region: conn.region,
txId: data.txId || null
})
}
conn._stateFlags &= ~this.STATE.VOICE_DATA_STALE
const migrated = this.checkRegionMigration()
if (migrated) return
conn._scheduleVoiceUpdate()
conn._player?._flushDeferredPlay?.()
}
checkRegionMigration() {
const conn = this.connection
if (conn._destroyed || conn._regionMigrationAttempted) return false
if (
!conn._aqua?.autoRegionMigrate ||
!conn.region ||
conn.region === 'unknown'
)
return false
const player = conn._player
if (!player || player.destroyed || player._resuming || player._reconnecting)
return false
const currentNode = player.nodes
if (!currentNode) return false
const currentRegions = Array.isArray(currentNode.regions)
? currentNode.regions
: []
const alreadyMatching = currentRegions.some((r) =>
conn._aqua._regionMatches?.(r, conn.region)
)
if (alreadyMatching) {
conn._regionMigrationAttempted = true
return false
}
const targetNode = conn._aqua._findBestNodeForRegion?.(conn.region)
if (!targetNode || targetNode === currentNode) return false
conn._regionMigrationAttempted = true
if (conn._aqua?.debugTrace) {
conn._aqua._trace('connection.region.migrate', {
guildId: conn._guildId,
region: conn.region,
from: currentNode?.name || currentNode?.host,
to: targetNode?.name || targetNode?.host
})
}
queueMicrotask(() => {
conn._aqua
.movePlayerToNode?.(conn._guildId, targetNode, 'region')
.catch((error) => {
conn._regionMigrationAttempted = false
reportSuppressedError(
conn._aqua,
'connection.region.migrate',
error,
{
guildId: conn._guildId,
region: conn.region
}
)
})
})
return true
}
async attemptResume() {
const conn = this.connection
if (!conn._canAttemptResumeCore()) return false
if (conn._aqua?.debugTrace) {
conn._aqua._trace('connection.resume.attempt', {
guildId: conn._guildId,
reconnectAttempts: conn._reconnectAttempts,
hasSessionId: !!conn.sessionId,
hasEndpoint: !!conn.endpoint,
hasToken: !!conn.token
})
}
const currentGen = conn._stateGeneration
if (
!conn.sessionId ||
!conn.endpoint ||
!conn.token ||
conn._stateFlags & this.STATE.VOICE_DATA_STALE
) {
const now = Date.now()
if (now - (conn._lastResumeBlockedLogAt || 0) >= 5000) {
conn._lastResumeBlockedLogAt = now
conn._aqua.emit(
AqualinkEvents.Debug,
`Resume blocked: missing voice data for guild ${conn._guildId}, requesting voice state`
)
}
conn._requestVoiceState()
return false
}
conn.txId = conn._player.txId || conn.txId
conn._stateFlags |= this.STATE.ATTEMPTING_RESUME
conn._reconnectAttempts++
conn._aqua.emit(
AqualinkEvents.Debug,
`Attempt resume: guild=${conn._guildId} endpoint=${conn.endpoint} session=${conn.sessionId}`
)
const payload = sharedPool.acquire()
try {
this._functions.fillVoicePayload(
payload,
conn._guildId,
conn,
conn._player,
true
)
if (conn._destroyed || !conn._player || conn._player.destroyed) {
conn._aqua.emit(
AqualinkEvents.Debug,
`Resume aborted: player destroyed during attempt for guild ${conn._guildId}`
)
return false
}
if (conn._stateGeneration !== currentGen) {
conn._aqua.emit(
AqualinkEvents.Debug,
`Resume aborted: State changed during attempt for guild ${conn._guildId}`
)
return false
}
await this.sendUpdate(payload)
if (conn._aqua?.debugTrace) {
conn._aqua._trace('connection.resume.success', {
guildId: conn._guildId
})
}
if (conn._destroyed || conn._player?.destroyed) {
return false
}
conn._reconnectAttempts = 0
conn._consecutiveFailures = 0
if (conn._player) conn._player._resuming = false
conn._aqua.emit(
AqualinkEvents.Debug,
`Resume PATCH sent for guild ${conn._guildId}`
)
return true
} catch (error) {
if (conn._destroyed || !conn._aqua) throw error
if (conn._player?.destroyed) {
conn._aqua.emit(
AqualinkEvents.Debug,
`Resume aborted: player destroyed during retry for guild ${conn._guildId}`
)
return false
}
conn._consecutiveFailures++
conn._aqua.emit(
AqualinkEvents.Debug,
`Resume failed for guild ${conn._guildId} (sessionId=${conn.sessionId || 'none'}, endpoint=${conn.endpoint || 'none'}): ${error?.message || error}`
)
if (conn._aqua?.debugTrace) {
conn._aqua._trace('connection.resume.error', {
guildId: conn._guildId,
error: error?.message || String(error)
})
}
if (
conn._reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS &&
!conn._destroyed &&
conn._consecutiveFailures < 5 &&
!conn._player?.destroyed
) {
const delay = Math.min(
this.RECONNECT_DELAY * (1 << (conn._reconnectAttempts - 1)),
this.RESUME_BACKOFF_MAX
)
conn._setReconnectTimer(delay)
} else {
conn._aqua.emit(
AqualinkEvents.Debug,
`Max reconnect attempts/failures reached for guild ${conn._guildId}`
)
if (conn._player) conn._player._resuming = false
conn._handleDisconnect()
}
return false
} finally {
conn._stateFlags &= ~this.STATE.ATTEMPTING_RESUME
sharedPool.release(payload)
}
}
async recoverMissingPlayer(isSessionError) {
const conn = this.connection
if (conn._destroyed || !conn._player || conn._missingPlayerRecovering)
return false
const now = Date.now()
if (now - conn._lastMissingPlayerRecoverAt < 5000) return false
conn._missingPlayerRecovering = true
conn._lastMissingPlayerRecoverAt = now
if (conn._aqua?.debugTrace) {
conn._aqua._trace('connection.playerMissing.recover.start', {
guildId: conn._guildId,
isSessionError: !!isSessionError
})
}
try {
const recoveryToken = conn._player._claimVoiceRecovery?.(
'missing_player_recover'
)
if (isSessionError && conn._player?.nodes?._clearSession) {
conn._player.nodes._clearSession()
}
if (conn._player?._isVoiceRecoveryActive?.(recoveryToken))
conn._requestVoiceState()
const resumed = await this.attemptResume().catch((error) => {
reportSuppressedError(
conn._aqua,
'connection.playerMissing.resume',
error,
{
guildId: conn._guildId
}
)
return false
})
if (resumed) {
conn._player?._clearVoiceRecovery?.(
recoveryToken,
'missing_player_resumed'
)
} else if (conn._player?._isVoiceRecoveryActive?.(recoveryToken)) {
conn.resendVoiceUpdate(true)
}
if (conn._player.playing && conn._player.current?.track) {
const data = {
track: { encoded: conn._player.current.track },
paused: !!conn._player.paused
}
if (conn._player.position > 0) data.position = conn._player.position
await conn._rest.updatePlayer({
guildId: conn._guildId,
data,
noReplace: false
})
}
if (conn._aqua?.debugTrace) {
conn._aqua._trace('connection.playerMissing.recover.ok', {
guildId: conn._guildId,
resumed: !!resumed,
playing: !!conn._player.playing
})
}
return true
} catch (error) {
if (conn._aqua?.debugTrace) {
conn._aqua._trace('connection.playerMissing.recover.error', {
guildId: conn._guildId,
error: error?.message || String(error)
})
}
return false
} finally {
conn._missingPlayerRecovering = false
}
}
async sendUpdate(payload) {
const conn = this.connection
if (conn._destroyed)
throw new Error(`Connection destroyed (guild=${conn._guildId})`)
if (!conn._rest)
throw new Error(
`REST interface unavailable (guild=${conn._guildId}, sessionId=${conn.sessionId || 'none'})`
)
try {
if (conn._aqua?.debugTrace) {
conn._aqua._trace('connection.update.send', {
guildId: conn._guildId,
hasSessionId: !!conn._rest?.sessionId,
hasVoice:
!!payload?.data?.voice?.sessionId &&
!!payload?.data?.voice?.endpoint
})
}
await conn._rest.updatePlayer(payload)
if (conn._aqua?.debugTrace) {
conn._aqua._trace('connection.update.ok', {
guildId: conn._guildId
})
}
} catch (error) {
if (conn._aqua?.debugTrace) {
conn._aqua._trace('connection.update.error', {
guildId: conn._guildId,
statusCode: error?.statusCode || error?.response?.statusCode || null,
error: error?.message || String(error)
})
}
if (error.statusCode === 404 || error.response?.statusCode === 404) {
const isSessionError =
error.body?.message?.includes('sessionId') || false
const recovered = await this.recoverMissingPlayer(isSessionError)
if (recovered) return
if (conn._aqua) {
conn._aqua.emit(
AqualinkEvents.Debug,
`[Aqua/Connection] Player ${conn._guildId} not found (404, sessionId=${conn.sessionId || 'none'}, endpoint=${conn.endpoint || 'none'})${isSessionError ? ' - Session invalid' : ''}. Recovery failed, destroying.`
)
await conn._aqua.destroyPlayer(conn._guildId)
}
throw error
}
if (!this._functions.isNetworkError(error)) {
conn._aqua.emit(
AqualinkEvents.Debug,
new Error(`Voice update failed: ${error?.message || error}`)
)
}
throw error
}
}
}
class PayloadPool {
constructor() {
this._pool = []
this._size = 0
}
_create() {
return {
guildId: null,
data: {
voice: {
token: null,
endpoint: null,
sessionId: null
},
volume: null
}
}
}
acquire() {
return this._size > 0 ? this._pool[--this._size] : this._create()
}
release(payload) {
if (!payload || this._size >= 12) return
payload.guildId = null
const v = payload.data.voice
v.token = v.endpoint = v.sessionId = null
payload.data.volume = null
this._pool[this._size++] = payload
}
}
const sharedPool = new PayloadPool()
module.exports = ConnectionRecovery