📸 fix: Snapshot Options to Prevent Mid-Await Client Disposal Crash#12398
Conversation
…disposal Add null guard in saveMessageToDatabase to handle the case where disposeClient nullifies this.options while a userMessagePromise is still pending from a prior async save operation.
There was a problem hiding this comment.
Pull request overview
This PR aims to prevent a Node process crash caused by a race condition where disposeClient() nulls client.options while an in-flight saveMessageToDatabase() async operation is still running (triggering TypeError: Cannot read properties of null (reading 'endpoint')).
Changes:
- Add an early null-guard in
BaseClient.saveMessageToDatabase()to skip saving when the client has already been disposed. - Emit a warning log when a post-disposal save is attempted.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!this.options) { | ||
| logger.warn('[BaseClient] saveMessageToDatabase called after client disposal, skipping save'); | ||
| return {}; | ||
| } |
There was a problem hiding this comment.
The null guard only protects the beginning of the method. saveMessageToDatabase still dereferences this.options.endpoint/this.options.endpointType after an await (e.g., when building fieldsToKeep), so disposeClient() can still set this.options = null mid-flight and trigger the same TypeError. Fix by snapshotting the needed values from this.options into locals before the first await and avoid reading this.options after awaits, or re-check this.options after await db.saveMessage(...) and return early (e.g., skip convo save) if disposed.
| if (!this.options) { | ||
| logger.warn('[BaseClient] saveMessageToDatabase called after client disposal, skipping save'); | ||
| return {}; | ||
| } |
There was a problem hiding this comment.
Add a regression test that simulates disposal during the await inside saveMessageToDatabase (e.g., delay db.saveMessage/db.saveConvo, set client.options = null before the promise resolves) and asserts the method resolves without throwing. This will cover the race described in the PR and prevent future reintroductions.
The original guard at function entry was insufficient — this.options is always valid at entry. The crash occurs after the first await (db.saveMessage) when disposeClient nullifies client.options while the promise is suspended. Fix: capture this.options into a local const before any await. The local reference is immune to client.options = null set by disposeClient. Also add .catch on userMessagePromise in sendMessage to prevent unhandled rejections when checkBalance throws before the promise is awaited, and add two regression tests.
…12398) * 🐛 fix: Prevent crash when token balance exhaustion races with client disposal Add null guard in saveMessageToDatabase to handle the case where disposeClient nullifies this.options while a userMessagePromise is still pending from a prior async save operation. * 🐛 fix: Snapshot this.options to prevent mid-await disposal crash The original guard at function entry was insufficient — this.options is always valid at entry. The crash occurs after the first await (db.saveMessage) when disposeClient nullifies client.options while the promise is suspended. Fix: capture this.options into a local const before any await. The local reference is immune to client.options = null set by disposeClient. Also add .catch on userMessagePromise in sendMessage to prevent unhandled rejections when checkBalance throws before the promise is awaited, and add two regression tests.
…anny-avila#12398) * 🐛 fix: Prevent crash when token balance exhaustion races with client disposal Add null guard in saveMessageToDatabase to handle the case where disposeClient nullifies this.options while a userMessagePromise is still pending from a prior async save operation. * 🐛 fix: Snapshot this.options to prevent mid-await disposal crash The original guard at function entry was insufficient — this.options is always valid at entry. The crash occurs after the first await (db.saveMessage) when disposeClient nullifies client.options while the promise is suspended. Fix: capture this.options into a local const before any await. The local reference is immune to client.options = null set by disposeClient. Also add .catch on userMessagePromise in sendMessage to prevent unhandled rejections when checkBalance throws before the promise is awaited, and add two regression tests.
…anny-avila#12398) * 🐛 fix: Prevent crash when token balance exhaustion races with client disposal Add null guard in saveMessageToDatabase to handle the case where disposeClient nullifies this.options while a userMessagePromise is still pending from a prior async save operation. * 🐛 fix: Snapshot this.options to prevent mid-await disposal crash The original guard at function entry was insufficient — this.options is always valid at entry. The crash occurs after the first await (db.saveMessage) when disposeClient nullifies client.options while the promise is suspended. Fix: capture this.options into a local const before any await. The local reference is immune to client.options = null set by disposeClient. Also add .catch on userMessagePromise in sendMessage to prevent unhandled rejections when checkBalance throws before the promise is awaited, and add two regression tests.
Summary
TypeError: Cannot read properties of null (reading 'endpoint')that crashes the Node process when a user exhausts their token balancesendMessage()fires an asyncuserMessagePromiseviasaveMessageToDatabase()(without awaiting it), thencheckBalance()throws, and the error handler callsdisposeClient()which setsclient.options = null— while the DB save promise is still suspended at anawaitthis.optionsinto a localconst optionsbefore anyawaitinsaveMessageToDatabase(), then use the local reference throughout.disposeClientsetsclient.options = null(nulling the property on the client), but the local variable retains its reference to the original object — immune to external mutation.catch()onuserMessagePromiseinsendMessage()to prevent unhandled rejections if the promise rejects aftercheckBalancethrows (since no one awaits it on the error path)Test plan
saveMessageToDatabasereturns{}early whenthis.optionsis null at entrysaveMessageToDatabasecompletes without TypeError whenthis.optionsis nullified mid-await (snapshot protects all post-await accesses)