From eeab7bc0688256247c47099a90c67741e6637e42 Mon Sep 17 00:00:00 2001 From: Timothy Gu Date: Sat, 23 Sep 2017 00:10:47 -0700 Subject: [PATCH] repl: support top-level await Much of the AST visitor code was ported from Chrome DevTools code written by Aleksey Kozyatinskiy . PR-URL: https://github.com/nodejs/node/pull/15566 Fixes: https://github.com/nodejs/node/issues/13209 Refs: https://chromium.googlesource.com/chromium/src/+/e8111c396fef38da6654093433b4be93bed01dce Reviewed-By: Stephen Belanger Reviewed-By: Ben Noordhuis --- lib/internal/repl/await.js | 128 +++++++++++++ lib/repl.js | 86 ++++++++- node.gyp | 1 + .../test-repl-preprocess-top-level-await.js | 68 +++++++ test/parallel/test-repl-top-level-await.js | 173 ++++++++++++++++++ 5 files changed, 449 insertions(+), 7 deletions(-) create mode 100644 lib/internal/repl/await.js create mode 100644 test/parallel/test-repl-preprocess-top-level-await.js create mode 100644 test/parallel/test-repl-top-level-await.js diff --git a/lib/internal/repl/await.js b/lib/internal/repl/await.js new file mode 100644 index 00000000000000..dfd4e9308594c1 --- /dev/null +++ b/lib/internal/repl/await.js @@ -0,0 +1,128 @@ +'use strict'; + +const acorn = require('internal/deps/acorn/dist/acorn'); +const walk = require('internal/deps/acorn/dist/walk'); + +const noop = () => {}; +const visitorsWithoutAncestors = { + ClassDeclaration(node, state, c) { + if (state.ancestors[state.ancestors.length - 2] === state.body) { + state.prepend(node, `${node.id.name}=`); + } + walk.base.ClassDeclaration(node, state, c); + }, + FunctionDeclaration(node, state, c) { + state.prepend(node, `${node.id.name}=`); + }, + FunctionExpression: noop, + ArrowFunctionExpression: noop, + MethodDefinition: noop, + AwaitExpression(node, state, c) { + state.containsAwait = true; + walk.base.AwaitExpression(node, state, c); + }, + ReturnStatement(node, state, c) { + state.containsReturn = true; + walk.base.ReturnStatement(node, state, c); + }, + VariableDeclaration(node, state, c) { + if (node.kind === 'var' || + state.ancestors[state.ancestors.length - 2] === state.body) { + if (node.declarations.length === 1) { + state.replace(node.start, node.start + node.kind.length, 'void'); + } else { + state.replace(node.start, node.start + node.kind.length, 'void ('); + } + + for (const decl of node.declarations) { + state.prepend(decl, '('); + state.append(decl, decl.init ? ')' : '=undefined)'); + } + + if (node.declarations.length !== 1) { + state.append(node.declarations[node.declarations.length - 1], ')'); + } + } + + walk.base.VariableDeclaration(node, state, c); + } +}; + +const visitors = {}; +for (const nodeType of Object.keys(walk.base)) { + const callback = visitorsWithoutAncestors[nodeType] || walk.base[nodeType]; + visitors[nodeType] = (node, state, c) => { + const isNew = node !== state.ancestors[state.ancestors.length - 1]; + if (isNew) { + state.ancestors.push(node); + } + callback(node, state, c); + if (isNew) { + state.ancestors.pop(); + } + }; +} + +function processTopLevelAwait(src) { + const wrapped = `(async () => { ${src} })()`; + const wrappedArray = wrapped.split(''); + let root; + try { + root = acorn.parse(wrapped, { ecmaVersion: 8 }); + } catch (err) { + return null; + } + const body = root.body[0].expression.callee.body; + const state = { + body, + ancestors: [], + replace(from, to, str) { + for (var i = from; i < to; i++) { + wrappedArray[i] = ''; + } + if (from === to) str += wrappedArray[from]; + wrappedArray[from] = str; + }, + prepend(node, str) { + wrappedArray[node.start] = str + wrappedArray[node.start]; + }, + append(node, str) { + wrappedArray[node.end - 1] += str; + }, + containsAwait: false, + containsReturn: false + }; + + walk.recursive(body, state, visitors); + + // Do not transform if + // 1. False alarm: there isn't actually an await expression. + // 2. There is a top-level return, which is not allowed. + if (!state.containsAwait || state.containsReturn) { + return null; + } + + const last = body.body[body.body.length - 1]; + if (last.type === 'ExpressionStatement') { + // For an expression statement of the form + // ( expr ) ; + // ^^^^^^^^^^ // last + // ^^^^ // last.expression + // + // We do not want the left parenthesis before the `return` keyword; + // therefore we prepend the `return (` to `last`. + // + // On the other hand, we do not want the right parenthesis after the + // semicolon. Since there can only be more right parentheses between + // last.expression.end and the semicolon, appending one more to + // last.expression should be fine. + state.prepend(last, 'return ('); + state.append(last.expression, ')'); + } + + return wrappedArray.join(''); +} + +module.exports = { + processTopLevelAwait +}; diff --git a/lib/repl.js b/lib/repl.js index 7fa39577ba8206..e2e716dd66f2da 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -43,6 +43,7 @@ 'use strict'; const internalModule = require('internal/module'); +const { processTopLevelAwait } = require('internal/repl/await'); const internalUtil = require('internal/util'); const { isTypedArray } = require('internal/util/types'); const util = require('util'); @@ -200,6 +201,7 @@ function REPLServer(prompt, function defaultEval(code, context, file, cb) { var err, result, script, wrappedErr; var wrappedCmd = false; + var awaitPromise = false; var input = code; if (/^\s*\{/.test(code) && /\}\s*$/.test(code)) { @@ -211,6 +213,15 @@ function REPLServer(prompt, wrappedCmd = true; } + if (code.includes('await')) { + const potentialWrappedCode = processTopLevelAwait(code); + if (potentialWrappedCode !== null) { + code = potentialWrappedCode; + wrappedCmd = true; + awaitPromise = true; + } + } + // first, create the Script object to check the syntax if (code === '\n') @@ -231,8 +242,9 @@ function REPLServer(prompt, } catch (e) { debug('parse error %j', code, e); if (wrappedCmd) { - wrappedCmd = false; // unwrap and try again + wrappedCmd = false; + awaitPromise = false; code = input; wrappedErr = e; continue; @@ -251,6 +263,20 @@ function REPLServer(prompt, // predefined RegExp properties `RegExp.$1`, `RegExp.$2` ... `RegExp.$9` regExMatcher.test(savedRegExMatches.join(sep)); + let finished = false; + function finishExecution(err, result) { + if (finished) return; + finished = true; + + // After executing the current expression, store the values of RegExp + // predefined properties back in `savedRegExMatches` + for (var idx = 1; idx < savedRegExMatches.length; idx += 1) { + savedRegExMatches[idx] = RegExp[`$${idx}`]; + } + + cb(err, result); + } + if (!err) { // Unset raw mode during evaluation so that Ctrl+C raises a signal. let previouslyInRawMode; @@ -301,15 +327,53 @@ function REPLServer(prompt, return; } } - } - // After executing the current expression, store the values of RegExp - // predefined properties back in `savedRegExMatches` - for (var idx = 1; idx < savedRegExMatches.length; idx += 1) { - savedRegExMatches[idx] = RegExp[`$${idx}`]; + if (awaitPromise && !err) { + let sigintListener; + pause(); + let promise = result; + if (self.breakEvalOnSigint) { + const interrupt = new Promise((resolve, reject) => { + sigintListener = () => { + reject(new Error('Script execution interrupted.')); + }; + prioritizedSigintQueue.add(sigintListener); + }); + promise = Promise.race([promise, interrupt]); + } + + promise.then((result) => { + // Remove prioritized SIGINT listener if it was not called. + // TODO(TimothyGu): Use Promise.prototype.finally when it becomes + // available. + prioritizedSigintQueue.delete(sigintListener); + + finishExecution(undefined, result); + unpause(); + }, (err) => { + // Remove prioritized SIGINT listener if it was not called. + prioritizedSigintQueue.delete(sigintListener); + + if (err.message === 'Script execution interrupted.') { + // The stack trace for this case is not very useful anyway. + Object.defineProperty(err, 'stack', { value: '' }); + } + + unpause(); + if (err && process.domain) { + debug('not recoverable, send to domain'); + process.domain.emit('error', err); + process.domain.exit(); + return; + } + finishExecution(err); + }); + } } - cb(err, result); + if (!awaitPromise || err) { + finishExecution(err, result); + } } self.eval = self._domain.bind(eval_); @@ -457,7 +521,15 @@ function REPLServer(prompt, var sawSIGINT = false; var sawCtrlD = false; + const prioritizedSigintQueue = new Set(); self.on('SIGINT', function onSigInt() { + if (prioritizedSigintQueue.size > 0) { + for (const task of prioritizedSigintQueue) { + task(); + } + return; + } + var empty = self.line.length === 0; self.clearLine(); _turnOffEditorMode(self); diff --git a/node.gyp b/node.gyp index 3fe0423d5e9763..1ced3ff5a5e936 100644 --- a/node.gyp +++ b/node.gyp @@ -118,6 +118,7 @@ 'lib/internal/process/write-coverage.js', 'lib/internal/readline.js', 'lib/internal/repl.js', + 'lib/internal/repl/await.js', 'lib/internal/socket_list.js', 'lib/internal/test/unicode.js', 'lib/internal/tls.js', diff --git a/test/parallel/test-repl-preprocess-top-level-await.js b/test/parallel/test-repl-preprocess-top-level-await.js new file mode 100644 index 00000000000000..2a173b7339b8c7 --- /dev/null +++ b/test/parallel/test-repl-preprocess-top-level-await.js @@ -0,0 +1,68 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { processTopLevelAwait } = require('internal/repl/await'); + +// Flags: --expose-internals + +// This test was created based on +// https://cs.chromium.org/chromium/src/third_party/WebKit/LayoutTests/http/tests/inspector-unit/preprocess-top-level-awaits.js?rcl=358caaba5e763e71c4abb9ada2d9cd8b1188cac9 + +const testCases = [ + [ '0', + null ], + [ 'await 0', + '(async () => { return (await 0) })()' ], + [ 'await 0;', + '(async () => { return (await 0); })()' ], + [ '(await 0)', + '(async () => { return ((await 0)) })()' ], + [ '(await 0);', + '(async () => { return ((await 0)); })()' ], + [ 'async function foo() { await 0; }', + null ], + [ 'async () => await 0', + null ], + [ 'class A { async method() { await 0 } }', + null ], + [ 'await 0; return 0;', + null ], + [ 'var a = await 1', + '(async () => { void (a = await 1) })()' ], + [ 'let a = await 1', + '(async () => { void (a = await 1) })()' ], + [ 'const a = await 1', + '(async () => { void (a = await 1) })()' ], + [ 'for (var i = 0; i < 1; ++i) { await i }', + '(async () => { for (void (i = 0); i < 1; ++i) { await i } })()' ], + [ 'for (let i = 0; i < 1; ++i) { await i }', + '(async () => { for (let i = 0; i < 1; ++i) { await i } })()' ], + [ 'var {a} = {a:1}, [b] = [1], {c:{d}} = {c:{d: await 1}}', + '(async () => { void ( ({a} = {a:1}), ([b] = [1]), ' + + '({c:{d}} = {c:{d: await 1}})) })()' ], + /* eslint-disable no-template-curly-in-string */ + [ 'console.log(`${(await { a: 1 }).a}`)', + '(async () => { return (console.log(`${(await { a: 1 }).a}`)) })()' ], + /* eslint-enable no-template-curly-in-string */ + [ 'await 0; function foo() {}', + '(async () => { await 0; foo=function foo() {} })()' ], + [ 'await 0; class Foo {}', + '(async () => { await 0; Foo=class Foo {} })()' ], + [ 'if (await true) { function foo() {} }', + '(async () => { if (await true) { foo=function foo() {} } })()' ], + [ 'if (await true) { class Foo{} }', + '(async () => { if (await true) { class Foo{} } })()' ], + [ 'if (await true) { var a = 1; }', + '(async () => { if (await true) { void (a = 1); } })()' ], + [ 'if (await true) { let a = 1; }', + '(async () => { if (await true) { let a = 1; } })()' ], + [ 'var a = await 1; let b = 2; const c = 3;', + '(async () => { void (a = await 1); void (b = 2); void (c = 3); })()' ], + [ 'let o = await 1, p', + '(async () => { void ( (o = await 1), (p=undefined)) })()' ] +]; + +for (const [input, expected] of testCases) { + assert.strictEqual(processTopLevelAwait(input), expected); +} diff --git a/test/parallel/test-repl-top-level-await.js b/test/parallel/test-repl-top-level-await.js new file mode 100644 index 00000000000000..2d92673692c227 --- /dev/null +++ b/test/parallel/test-repl-top-level-await.js @@ -0,0 +1,173 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { stripVTControlCharacters } = require('internal/readline'); +const repl = require('repl'); + +common.crashOnUnhandledRejection(); + +// Flags: --expose-internals + +const PROMPT = 'await repl > '; + +class REPLStream extends common.ArrayStream { + constructor() { + super(); + this.waitingForResponse = false; + this.lines = ['']; + } + write(chunk, encoding, callback) { + if (Buffer.isBuffer(chunk)) { + chunk = chunk.toString(encoding); + } + const chunkLines = stripVTControlCharacters(chunk).split('\n'); + this.lines[this.lines.length - 1] += chunkLines[0]; + if (chunkLines.length > 1) { + this.lines.push(...chunkLines.slice(1)); + } + this.emit('line'); + if (callback) callback(); + return true; + } + + wait(lookFor = PROMPT) { + if (this.waitingForResponse) { + throw new Error('Currently waiting for response to another command'); + } + this.lines = ['']; + return common.fires(new Promise((resolve, reject) => { + const onError = (err) => { + this.removeListener('line', onLine); + reject(err); + }; + const onLine = () => { + if (this.lines[this.lines.length - 1].includes(lookFor)) { + this.removeListener('error', onError); + this.removeListener('line', onLine); + resolve(this.lines); + } + }; + this.once('error', onError); + this.on('line', onLine); + }), new Error(), 1000); + } +} + +const putIn = new REPLStream(); +const testMe = repl.start({ + prompt: PROMPT, + stream: putIn, + terminal: true, + useColors: false, + breakEvalOnSigint: true +}); + +function runAndWait(cmds, lookFor) { + const promise = putIn.wait(lookFor); + for (const cmd of cmds) { + if (typeof cmd === 'string') { + putIn.run([cmd]); + } else { + testMe.write('', cmd); + } + } + return promise; +} + +async function ordinaryTests() { + // These tests were created based on + // https://cs.chromium.org/chromium/src/third_party/WebKit/LayoutTests/http/tests/devtools/console/console-top-level-await.js?rcl=5d0ea979f0ba87655b7ef0e03b58fa3c04986ba6 + putIn.run([ + 'function foo(x) { return x; }', + 'function koo() { return Promise.resolve(4); }' + ]); + const testCases = [ + [ 'await Promise.resolve(0)', '0' ], + [ '{ a: await Promise.resolve(1) }', '{ a: 1 }' ], + [ '_', '{ a: 1 }' ], + [ 'let { a, b } = await Promise.resolve({ a: 1, b: 2 }), f = 5;', + 'undefined' ], + [ 'a', '1' ], + [ 'b', '2' ], + [ 'f', '5' ], + [ 'let c = await Promise.resolve(2)', 'undefined' ], + [ 'c', '2' ], + [ 'let d;', 'undefined' ], + [ 'd', 'undefined' ], + [ 'let [i, { abc: { k } }] = [0, { abc: { k: 1 } }];', 'undefined' ], + [ 'i', '0' ], + [ 'k', '1' ], + [ 'var l = await Promise.resolve(2);', 'undefined' ], + [ 'l', '2' ], + [ 'foo(await koo())', '4' ], + [ '_', '4' ], + [ 'const m = foo(await koo());', 'undefined' ], + [ 'm', '4' ], + [ 'const n = foo(await\nkoo());', 'undefined' ], + [ 'n', '4' ], + // eslint-disable-next-line no-template-curly-in-string + [ '`status: ${(await Promise.resolve({ status: 200 })).status}`', + "'status: 200'"], + [ 'for (let i = 0; i < 2; ++i) await i', 'undefined' ], + [ 'for (let i = 0; i < 2; ++i) { await i }', 'undefined' ], + [ 'await 0', '0' ], + [ 'await 0; function foo() {}', 'undefined' ], + [ 'foo', '[Function: foo]' ], + [ 'class Foo {}; await 1;', '1' ], + [ 'Foo', '[Function: Foo]' ], + [ 'if (await true) { function bar() {}; }', 'undefined' ], + [ 'bar', '[Function: bar]' ], + [ 'if (await true) { class Bar {}; }', 'undefined' ], + [ 'Bar', 'ReferenceError: Bar is not defined', { line: 0 } ], + [ 'await 0; function* gen(){}', 'undefined' ], + [ 'for (var i = 0; i < 10; ++i) { await i; }', 'undefined' ], + [ 'i', '10' ], + [ 'for (let j = 0; j < 5; ++j) { await j; }', 'undefined' ], + [ 'j', 'ReferenceError: j is not defined', { line: 0 } ], + [ 'gen', '[GeneratorFunction: gen]' ], + [ 'return 42; await 5;', 'SyntaxError: Illegal return statement', + { line: 3 } ], + [ 'let o = await 1, p', 'undefined' ], + [ 'p', 'undefined' ], + [ 'let q = 1, s = await 2', 'undefined' ], + [ 's', '2' ] + ]; + + for (const [input, expected, options = {}] of testCases) { + console.log(`Testing ${input}`); + const toBeRun = input.split('\n'); + const lines = await runAndWait(toBeRun); + if ('line' in options) { + assert.strictEqual(lines[toBeRun.length + options.line], expected); + } else { + const echoed = toBeRun.map((a, i) => `${i > 0 ? '... ' : ''}${a}\r`); + assert.deepStrictEqual(lines, [...echoed, expected, PROMPT]); + } + } +} + +async function ctrlCTest() { + putIn.run([ + `const timeout = (msecs) => new Promise((resolve) => { + setTimeout(resolve, msecs).unref(); + });` + ]); + + console.log('Testing Ctrl+C'); + assert.deepStrictEqual(await runAndWait([ + 'await timeout(100000)', + { ctrl: true, name: 'c' } + ]), [ + 'await timeout(100000)\r', + 'Thrown: Error: Script execution interrupted.', + PROMPT + ]); +} + +async function main() { + await ordinaryTests(); + await ctrlCTest(); +} + +main();