diff --git a/handwritten/firestore/dev/src/transaction.ts b/handwritten/firestore/dev/src/transaction.ts index 968fb771d0f6..6267f51b2378 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. + void 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