Skip to content

Commit 1536627

Browse files
authored
fix(async-rewriter2): improve error messages MONGOSH-656 (#747)
Improve the error messages thrown at runtime by storing the original source code next to the expression, and then using the fact that literals are preserved in error messages by V8 in order to restore the original source code of the expression at runtime.
1 parent e9f3f9d commit 1536627

File tree

3 files changed

+148
-10
lines changed

3 files changed

+148
-10
lines changed

packages/async-rewriter2/README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ made for readability).
104104
return p && p[_syntheticPromise];
105105
}
106106

107+
function _demangleError(err) {
108+
// ... fix up the error message in 'err' using the original source code ...
109+
}
110+
107111
let _functionState = "sync",
108112
_synchronousReturnValue,
109113
_ex;
@@ -112,17 +116,18 @@ made for readability).
112116
try {
113117
// All return statements are decorated with `return _synchronousReturnValue = ...`
114118
return _synchronousReturnValue = (
115-
// Most expressions are wrapped in (_ex = ..., _isp(_ex) ? await _ex : _ex)
116-
_ex = (
117-
_ex = (
118-
_ex = (
119+
// Most expressions are wrapped in ('original source', _ex = ..., _isp(_ex) ? await _ex : _ex)
120+
_ex = ('db.test.find()',
121+
_ex = ('db.test',
122+
_ex = ('db',
119123
_ex = db, _isp(_ex) ? await _ex : _ex
120124
).test, _isp(_ex) ? await _ex : _ex
121125
).find(), _isp(_ex) ? await _ex : _ex
122126
).toArray()
123127
, _isp(_ex) ? await _ex : _ex
124128
);
125129
} catch (err) {
130+
err = _demangleError(err);
126131
if (_functionState === "sync") {
127132
// Forward synchronous exceptions.
128133
_synchronousReturnValue = err;
@@ -139,6 +144,12 @@ made for readability).
139144
}
140145
})();
141146

147+
if (_functionState !== 'sync') {
148+
// Add a .catch with a no-op function, because if we're here, then that
149+
// means that we'll discard the async return value even if it results in a
150+
// rejected Promise (and Node.js would otherwise warn about this).
151+
_asynchronousReturnValue.catch(() => {});
152+
}
142153
if (_functionState === "returned") {
143154
return _synchronousReturnValue;
144155
} else if (_functionState === "threw") {

packages/async-rewriter2/src/async-writer-babel.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,4 +552,46 @@ describe('AsyncWriter', () => {
552552
});
553553
});
554554
});
555+
556+
context('error messages', () => {
557+
it('throws sensible error messages', () => {
558+
expect(() => runTranspiledCode('foo()'))
559+
.to.throw('foo is not defined');
560+
expect(() => runTranspiledCode('var foo = 0; foo()'))
561+
.to.throw('foo is not a function');
562+
expect(() => runTranspiledCode('Number.prototype()'))
563+
.to.throw('Number.prototype is not a function');
564+
expect(() => runTranspiledCode('(Number.prototype[0])()'))
565+
.to.throw('Number.prototype[0] is not a function');
566+
expect(() => runTranspiledCode('var db = {}; db.testx();'))
567+
.to.throw('db.testx is not a function');
568+
// (Note: The following ones would give better error messages in regular code)
569+
expect(() => runTranspiledCode('var db = {}; new Promise(db.foo)'))
570+
.to.throw('Promise resolver undefined is not a function');
571+
expect(() => runTranspiledCode('var db = {}; for (const a of db.foo) {}'))
572+
.to.throw(/undefined is not iterable/);
573+
expect(() => runTranspiledCode('var db = {}; for (const a of db[0]) {}'))
574+
.to.throw(/undefined is not iterable/);
575+
expect(() => runTranspiledCode('for (const a of 8) {}'))
576+
.to.throw('8 is not iterable');
577+
});
578+
579+
it('throws sensible error message for code in IIFEs', async() => {
580+
expect(() => runTranspiledCode('(() => foo())()'))
581+
.to.throw('foo is not defined');
582+
expect(() => runTranspiledCode('(() => { var foo; foo(); })()'))
583+
.to.throw('foo is not a function');
584+
try {
585+
await runTranspiledCode('(async () => { var foo; foo(); })()');
586+
expect.fail('missed exception');
587+
} catch (err) {
588+
expect(err.message).to.equal('foo is not a function');
589+
}
590+
});
591+
592+
it('throws sensible error messages for long expressions', () => {
593+
expect(() => runTranspiledCode('var abcdefghijklmnopqrstuvwxyz; abcdefghijklmnopqrstuvwxyz()'))
594+
.to.throw('abcdefghijklm ... uvwxyz is not a function');
595+
});
596+
});
555597
});

packages/async-rewriter2/src/async-writer-babel.ts

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ interface AsyncFunctionIdentifiers {
212212
markSyntheticPromise: babel.types.Identifier;
213213
isSyntheticPromise: babel.types.Identifier;
214214
syntheticPromiseSymbol: babel.types.Identifier;
215+
demangleError: babel.types.Identifier;
215216
}
216217
/**
217218
* The second step that performs the heavy lifting of turning regular functions
@@ -232,7 +233,7 @@ interface AsyncFunctionIdentifiers {
232233
*
233234
* The README file has more complete code snippets.
234235
*/
235-
export const makeMaybeAsyncFunctionPlugin = ({ types: t }: { types: typeof BabelTypes }): babel.PluginObj<{}> => {
236+
export const makeMaybeAsyncFunctionPlugin = ({ types: t }: { types: typeof BabelTypes }): babel.PluginObj<{ file: babel.types.File }> => {
236237
// We mark certain AST nodes as 'already visited' using these symbols.
237238
function asNodeKey(v: any): keyof babel.types.Node { return v; }
238239
const isGeneratedInnerFunction = asNodeKey(Symbol('isGeneratedInnerFunction'));
@@ -283,13 +284,17 @@ export const makeMaybeAsyncFunctionPlugin = ({ types: t }: { types: typeof Babel
283284
}
284285
`);
285286

287+
// Function.prototype is a good no-op function in situations where
288+
// function literals receive special treatment :)
286289
const wrapperFunctionTemplate = babel.template.statements(`
287290
let FUNCTION_STATE_IDENTIFIER = "sync",
288291
SYNC_RETURN_VALUE_IDENTIFIER,
289292
EXPRESSION_HOLDER_IDENTIFIER;
290293
291294
const ASYNC_RETURN_VALUE_IDENTIFIER = (ASYNC_TRY_CATCH_WRAPPER)();
292295
296+
if (FUNCTION_STATE_IDENTIFIER !== "sync")
297+
ASYNC_RETURN_VALUE_IDENTIFIER.catch(Function.prototype);
293298
if (FUNCTION_STATE_IDENTIFIER === "returned")
294299
return SYNC_RETURN_VALUE_IDENTIFIER;
295300
else if (FUNCTION_STATE_IDENTIFIER === "threw")
@@ -299,13 +304,41 @@ export const makeMaybeAsyncFunctionPlugin = ({ types: t }: { types: typeof Babel
299304
`);
300305

301306
const awaitSyntheticPromiseTemplate = babel.template.expression(`(
307+
ORIGINAL_SOURCE,
302308
EXPRESSION_HOLDER = NODE,
303309
ISP_IDENTIFIER(EXPRESSION_HOLDER) ? await EXPRESSION_HOLDER : EXPRESSION_HOLDER
304310
)`, {
305311
allowAwaitOutsideFunction: true
306312
});
307313

314+
const rethrowTemplate = babel.template.statement(`
315+
try {
316+
ORIGINAL_CODE;
317+
} catch (err) {
318+
throw err;
319+
}
320+
`);
321+
322+
// If we encounter an error object, we fix up the error message from something
323+
// like `("a" , foo(...)(...)) is not a function` to `a is not a function`.
324+
// For that, we look for a) the U+FEFF markers we use to tag the original source
325+
// code with, and b) drop everything else in this parenthesis group (this uses
326+
// the fact that currently, parentheses in error messages are nested at most
327+
// two levels deep, which makes it something that we can tackle with regexps).
328+
const demangleErrorTemplate = babel.template.statement(String.raw `
329+
function DE_IDENTIFIER(err) {
330+
if (Object.prototype.toString.call(err) === '[object Error]' &&
331+
err.message.includes('\ufeff')) {
332+
err.message = err.message.replace(/\(\s*"\ufeff(.+?)\ufeff"\s*,(?:[^\(]|\([^\)]*\))*\)/g, '$1');
333+
}
334+
return err;
335+
}
336+
`, { placeholderPattern: false, placeholderWhitelist: new Set(['DE_IDENTIFIER']) });
337+
308338
return {
339+
pre(file: babel.types.File) {
340+
this.file = file;
341+
},
309342
visitor: {
310343
BlockStatement(path) {
311344
// This might be a function body. If it's what we're looking for, wrap it.
@@ -330,7 +363,7 @@ export const makeMaybeAsyncFunctionPlugin = ({ types: t }: { types: typeof Babel
330363

331364
// A parent function might have a set of existing helper methods.
332365
// If it does, we re-use the functionally equivalent ones.
333-
const existingIdentifiers =
366+
const existingIdentifiers: AsyncFunctionIdentifiers | null =
334367
path.findParent(path => !!path.getData(identifierGroupKey))?.getData(identifierGroupKey);
335368

336369
// Generate and store a set of identifiers for helpers.
@@ -341,14 +374,16 @@ export const makeMaybeAsyncFunctionPlugin = ({ types: t }: { types: typeof Babel
341374
const markSyntheticPromise = existingIdentifiers?.markSyntheticPromise ?? path.scope.generateUidIdentifier('msp');
342375
const isSyntheticPromise = existingIdentifiers?.isSyntheticPromise ?? path.scope.generateUidIdentifier('isp');
343376
const syntheticPromiseSymbol = existingIdentifiers?.syntheticPromiseSymbol ?? path.scope.generateUidIdentifier('sp');
377+
const demangleError = existingIdentifiers?.demangleError ?? path.scope.generateUidIdentifier('de');
344378
const identifiersGroup: AsyncFunctionIdentifiers = {
345379
functionState,
346380
synchronousReturnValue,
347381
asynchronousReturnValue,
348382
expressionHolder,
349383
markSyntheticPromise,
350384
isSyntheticPromise,
351-
syntheticPromiseSymbol
385+
syntheticPromiseSymbol,
386+
demangleError
352387
};
353388
path.parentPath.setData(identifierGroupKey, identifiersGroup);
354389

@@ -385,15 +420,25 @@ export const makeMaybeAsyncFunctionPlugin = ({ types: t }: { types: typeof Babel
385420
SP_IDENTIFIER: syntheticPromiseSymbol
386421
}),
387422
{ [isGeneratedHelper]: true }
423+
),
424+
Object.assign(
425+
demangleErrorTemplate({
426+
DE_IDENTIFIER: demangleError
427+
}),
428+
{ [isGeneratedHelper]: true }
388429
)
389430
];
390431

391432
if (path.parentPath.node.async) {
392433
// If we are in an async function, no async wrapping is necessary.
393-
// We still want to have the runtime helpers available.
434+
// We still want to have the runtime helpers available, and we add
435+
// a re-throwing try/catch around the body so that we can perform
436+
// error message adjustment through the CatchClause handler below.
394437
path.replaceWith(t.blockStatement([
395438
...promiseHelpers,
396-
...path.node.body
439+
rethrowTemplate({
440+
ORIGINAL_CODE: path.node.body
441+
})
397442
]));
398443
return;
399444
}
@@ -521,22 +566,62 @@ export const makeMaybeAsyncFunctionPlugin = ({ types: t }: { types: typeof Babel
521566
}
522567

523568
// Transform expression `foo` into
524-
// `(ex = foo, isSyntheticPromise(ex) ? await ex : ex)`
569+
// `('\uFEFFfoo\uFEFF', ex = foo, isSyntheticPromise(ex) ? await ex : ex)`
570+
// The first part of the sequence expression is used to identify this
571+
// expression for re-writing error messages, so that we can transform
572+
// TypeError: ((intermediate value)(intermediate value) , (intermediate value)(intermediate value)(intermediate value)).findx is not a function
573+
// back into
574+
// TypeError: db.test.findx is not a function
575+
// The U+FEFF markers are only used to rule out any practical chance of
576+
// user code accidentally being recognized as the original source code.
577+
// We limit the string length so that long expressions (e.g. those
578+
// containing functions) are not included in full length.
525579
const { expressionHolder, isSyntheticPromise } = identifierGroup;
580+
const originalSource = t.stringLiteral(
581+
'\ufeff' + limitStringLength(
582+
(this.file as any).code.slice(path.node.start, path.node.end), 24) +
583+
'\ufeff');
526584
path.replaceWith(Object.assign(
527585
awaitSyntheticPromiseTemplate({
586+
ORIGINAL_SOURCE: originalSource,
528587
EXPRESSION_HOLDER: expressionHolder,
529588
ISP_IDENTIFIER: isSyntheticPromise,
530589
NODE: path.node
531590
}),
532591
{ [isGeneratedHelper]: true }
533592
));
534593
}
594+
},
595+
CatchClause: {
596+
exit(path) {
597+
if (path.node[isGeneratedHelper] || !path.node.param || path.node.param.type !== 'Identifier') return;
598+
const existingIdentifiers: AsyncFunctionIdentifiers | null =
599+
path.findParent(path => !!path.getData(identifierGroupKey))?.getData(identifierGroupKey);
600+
if (!existingIdentifiers) return;
601+
// Turn `... catch (err) { ... }` into `... catch (err) { err = demangleError(err); ... }`
602+
path.replaceWith(Object.assign(
603+
t.catchClause(path.node.param,
604+
t.blockStatement([
605+
t.expressionStatement(
606+
t.assignmentExpression('=', path.node.param,
607+
t.callExpression(existingIdentifiers.demangleError, [path.node.param]))),
608+
path.node.body
609+
])),
610+
{ [isGeneratedHelper]: true }
611+
));
612+
}
535613
}
536614
}
537615
};
538616
};
539617

618+
function limitStringLength(input: string, maxLength: number) {
619+
if (input.length <= maxLength) return input;
620+
return input.slice(0, (maxLength - 5) * 0.7) +
621+
' ... ' +
622+
input.slice(input.length - (maxLength - 5) * 0.3);
623+
}
624+
540625
export default class AsyncWriter {
541626
step(code: string, plugins: babel.PluginItem[]): string {
542627
return babel.transformSync(code, {

0 commit comments

Comments
 (0)