Skip to content

Commit f2f2469

Browse files
authored
fix(async-rewriter2): fix interaction with domains MONGOSH-662 (#751)
The fix I previously inserted for the “Node.js prints a warning if we intentionally disregard a rejected Promise from the generated inner async function” problem turned out to be problematic, because it creates an infinite loop in the domain handler system. This problem goes away with Node.js v14.15.2 (due to nodejs/node@0ad4f70), but since we also support Node.js-v12.x environments (and we would want to be resilient against changes to the platform internals anyway), it makes sense to aim for a better solution here. This patch makes sure that the generated inner async function only returns a meaningful value if its return value is actually used, i.e. if we’re encountering an async-evaluation case where the generated outer function actually just forwards the returned Promise.
1 parent 39036d7 commit f2f2469

File tree

5 files changed

+80
-25
lines changed

5 files changed

+80
-25
lines changed

packages/async-rewriter2/README.md

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -114,18 +114,25 @@ made for readability).
114114

115115
const _asynchronousReturnValue = (async () => {
116116
try {
117-
// All return statements are decorated with `return _synchronousReturnValue = ...`
118-
return _synchronousReturnValue = (
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',
123-
_ex = db, _isp(_ex) ? await _ex : _ex
124-
).test, _isp(_ex) ? await _ex : _ex
125-
).find(), _isp(_ex) ? await _ex : _ex
126-
).toArray()
127-
, _isp(_ex) ? await _ex : _ex
128-
);
117+
// All return statements are decorated with
118+
// `return (_synchronousReturnValue = ..., _functionState === 'async' ? _synchronousReturnValue : null)`
119+
// The function state check is here that, if we are returning synchronously,
120+
// we know that we are going to discard the value of `_asynchronousReturnValue`,
121+
// which is not what we want if the return value happens to be a rejected
122+
// Promise (because Node.js print a warning in that case).
123+
return (
124+
_synchronousReturnValue = (
125+
// Most expressions are wrapped in ('original source', _ex = ..., _isp(_ex) ? await _ex : _ex)
126+
_ex = ('db.test.find()',
127+
_ex = ('db.test',
128+
_ex = ('db',
129+
_ex = db, _isp(_ex) ? await _ex : _ex
130+
).test, _isp(_ex) ? await _ex : _ex
131+
).find(), _isp(_ex) ? await _ex : _ex
132+
).toArray()
133+
, _isp(_ex) ? await _ex : _ex
134+
),
135+
_functionState === 'async' ? _synchronousReturnValue : null);
129136
} catch (err) {
130137
err = _demangleError(err);
131138
if (_functionState === "sync") {
@@ -144,12 +151,6 @@ made for readability).
144151
}
145152
})();
146153

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-
}
153154
if (_functionState === "returned") {
154155
return _synchronousReturnValue;
155156
} else if (_functionState === "threw") {

packages/async-rewriter2/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"description": "MongoDB Shell Async Rewriter Package",
55
"main": "./lib/index.js",
66
"scripts": {
7+
"pretest": "npm run compile-ts",
78
"test": "mocha -r \"../../scripts/import-expansions.js\" --timeout 60000 --colors -r ts-node/register \"./{src,lib}/**/*.spec.ts\"",
89
"test-ci": "mocha -r \"../../scripts/import-expansions.js\" --timeout 60000 -r ts-node/register \"./{src,lib}/**/*.spec.ts\"",
910
"lint": "eslint \"**/*.{js,ts,tsx}\"",

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import AsyncWriter from './';
2+
import childProcess from 'child_process';
3+
import path from 'path';
4+
import { promisify } from 'util';
25
import vm from 'vm';
36
import sinon from 'ts-sinon';
47
import chai, { expect } from 'chai';
58
import sinonChai from 'sinon-chai';
69
chai.use(sinonChai);
10+
const execFile = promisify(childProcess.execFile);
711

812
describe('AsyncWriter', () => {
913
let implicitlyAsyncFn: sinon.SinonStub;
@@ -49,6 +53,10 @@ describe('AsyncWriter', () => {
4953
};
5054
});
5155

56+
before(() => {
57+
process.on('unhandledRejection', err => { throw err; });
58+
});
59+
5260
context('basic testing', () => {
5361
it('evaluates plain literal expressions', () => {
5462
expect(runTranspiledCode('42')).to.equal(42);
@@ -65,6 +73,15 @@ describe('AsyncWriter', () => {
6573
expect(runTranspiledCode('Promise.resolve([]).constructor').name).to.equal('Promise');
6674
expect(runTranspiledCode('Promise.resolve([]).constructor.name')).to.equal('Promise');
6775
});
76+
77+
it('works fine when immediately receiving a rejected Promise', async() => {
78+
try {
79+
await runTranspiledCode('Promise.reject(42)');
80+
expect.fail('missed exception');
81+
} catch (err) {
82+
expect(err).to.equal(42);
83+
}
84+
});
6885
});
6986

7087
context('scoping', () => {
@@ -594,4 +611,14 @@ describe('AsyncWriter', () => {
594611
.to.throw('abcdefghijklm ... uvwxyz is not a function');
595612
});
596613
});
614+
615+
context('domain support', () => {
616+
it('works fine when run inside a Node.js domain context', async() => {
617+
await execFile(process.execPath, [
618+
path.resolve(__dirname, '..', 'test', 'fixtures', 'with-domain.js')
619+
], {
620+
timeout: 5_000
621+
});
622+
});
623+
});
597624
});

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -284,17 +284,13 @@ export const makeMaybeAsyncFunctionPlugin = ({ types: t }: { types: typeof Babel
284284
}
285285
`);
286286

287-
// Function.prototype is a good no-op function in situations where
288-
// function literals receive special treatment :)
289287
const wrapperFunctionTemplate = babel.template.statements(`
290288
let FUNCTION_STATE_IDENTIFIER = "sync",
291289
SYNC_RETURN_VALUE_IDENTIFIER,
292290
EXPRESSION_HOLDER_IDENTIFIER;
293291
294292
const ASYNC_RETURN_VALUE_IDENTIFIER = (ASYNC_TRY_CATCH_WRAPPER)();
295293
296-
if (FUNCTION_STATE_IDENTIFIER !== "sync")
297-
ASYNC_RETURN_VALUE_IDENTIFIER.catch(Function.prototype);
298294
if (FUNCTION_STATE_IDENTIFIER === "returned")
299295
return SYNC_RETURN_VALUE_IDENTIFIER;
300296
else if (FUNCTION_STATE_IDENTIFIER === "threw")
@@ -335,6 +331,11 @@ export const makeMaybeAsyncFunctionPlugin = ({ types: t }: { types: typeof Babel
335331
}
336332
`, { placeholderPattern: false, placeholderWhitelist: new Set(['DE_IDENTIFIER']) });
337333

334+
const returnValueWrapperTemplate = babel.template.expression(`(
335+
SYNC_RETURN_VALUE_IDENTIFIER = NODE,
336+
FUNCTION_STATE_IDENTIFIER === 'async' ? SYNC_RETURN_VALUE_IDENTIFIER : null
337+
)`);
338+
338339
return {
339340
pre(file: babel.types.File) {
340341
this.file = file;
@@ -502,9 +503,14 @@ export const makeMaybeAsyncFunctionPlugin = ({ types: t }: { types: typeof Babel
502503
identifierGroup = path.getFunctionParent().getFunctionParent().getData(identifierGroupKey);
503504
if (path.parentPath.isReturnStatement() && !path.node[isGeneratedHelper]) {
504505
// If this is inside a return statement that we have not already handled,
505-
// we replace the `return ...` with `return synchronousReturnValue = ...`.
506+
// we replace the `return ...` with
507+
// `return (_synchronousReturnValue = ..., _functionState === 'async' ? _synchronousReturnValue : null)`.
506508
path.replaceWith(Object.assign(
507-
t.assignmentExpression('=', identifierGroup.synchronousReturnValue, path.node),
509+
returnValueWrapperTemplate({
510+
SYNC_RETURN_VALUE_IDENTIFIER: identifierGroup.synchronousReturnValue,
511+
FUNCTION_STATE_IDENTIFIER: identifierGroup.functionState,
512+
NODE: path.node
513+
}),
508514
{ [isGeneratedHelper]: true }
509515
));
510516
return;
@@ -579,7 +585,9 @@ export const makeMaybeAsyncFunctionPlugin = ({ types: t }: { types: typeof Babel
579585
const { expressionHolder, isSyntheticPromise } = identifierGroup;
580586
const originalSource = t.stringLiteral(
581587
'\ufeff' + limitStringLength(
582-
(this.file as any).code.slice(path.node.start, path.node.end), 24) +
588+
path.node.start !== undefined ?
589+
(this.file as any).code.slice(path.node.start, path.node.end) :
590+
'<unknown>', 24) +
583591
'\ufeff');
584592
path.replaceWith(Object.assign(
585593
awaitSyntheticPromiseTemplate({
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// eslint-disable-next-line strict
2+
'use strict';
3+
const { default: AsyncWriter } = require('../../');
4+
const domain = require('domain');
5+
const vm = require('vm');
6+
7+
// This script should not enter an infinite loop. In the past, it did on
8+
// Node.js < 14.15.2, because domains use Array.prototype.every when being
9+
// entered, for which we provide a polyfill, which could lead to an async call
10+
// because of the async wrapping, which in turn would lead to the domain being
11+
// entered, and so on.
12+
13+
const d = domain.create();
14+
const aw = new AsyncWriter();
15+
d.run(() => {
16+
vm.runInThisContext(aw.runtimeSupportCode());
17+
return vm.runInThisContext('(async() => 42)()');
18+
});

0 commit comments

Comments
 (0)