From 007d1326f4da5b4a4fdb0a8b3d96d66d9c067725 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Wed, 12 May 2021 13:45:05 +0200 Subject: [PATCH] feat(async-rewriter2): add uncatchable exception support As part of MONGOSH-640 :) --- packages/async-rewriter2/README.md | 59 +++++- .../src/async-writer-babel.spec.ts | 183 ++++++++++++++++++ .../async-rewriter2/src/async-writer-babel.ts | 4 +- .../src/stages/uncatchable-exceptions.ts | 106 ++++++++++ 4 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 packages/async-rewriter2/src/stages/uncatchable-exceptions.ts diff --git a/packages/async-rewriter2/README.md b/packages/async-rewriter2/README.md index 9c8f0b815d..079536cd3e 100644 --- a/packages/async-rewriter2/README.md +++ b/packages/async-rewriter2/README.md @@ -33,7 +33,7 @@ they reach their first `await` expression, and the fact that we can determine which `Promise`s need `await`ing by marking them as such using decorators on the API surface. -The transformation takes place in two main steps. +The transformation takes place in three main steps. ### Step one: IIFE wrapping @@ -63,7 +63,62 @@ function foo() { Note how identifiers remain accessible in the outside environment, including top-level functions being hoisted to the outside. -### Step two: Async function wrapping +### Step two: Making certain exceptions uncatchable + +In order to support Ctrl+C properly, we add a type of exception that is not +catchable by userland code. + +For example, + +```js +try { + foo3(); +} catch { + bar3(); +} +``` + +is transformed into + +```js +try { + foo3(); +} catch (_err) { + if (!err || !_err[Symbol.for('@@mongosh.uncatchable')]) { + bar3(); + } +} +``` + +and + +```js +try { + foo1(); +} catch (err) { + bar1(err); +} finally { + baz(); +} +``` + +into + +```js +let _isCatchable; + +try { + foo1(); +} catch (err) { + _isCatchable = !err || !err[Symbol.for('@@mongosh.uncatchable')]; + + if (_isCatchable) bar1(err); else throw err; +} finally { + if (_isCatchable) baz(); +} +``` + +### Step three: Async function wrapping We perform three operations: diff --git a/packages/async-rewriter2/src/async-writer-babel.spec.ts b/packages/async-rewriter2/src/async-writer-babel.spec.ts index 38d0491029..c19a65b239 100644 --- a/packages/async-rewriter2/src/async-writer-babel.spec.ts +++ b/packages/async-rewriter2/src/async-writer-babel.spec.ts @@ -49,6 +49,11 @@ describe('AsyncWriter', () => { return Object.assign( Promise.resolve(implicitlyAsyncValue), { [Symbol.for('@@mongosh.syntheticPromise')]: true }); + }, + throwUncatchable() { + throw Object.assign( + new Error('uncatchable!'), + { [Symbol.for('@@mongosh.uncatchable')]: true }); } }); runTranspiledCode = (code: string, context?: any) => { @@ -760,4 +765,182 @@ describe('AsyncWriter', () => { }); }); }); + + context('uncatchable exceptions', () => { + it('allows catching regular exceptions', () => { + const result = runTranspiledCode(` + (() => { + try { + throw new Error('generic error'); + } catch (err) { + return ({ caught: err }); + } + })();`); + expect(result.caught.message).to.equal('generic error'); + }); + + it('allows catching regular exceptions with destructuring catch (object)', () => { + const result = runTranspiledCode(` + (() => { + try { + throw new Error('generic error'); + } catch ({ message }) { + return ({ caught: message }); + } + })();`); + expect(result.caught).to.equal('generic error'); + }); + + + it('allows catching regular exceptions with destructuring catch (array)', () => { + const result = runTranspiledCode(` + (() => { + try { + throw [ 'foo' ]; + } catch ([message]) { + return ({ caught: message }); + } + })();`); + expect(result.caught).to.equal('foo'); + }); + + it('allows catching regular exceptions with destructuring catch (assignable)', () => { + const result = runTranspiledCode(` + (() => { + try { + throw [ 'foo' ]; + } catch ([message]) { + message = 42; + return ({ caught: message }); + } + })();`); + expect(result.caught).to.equal(42); + }); + + it('allows rethrowing regular exceptions', () => { + try { + runTranspiledCode(` + (() => { + try { + throw new Error('generic error'); + } catch (err) { + throw err; + } + })();`); + expect.fail('missed exception'); + } catch (err) { + expect(err.message).to.equal('generic error'); + } + }); + + it('allows returning from finally', () => { + const result = runTranspiledCode(` + (() => { + try { + throw new Error('generic error'); + } catch (err) { + return ({ caught: err }); + } finally { + return 'finally'; + } + })();`); + expect(result).to.equal('finally'); + }); + + it('allows finally without catch', () => { + const result = runTranspiledCode(` + (() => { + try { + throw new Error('generic error'); + } finally { + return 'finally'; + } + })();`); + expect(result).to.equal('finally'); + }); + + it('allows throwing primitives', () => { + const result = runTranspiledCode(` + (() => { + try { + throw null; + } catch (err) { + return ({ caught: err }); + } + })();`); + expect(result.caught).to.equal(null); + }); + + it('allows throwing primitives with finally', () => { + const result = runTranspiledCode(` + (() => { + try { + throw null; + } catch (err) { + return ({ caught: err }); + } finally { + return 'finally'; + } + })();`); + expect(result).to.equal('finally'); + }); + + it('does not catch uncatchable exceptions', () => { + try { + runTranspiledCode(` + (() => { + try { + throwUncatchable(); + } catch (err) { + return ({ caught: err }); + } + })();`); + expect.fail('missed exception'); + } catch (err) { + expect(err.message).to.equal('uncatchable!'); + } + }); + + it('does not catch uncatchable exceptions with empty catch clause', () => { + try { + runTranspiledCode(` + (() => { + try { + throwUncatchable(); + } catch { } + })();`); + expect.fail('missed exception'); + } catch (err) { + expect(err.message).to.equal('uncatchable!'); + } + }); + + it('does not catch uncatchable exceptions with finalizer', () => { + try { + runTranspiledCode(` + (() => { + try { + throwUncatchable(); + } catch { } finally { return; } + })();`); + expect.fail('missed exception'); + } catch (err) { + expect(err.message).to.equal('uncatchable!'); + } + }); + + it('does not catch uncatchable exceptions with only finalizer', () => { + try { + runTranspiledCode(` + (() => { + try { + throwUncatchable(); + } finally { return; } + })();`); + expect.fail('missed exception'); + } catch (err) { + expect(err.message).to.equal('uncatchable!'); + } + }); + }); }); diff --git a/packages/async-rewriter2/src/async-writer-babel.ts b/packages/async-rewriter2/src/async-writer-babel.ts index b07b1b4b00..2008f9a840 100644 --- a/packages/async-rewriter2/src/async-writer-babel.ts +++ b/packages/async-rewriter2/src/async-writer-babel.ts @@ -2,13 +2,14 @@ import * as babel from '@babel/core'; import runtimeSupport from './runtime-support.nocov'; import wrapAsFunctionPlugin from './stages/wrap-as-iife'; +import uncatchableExceptionPlugin from './stages/uncatchable-exceptions'; import makeMaybeAsyncFunctionPlugin from './stages/transform-maybe-await'; import { AsyncRewriterErrors } from './error-codes'; /** * General notes for this package: * - * This package contains two babel plugins used in async rewriting, plus a helper + * This package contains three babel plugins used in async rewriting, plus a helper * to apply these plugins to plain code. * * If you have not worked with babel plugins, @@ -51,6 +52,7 @@ export default class AsyncWriter { require('@babel/plugin-transform-destructuring').default ]); code = this.step(code, [wrapAsFunctionPlugin]); + code = this.step(code, [uncatchableExceptionPlugin]); code = this.step(code, [ [ makeMaybeAsyncFunctionPlugin, diff --git a/packages/async-rewriter2/src/stages/uncatchable-exceptions.ts b/packages/async-rewriter2/src/stages/uncatchable-exceptions.ts new file mode 100644 index 0000000000..480795947f --- /dev/null +++ b/packages/async-rewriter2/src/stages/uncatchable-exceptions.ts @@ -0,0 +1,106 @@ +import * as babel from '@babel/core'; +import * as BabelTypes from '@babel/types'; + +/** + * In this step, we transform try/catch statements so that there are specific + * types of exceptions that they cannot catch (marked by + * Symbol.for('@@mongosh.uncatchable')) or run finally blocks for. + */ +export default ({ types: t }: { types: typeof BabelTypes }): babel.PluginObj<{}> => { + // We mark already-visited try/catch statements using these symbols. + function asNodeKey(v: any): keyof babel.types.Node { return v; } + const isGeneratedTryCatch = asNodeKey(Symbol('isGeneratedTryCatch')); + const notUncatchableCheck = babel.template.expression(` + (!ERR_IDENTIFIER || !ERR_IDENTIFIER[Symbol.for('@@mongosh.uncatchable')]) + `); + + return { + visitor: { + TryStatement(path) { + if (path.node[isGeneratedTryCatch]) return; + const { block, finalizer } = path.node; + let catchParam: babel.types.Identifier; + let handler: babel.types.CatchClause; + const fallbackCatchParam = path.scope.generateUidIdentifier('err'); + + if (path.node.handler) { + if (path.node.handler.param?.type === 'Identifier') { + // Classic catch(err) { ... }. We're good, no need to change anything. + catchParam = path.node.handler.param; + handler = path.node.handler; + } else if (path.node.handler.param) { + // Destructuring catch({ ... }) { ... body ... }. Transform to + // catch(err) { let ... = err; ... body ... }. + catchParam = fallbackCatchParam; + handler = t.catchClause(catchParam, t.blockStatement([ + t.variableDeclaration('let', [ + t.variableDeclarator(path.node.handler.param, catchParam) + ]), + path.node.handler.body + ])); + } else { + // + catchParam = fallbackCatchParam; + handler = path.node.handler; + } + } else { + // try {} finally {} without 'catch' is valid -- if we encounter that, + // pretend that there is a dummy catch (err) { throw err; } + catchParam = fallbackCatchParam; + handler = t.catchClause(catchParam, t.blockStatement([ + t.throwStatement(catchParam) + ])); + } + + if (!finalizer) { + // No finalizer -> no need to keep track of state outside the catch {} + // block itself. This is a bit simpler. + path.replaceWith(Object.assign( + t.tryStatement( + block, + t.catchClause( + catchParam, + t.blockStatement([ + // if (!err[isUncatchableSymbol]) { ... } else throw err; + t.ifStatement( + notUncatchableCheck({ ERR_IDENTIFIER: catchParam }), + handler.body, + t.throwStatement(catchParam)) + ]) + ) + ), + { [isGeneratedTryCatch]: true })); + } else { + // finalizer -> need to store whether the exception was catchable + // (i.e. whether the finalizer is allowed to run) outside of the + // try/catch/finally block. + const isCatchable = path.scope.generateUidIdentifier('_isCatchable'); + path.replaceWithMultiple([ + t.variableDeclaration('let', [t.variableDeclarator(isCatchable)]), + Object.assign( + t.tryStatement( + block, + t.catchClause( + catchParam, + t.blockStatement([ + // isCatchable = !err[isUncatchableSymbol] + t.expressionStatement( + t.assignmentExpression('=', + isCatchable, + notUncatchableCheck({ ERR_IDENTIFIER: catchParam }))), + // if (isCatchable) { ... } else throw err; + t.ifStatement(isCatchable, handler.body, t.throwStatement(catchParam)) + ]), + ), + t.blockStatement([ + // if (isCatchable) { ... } + t.ifStatement(isCatchable, finalizer) + ]) + ), + { [isGeneratedTryCatch]: true }) + ]); + } + } + } + }; +};