fix(firestore): do not throw when wrapping an error with a read only stack - #9197
fix(firestore): do not throw when wrapping an error with a read only stack#9197Om-singhaI wants to merge 2 commits into
Conversation
…stack
wrapError() appends the captured callsite stack to the error it is given
with a compound assignment. The emitted util.js runs in strict mode, so
when the error carries a non writable own `stack` property the assignment
throws a TypeError instead of silently doing nothing, and that TypeError
replaces the error the caller was meant to see.
The consequences depend on the call site. In query-util.ts the wrap runs
inside a stream 'error' listener, which is invoked long after the Promise
executor returned, so the TypeError propagates out of EventEmitter.emit
through emitErrorNT and surfaces as an uncaughtException that takes the
process down. Reproduced on a query whose backend stream fails with such
an error:
Uncaught TypeError: Cannot assign to read only property 'stack' of
object 'Error: Expected error'
at wrapError (dev/src/util.ts:237:12)
at Transform.<anonymous> (dev/src/reference/query-util.ts:87:27)
at Transform.emit (node:events:508:20)
at emitErrorNT (node:internal/streams/destroy:170:8)
aggregate-query.ts and pipelines/pipeline-util.ts wrap inside stream
'error' listeners the same way and have the same exposure.
Redefine the property when a plain assignment is not allowed, which keeps
the callsite for the errors that actually trigger this. google-auth-library
installs `stack` with Object.defineProperty and specifies only value,
writable and enumerable; because `stack` already exists on the error the
unspecified `configurable` keeps its current value of true, so the property
can still be redefined. Wrap the whole thing so that an error whose stack
is genuinely immutable is returned untouched instead. Recording the
callsite is a diagnostic nicety; delivering the actual error is not
optional.
Adds unit tests for both descriptor shapes and a query level test covering
the stream listener path.
There was a problem hiding this comment.
Code Review
This pull request updates the wrapError utility function to safely handle errors with non-writable stack properties (such as those generated by google-auth-library), preventing TypeError exceptions during stack trace wrapping. It also adds comprehensive unit and integration tests for these scenarios. The reviewer suggested optimizing wrapError by attempting direct assignment first and using a nested try-catch block to handle read-only or accessor properties, which avoids the overhead of Object.getOwnPropertyDescriptor on the happy path.
| const wrappedStack = err.stack + '\nCaused by: ' + stack; | ||
| try { | ||
| if (Object.getOwnPropertyDescriptor(err, 'stack')?.writable === false) { | ||
| // Some libraries define `stack` as a read only property, which makes a | ||
| // plain assignment throw. Redefining the property still preserves the | ||
| // callsite as long as the property stayed configurable. | ||
| Object.defineProperty(err, 'stack', {value: wrappedStack}); | ||
| } else { | ||
| err.stack = wrappedStack; | ||
| } | ||
| } catch { | ||
| // The stack cannot be modified at all. Recording the callsite is best | ||
| // effort, so return the error we were asked to wrap rather than let the | ||
| // resulting TypeError take its place. | ||
| } | ||
| return err; |
There was a problem hiding this comment.
Instead of calling Object.getOwnPropertyDescriptor on every call to wrapError (which adds overhead on the happy path) and only checking writable === false, we can use a nested try-catch block. This approach is more robust because it also handles accessor properties without setters (where writable is undefined but assignment still throws), and it avoids property descriptor lookup overhead when the stack is writable.
const wrappedStack = err.stack + '\nCaused by: ' + stack;
try {
err.stack = wrappedStack;
} catch {
try {
// Some libraries define `stack` as a read-only or accessor property without a setter,
// which makes plain assignment throw. Redefining the property preserves the callsite
// as long as the property is configurable.
Object.defineProperty(err, 'stack', {
value: wrappedStack,
configurable: true,
writable: true,
});
} catch {
// The stack cannot be modified at all. Recording the callsite is best
// effort, so return the error we were asked to wrap rather than let the
// resulting TypeError take its place.
}
}
return err;|
Good catch, taken. I had only checked Added a test for that case. One thing worth knowing if anyone writes a similar test later: the Reverting only |
fix(firestore): do not throw when wrapping an error with a read only stack
Fixes #9154
What breaks
wrapError()inhandwritten/firestore/dev/src/util.tsappends the capturedcallsite stack to the error it is handed:
The compiled
build/src/util.jsbegins with"use strict", so this compoundassignment is a strict mode write. When the incoming error carries a non
writable own
stackproperty the write does not silently do nothing. It throwsa
TypeError, and thatTypeErrortakes the place of the real error the callerwas supposed to receive. The user loses both the failure and any hint of where
it came from.
Why it takes the process down
Where
wrapError()is called decides how bad this is.At
dev/src/index.ts:1384the call sits inside a promise.catch(), so a throwthere only turns into a rejected promise. Unpleasant, but catchable.
At
dev/src/reference/query-util.ts:87it sits inside a stream'error'listener:
That listener runs long after the
Promiseexecutor returned, so a throw insideit is not funnelled into
reject. It propagates out ofEventEmitter.emit,through
emitErrorNT, and becomes anuncaughtException. Two further callsites have the identical shape and the identical exposure:
dev/src/reference/aggregate-query.ts:151anddev/src/pipelines/pipeline-util.ts:106.Running a query whose backend stream fails with such an error, against
mainbefore this change, produces:
The fix
Two things are going on, and they are separate on purpose.
Redefining rather than assigning keeps the diagnostic. The errors that
actually trigger this in the wild are still configurable, so the callsite can be
recorded. Simply swallowing the
TypeErrorwould makewrapError()a no op forexactly the failures where an async stack trace is most useful.
The
tryis the safety net, not the mechanism. Astackthat is both nonwritable and non configurable cannot be redefined either, and neither can one on
a frozen error. In those cases the original error is returned untouched.
Recording the callsite is a diagnostic nicety; delivering the actual error is
not optional.
Behaviour for ordinary errors is unchanged: on a normal
Error,stackis anaccessor property,
descriptor.writableisundefined, and the plainassignment path runs exactly as before.
Relationship to the auth library
One producer of such errors is
core/packages/google-auth-library-nodejs/src/auth/oauth2common.ts, wheregetErrorFromOAuthErrorResponse()copiesstackonto a new error:Worth noting, because it decides which fix is correct here:
configurableisnot specified, and
stackalready exists onnewError, sodefinePropertyleaves the existing attribute alone. The resulting descriptor is
writable: false, configurable: true. Verified against thegoogle-auth-librarythat Firestore actually resolves:That producer is a separate defect in a separate package with a separate owner
(
.github/CODEOWNERSassigns/handwritten/firestoreto@googleapis/firestore-teamand/core/packages/google-auth-library-nodejsto@googleapis/aion-team), and it is addressed on its own. Fixing the producerdoes not fix this one.
wrapError()still converts any error with a nonwritable stack, from any source, into a
TypeErroror anuncaughtException,and Firestore resolves the auth library transitively through a published
google-gax(@google-cloud/firestore-apitogoogle-gaxtogoogle-auth-library), so released Firestore keeps crashing until every layerupgrades. This change makes the Firestore side robust on its own.
Testing
Three regression tests, all written before the fix and confirmed failing against
unmodified source.
dev/test/util.ts, unit coverage of the root cause:appends the callsite stack to the error stackguards the existing behaviour.appends the callsite stack when the error stack is not writableuses thedescriptor shape that
google-auth-libraryproduces, and asserts the callsiteis still recorded.
returns the original error when its stack cannot be modifieduses a nonconfigurable stack and asserts the original error comes back untouched.
dev/test/query.ts, coverage of the reported crash path:handles stream exception whose stack is not writabledrives a real querythrough
query-util.tswith a mock backend stream that fails with an errorwhose
stackwas defined the waygoogle-auth-librarydefines it, andasserts the returned promise rejects with the original error.
All three fail without the source change
With
dev/src/util.tsrestored toorigin/mainand the tests left in place:Suites with the change applied
Run from
handwritten/firestoreon Node v25.6.1.No failures. Changed files pass
prettierand thegtsbase ESLint config.Note that
npm run lintat the package level currently cannot run from apackage only install, because the repository root
.eslintrc.jsonextends./node_modules/gtsrelative to the repository root; that is unrelated to thischange.