More reliable debounced timeouts

This commit is contained in:
Adhiraj Singh
2020-10-23 14:21:15 +05:30
parent 5861e851cc
commit 51672150e4
5 changed files with 65 additions and 47 deletions

View File

@@ -116,16 +116,20 @@ describe('Test Connect', () => {
describe ('Reconnects', () => { describe ('Reconnects', () => {
const verifyConnectionOpen = async (conn: WAConnection) => { const verifyConnectionOpen = async (conn: WAConnection) => {
assert.ok (conn.user.jid) assert.ok (conn.user.jid)
let failed = false
// check that the connection stays open // check that the connection stays open
conn.on ('close', ({reason}) => ( conn.on ('close', ({reason}) => {
reason !== DisconnectReason.intentional && assert.fail ('should not have closed again') if(reason !== DisconnectReason.intentional) failed = true
)) })
await delay (60*1000) await delay (60*1000)
const status = await conn.getStatus () const status = await conn.getStatus ()
assert.ok (status) assert.ok (status)
assert.ok (!conn['debounceTimeout']) // this should be null
conn.close () conn.close ()
if (failed) assert.fail ('should not have closed again')
} }
it('should dispose correctly on bad_session', async () => { it('should dispose correctly on bad_session', async () => {
const conn = new WAConnection() const conn = new WAConnection()
@@ -185,6 +189,7 @@ describe ('Reconnects', () => {
*/ */
it('should disrupt connect loop', async () => { it('should disrupt connect loop', async () => {
const conn = new WAConnection() const conn = new WAConnection()
conn.autoReconnect = ReconnectMode.onAllErrors conn.autoReconnect = ReconnectMode.onAllErrors
conn.loadAuthInfo ('./auth_info.json') conn.loadAuthInfo ('./auth_info.json')
@@ -194,7 +199,7 @@ describe ('Reconnects', () => {
while (!conn['conn']) { while (!conn['conn']) {
await delay(100) await delay(100)
} }
conn['conn'].terminate () conn['conn'].close ()
while (conn['conn']) { while (conn['conn']) {
await delay(100) await delay(100)

View File

@@ -32,7 +32,7 @@ const logger = pino({ prettyPrint: { levelFirst: true, ignore: 'hostname', trans
export class WAConnection extends EventEmitter { export class WAConnection extends EventEmitter {
/** The version of WhatsApp Web we're telling the servers we are */ /** The version of WhatsApp Web we're telling the servers we are */
version: [number, number, number] = [2, 2041, 6] version: [number, number, number] = [2, 2043, 8]
/** The Browser we're telling the WhatsApp Web servers we are */ /** The Browser we're telling the WhatsApp Web servers we are */
browserDescription: [string, string, string] = Utils.Browsers.baileys ('Chrome') browserDescription: [string, string, string] = Utils.Browsers.baileys ('Chrome')
/** Metadata like WhatsApp id, name set on WhatsApp etc. */ /** Metadata like WhatsApp id, name set on WhatsApp etc. */
@@ -46,8 +46,8 @@ export class WAConnection extends EventEmitter {
maxIdleTimeMs: 15_000, maxIdleTimeMs: 15_000,
waitOnlyForLastMessage: false, waitOnlyForLastMessage: false,
waitForChats: true, waitForChats: true,
maxRetries: 5, maxRetries: 10,
connectCooldownMs: 3000, connectCooldownMs: 4000,
phoneResponseTime: 10_000, phoneResponseTime: 10_000,
alwaysUseTakeover: true alwaysUseTakeover: true
} }
@@ -93,11 +93,12 @@ export class WAConnection extends EventEmitter {
protected mediaConn: MediaConnInfo protected mediaConn: MediaConnInfo
protected debounceTimeout: NodeJS.Timeout protected debounceTimeout: NodeJS.Timeout
protected onDebounceTimeout = () => {}
constructor () { constructor () {
super () super ()
this.registerCallback (['Cmd', 'type:disconnect'], json => ( this.registerCallback (['Cmd', 'type:disconnect'], json => (
this.unexpectedDisconnect(json[1].kind || 'unknown') this.state === 'open' && this.unexpectedDisconnect(json[1].kind || 'unknown')
)) ))
} }
/** /**
@@ -246,7 +247,7 @@ export class WAConnection extends EventEmitter {
* @param tag the tag to attach to the message * @param tag the tag to attach to the message
*/ */
async query(q: WAQuery) { async query(q: WAQuery) {
let {json, binaryTags, tag, timeoutMs, expect200, waitForOpen, longTag, requiresPhoneConnection} = q let {json, binaryTags, tag, timeoutMs, expect200, waitForOpen, longTag, requiresPhoneConnection, startDebouncedTimeout} = q
requiresPhoneConnection = requiresPhoneConnection !== false requiresPhoneConnection = requiresPhoneConnection !== false
waitForOpen = waitForOpen !== false waitForOpen = waitForOpen !== false
if (waitForOpen) await this.waitForConnection() if (waitForOpen) await this.waitForConnection()
@@ -273,6 +274,7 @@ export class WAConnection extends EventEmitter {
{query: json, message, status: response.status} {query: json, message, status: response.status}
) )
} }
if (startDebouncedTimeout) this.stopDebouncedTimeout ()
return response return response
} }
/** interval is started when a query takes too long to respond */ /** interval is started when a query takes too long to respond */
@@ -320,6 +322,14 @@ export class WAConnection extends EventEmitter {
await this.send(buff) // send it off await this.send(buff) // send it off
return tag return tag
} }
protected startDebouncedTimeout () {
this.stopDebouncedTimeout ()
this.debounceTimeout = setTimeout (() => this.onDebounceTimeout(), this.connectOptions.maxIdleTimeMs)
}
protected stopDebouncedTimeout () {
this.debounceTimeout && clearTimeout (this.debounceTimeout)
this.debounceTimeout = null
}
/** /**
* Send a plain JSON message to the WhatsApp servers * Send a plain JSON message to the WhatsApp servers
* @param json the message to send * @param json the message to send
@@ -390,10 +400,9 @@ export class WAConnection extends EventEmitter {
this.keepAliveReq && clearInterval(this.keepAliveReq) this.keepAliveReq && clearInterval(this.keepAliveReq)
this.clearPhoneCheckInterval () this.clearPhoneCheckInterval ()
try { try {
this.conn?.close() this.conn?.close()
this.conn?.terminate() //this.conn?.terminate()
} catch { } catch {
} }

View File

@@ -6,7 +6,7 @@ import { WAMetric, WAFlag, BaileysError, Presence, WAUser } from './Constants'
export class WAConnection extends Base { export class WAConnection extends Base {
/** Authenticate the connection */ /** Authenticate the connection */
protected async authenticate (startDebouncedTimeout: () => void, stopDebouncedTimeout: () => void, reconnect?: string) { protected async authenticate (reconnect?: string) {
// if no auth info is present, that is, a new session has to be established // if no auth info is present, that is, a new session has to be established
// generate a client ID // generate a client ID
if (!this.authInfo?.clientID) { if (!this.authInfo?.clientID) {
@@ -16,7 +16,7 @@ export class WAConnection extends Base {
const canLogin = this.authInfo?.encKey && this.authInfo?.macKey const canLogin = this.authInfo?.encKey && this.authInfo?.macKey
this.referenceDate = new Date () // refresh reference date this.referenceDate = new Date () // refresh reference date
startDebouncedTimeout () this.startDebouncedTimeout ()
const initQueries = [ const initQueries = [
(async () => { (async () => {
@@ -25,12 +25,13 @@ export class WAConnection extends Base {
expect200: true, expect200: true,
waitForOpen: false, waitForOpen: false,
longTag: true, longTag: true,
requiresPhoneConnection: false requiresPhoneConnection: false,
startDebouncedTimeout: true
}) })
if (!canLogin) { if (!canLogin) {
stopDebouncedTimeout () // stop the debounced timeout for QR gen this.stopDebouncedTimeout () // stop the debounced timeout for QR gen
const result = await this.generateKeysForAuth (ref) const result = await this.generateKeysForAuth (ref)
startDebouncedTimeout () // restart debounced timeout this.startDebouncedTimeout () // restart debounced timeout
return result return result
} }
})() })()
@@ -49,7 +50,15 @@ export class WAConnection extends Base {
if (reconnect) json.push(...['reconnect', reconnect.replace('@s.whatsapp.net', '@c.us')]) if (reconnect) json.push(...['reconnect', reconnect.replace('@s.whatsapp.net', '@c.us')])
else json.push ('takeover') else json.push ('takeover')
let response = await this.query({ json, tag: 's1', waitForOpen: false, expect200: true, longTag: true, requiresPhoneConnection: false }) // wait for response with tag "s1" let response = await this.query({
json,
tag: 's1',
waitForOpen: false,
expect200: true,
longTag: true,
requiresPhoneConnection: false,
startDebouncedTimeout: true
}) // wait for response with tag "s1"
// if its a challenge request (we get it when logging in) // if its a challenge request (we get it when logging in)
if (response[1]?.challenge) { if (response[1]?.challenge) {
await this.respondToChallenge(response[1].challenge) await this.respondToChallenge(response[1].challenge)
@@ -66,9 +75,11 @@ export class WAConnection extends Base {
this.logger.info('validated connection successfully') this.logger.info('validated connection successfully')
this.emit ('connection-validated', this.user) this.emit ('connection-validated', this.user)
const response = await this.query({ json: ['query', 'ProfilePicThumb', this.user.jid], waitForOpen: false, expect200: false, requiresPhoneConnection: false }) if (this.loadProfilePicturesForChatsAutomatically) {
this.user.imgUrl = response?.eurl || '' const response = await this.query({ json: ['query', 'ProfilePicThumb', this.user.jid], waitForOpen: false, expect200: false, requiresPhoneConnection: false, startDebouncedTimeout: true })
this.user.imgUrl = response?.eurl || ''
}
this.sendPostConnectQueries () this.sendPostConnectQueries ()
this.logger.debug('sent init queries') this.logger.debug('sent init queries')
@@ -181,7 +192,7 @@ export class WAConnection extends Base {
const json = ['admin', 'challenge', signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID const json = ['admin', 'challenge', signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID
this.logger.info('resolving login challenge') this.logger.info('resolving login challenge')
return this.query({json, expect200: true, waitForOpen: false}) return this.query({json, expect200: true, waitForOpen: false, startDebouncedTimeout: true})
} }
/** When starting a new session, generate a QR code by generating a private/public key pair & the keys the server sends */ /** When starting a new session, generate a QR code by generating a private/public key pair & the keys the server sends */
protected async generateKeysForAuth(ref: string) { protected async generateKeysForAuth(ref: string) {
@@ -194,21 +205,21 @@ export class WAConnection extends Base {
} }
const regenQR = () => { const regenQR = () => {
this.qrTimeout = setTimeout (() => { this.qrTimeout = setTimeout (async () => {
if (this.state === 'open') return if (this.state === 'open') return
this.logger.debug ('regenerated QR') this.logger.debug ('regenerated QR')
try {
this.generateNewQRCodeRef () const newRef = await this.generateNewQRCodeRef ()
.then (newRef => ref = newRef) ref = newRef
.then (emitQR) emitQR ()
.then (regenQR) regenQR ()
.catch (error => { } catch (error) {
this.logger.error ({ error }, `error in QR gen`) this.logger.error ({ error }, `error in QR gen`)
if (error.status === 429) { // too many QR requests if (error.status === 429) { // too many QR requests
this.endConnection () this.endConnection ()
} }
}) }
}, this.connectOptions.regenerateQRIntervalMs) }, this.connectOptions.regenerateQRIntervalMs)
} }

View File

@@ -66,16 +66,6 @@ export class WAConnection extends Base {
let cancel: () => void let cancel: () => void
const task = new Promise((resolve, reject) => { const task = new Promise((resolve, reject) => {
cancel = () => reject (CancelledError()) cancel = () => reject (CancelledError())
const checkIdleTime = () => {
this.debounceTimeout && clearTimeout (this.debounceTimeout)
this.debounceTimeout = setTimeout (() => rejectSafe (TimedOutError()), this.connectOptions.maxIdleTimeMs)
}
const startDebouncedTimeout = () => this.connectOptions.maxIdleTimeMs && this.conn.addEventListener ('message', checkIdleTime)
const stopDebouncedTimeout = () => {
clearTimeout (this.debounceTimeout)
this.conn.removeEventListener ('message', checkIdleTime)
}
// determine whether reconnect should be used or not // determine whether reconnect should be used or not
const shouldUseReconnect = (this.lastDisconnectReason === DisconnectReason.close || const shouldUseReconnect = (this.lastDisconnectReason === DisconnectReason.close ||
this.lastDisconnectReason === DisconnectReason.lost) && this.lastDisconnectReason === DisconnectReason.lost) &&
@@ -110,7 +100,8 @@ export class WAConnection extends Base {
} }
} }
try { try {
await this.authenticate (startDebouncedTimeout, stopDebouncedTimeout, reconnectID) this.onDebounceTimeout = () => rejectSafe(TimedOutError())
await this.authenticate (reconnectID)
this.startKeepAliveRequest() this.startKeepAliveRequest()
@@ -118,7 +109,9 @@ export class WAConnection extends Base {
.removeAllListeners ('error') .removeAllListeners ('error')
.removeAllListeners ('close') .removeAllListeners ('close')
const result = waitForChats && (await waitForChats) const result = waitForChats && (await waitForChats)
this.conn.removeEventListener ('message', checkIdleTime)
this.stopDebouncedTimeout ()
resolve (result) resolve (result)
} catch (error) { } catch (error) {
reject (error) reject (error)
@@ -177,10 +170,8 @@ export class WAConnection extends Base {
const deregisterCallbacks = () => { const deregisterCallbacks = () => {
// wait for actual messages to load, "last" is the most recent message, "before" contains prior messages // wait for actual messages to load, "last" is the most recent message, "before" contains prior messages
this.deregisterCallback(['action', 'add:last']) this.deregisterCallback(['action', 'add:last'])
if (!waitOnlyForLast) { this.deregisterCallback(['action', 'add:before'])
this.deregisterCallback(['action', 'add:before']) this.deregisterCallback(['action', 'add:unread'])
this.deregisterCallback(['action', 'add:unread'])
}
this.deregisterCallback(['response', 'type:chat']) this.deregisterCallback(['response', 'type:chat'])
this.deregisterCallback(['response', 'type:contacts']) this.deregisterCallback(['response', 'type:contacts'])
@@ -189,13 +180,13 @@ export class WAConnection extends Base {
// wait for messages to load // wait for messages to load
const chatUpdate = json => { const chatUpdate = json => {
this.startDebouncedTimeout () // restart debounced timeout
receivedMessages = true receivedMessages = true
const isLast = json[1].last || waitOnlyForLast const isLast = json[1].last || waitOnlyForLast
const messages = json[2] as WANode[] const messages = json[2] as WANode[]
if (messages) { if (messages) {
messages.reverse().forEach (([,, message]: ['message', null, WAMessage]) => { messages.reverse().forEach (([,, message]: ['message', null, WAMessage]) => {
const jid = message.key.remoteJid const jid = message.key.remoteJid
const chat = chats.get(jid) const chat = chats.get(jid)
@@ -207,7 +198,6 @@ export class WAConnection extends Base {
message['epoch'] = prevEpoch-1 message['epoch'] = prevEpoch-1
chat.messages.insert (message) chat.messages.insert (message)
} }
}) })
} }
// if received contacts before messages // if received contacts before messages
@@ -222,6 +212,7 @@ export class WAConnection extends Base {
// get chats // get chats
this.registerCallback(['response', 'type:chat'], json => { this.registerCallback(['response', 'type:chat'], json => {
if (json[1].duplicate || !json[2]) return if (json[1].duplicate || !json[2]) return
this.startDebouncedTimeout () // restart debounced timeout
json[2] json[2]
.forEach(([item, chat]: [any, WAChat]) => { .forEach(([item, chat]: [any, WAChat]) => {
@@ -248,6 +239,7 @@ export class WAConnection extends Base {
// get contacts // get contacts
this.registerCallback(['response', 'type:contacts'], json => { this.registerCallback(['response', 'type:contacts'], json => {
if (json[1].duplicate || !json[2]) return if (json[1].duplicate || !json[2]) return
this.startDebouncedTimeout () // restart debounced timeout
receivedContacts = true receivedContacts = true

View File

@@ -57,6 +57,7 @@ export interface WAQuery {
waitForOpen?: boolean waitForOpen?: boolean
longTag?: boolean longTag?: boolean
requiresPhoneConnection?: boolean requiresPhoneConnection?: boolean
startDebouncedTimeout?: boolean
} }
export enum ReconnectMode { export enum ReconnectMode {
/** does not reconnect */ /** does not reconnect */