From c83280ada4a14d56d8ec144770a8edf09a9bfc3d Mon Sep 17 00:00:00 2001 From: Tyler Rockwood Date: Tue, 11 Aug 2026 12:20:15 -0500 Subject: [PATCH 1/2] fix(firestore): observe rejection of lazily-started transaction ID promise When the first read of a transaction fails, the derived _transactionIdPromise can be left without a handler. Nothing awaits it for read-only transactions, because rollback() returns early when _writeBatch is unset, and a read-write transaction can leave it rejected across a macrotask boundary. Node reports an unhandled rejection, which terminates the process under the default --unhandled-rejections=throw, even though the caller handled the error from runTransaction(). Attach a no-op handler when the promise is created. Later awaiters in commit() and rollback() still reject, so error propagation is unchanged. --- handwritten/firestore/dev/src/transaction.ts | 9 ++++++ handwritten/firestore/dev/test/transaction.ts | 31 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/handwritten/firestore/dev/src/transaction.ts b/handwritten/firestore/dev/src/transaction.ts index 968fb771d0f6..802bfc3071d4 100644 --- a/handwritten/firestore/dev/src/transaction.ts +++ b/handwritten/firestore/dev/src/transaction.ts @@ -784,6 +784,15 @@ export class Transaction implements firestore.Transaction { return r.transaction; }); + // Nothing awaits `_transactionIdPromise` until a subsequent read, a + // commit, or a rollback. When the first read is the operation that + // fails, `rollback()` returns early for read-only transactions and never + // awaits it, and a read-write transaction can leave it rejected across a + // macrotask boundary. Node then reports an unhandled rejection, which + // terminates the process under the default `--unhandled-rejections=throw`. + // Observe the rejection here; awaiters still see it. + this._transactionIdPromise.catch(() => {}); + return resultPromise.then(r => r.result); } } diff --git a/handwritten/firestore/dev/test/transaction.ts b/handwritten/firestore/dev/test/transaction.ts index 175055ada1b3..f2662586b305 100644 --- a/handwritten/firestore/dev/test/transaction.ts +++ b/handwritten/firestore/dev/test/transaction.ts @@ -514,6 +514,37 @@ describe('failed transactions', () => { } }); + it('does not orphan the transaction ID promise when the first read fails', async () => { + // The transaction ID promise is derived from the first read. On this path + // nothing ever awaits it, so without an attached handler Node reports an + // unhandled rejection and terminates the process. + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => + unhandledRejections.push(reason); + process.on('unhandledRejection', onUnhandledRejection); + + const serverError = new GoogleError('Test Error'); + serverError.code = Status.UNAUTHENTICATED; + + try { + await expect( + runTransaction( + /* transactionOptions= */ {readOnly: true}, + (transaction, docRef) => transaction.get(docRef), + getDocument({newTransaction: {readOnly: {}}, error: serverError}), + // No rollback because the lazy-start operation failed + ), + ).to.eventually.be.rejected; + + // Node reports unhandled rejections once the microtask queue drains, so + // yield a macrotask before asserting. + await new Promise(resolve => setImmediate(resolve)); + expect(unhandledRejections).to.be.empty; + } finally { + process.removeListener('unhandledRejection', onUnhandledRejection); + } + }); + it('retries commit for expired transaction', async () => { // The transaction needs to perform a read or write otherwise it will be // a no-op and will not retry From ee23e96078e847add976c624301a0c2482d52bfb Mon Sep 17 00:00:00 2001 From: Tyler Rockwood Date: Tue, 11 Aug 2026 12:32:53 -0500 Subject: [PATCH 2/2] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- handwritten/firestore/dev/src/transaction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handwritten/firestore/dev/src/transaction.ts b/handwritten/firestore/dev/src/transaction.ts index 802bfc3071d4..6267f51b2378 100644 --- a/handwritten/firestore/dev/src/transaction.ts +++ b/handwritten/firestore/dev/src/transaction.ts @@ -791,7 +791,7 @@ export class Transaction implements firestore.Transaction { // macrotask boundary. Node then reports an unhandled rejection, which // terminates the process under the default `--unhandled-rejections=throw`. // Observe the rejection here; awaiters still see it. - this._transactionIdPromise.catch(() => {}); + void this._transactionIdPromise.catch(() => {}); return resultPromise.then(r => r.result); }