diff --git a/lib/internal/assert/utils.js b/lib/internal/assert/utils.js index 13e41d67c63..cf7e45fe375 100644 --- a/lib/internal/assert/utils.js +++ b/lib/internal/assert/utils.js @@ -1,38 +1,21 @@ 'use strict'; const { - ArrayPrototypeShift, Error, ErrorCaptureStackTrace, - FunctionPrototypeBind, - RegExpPrototypeSymbolReplace, - SafeMap, StringPrototypeCharCodeAt, - StringPrototypeIncludes, StringPrototypeReplace, - StringPrototypeSlice, - StringPrototypeSplit, - StringPrototypeStartsWith, } = primordials; -const { Buffer } = require('buffer'); const { isErrorStackTraceLimitWritable, - overrideStackTrace, } = require('internal/errors'); const AssertionError = require('internal/assert/assertion_error'); -const { openSync, closeSync, readSync } = require('fs'); -const { EOL } = require('internal/constants'); -const { BuiltinModule } = require('internal/bootstrap/realm'); const { isError } = require('internal/util'); -const errorCache = new SafeMap(); -const { fileURLToPath } = require('internal/url'); - -let parseExpressionAt; -let findNodeAround; -let tokenizer; -let decoder; +const { + getErrorSourceExpression, +} = require('internal/errors/error_source'); // Escape control characters but not \n and \t to keep the line breaks and // indentation intact. @@ -50,111 +33,7 @@ const meta = [ const escapeFn = (str) => meta[StringPrototypeCharCodeAt(str, 0)]; -function findColumn(fd, column, code) { - if (code.length > column + 100) { - try { - return parseCode(code, column); - } catch { - // End recursion in case no code could be parsed. The expression should - // have been found after 2500 characters, so stop trying. - if (code.length - column > 2500) { - // eslint-disable-next-line no-throw-literal - throw null; - } - } - } - // Read up to 2500 bytes more than necessary in columns. That way we address - // multi byte characters and read enough data to parse the code. - const bytesToRead = column - code.length + 2500; - const buffer = Buffer.allocUnsafe(bytesToRead); - const bytesRead = readSync(fd, buffer, 0, bytesToRead); - code += decoder.write(buffer.slice(0, bytesRead)); - // EOF: fast path. - if (bytesRead < bytesToRead) { - return parseCode(code, column); - } - // Read potentially missing code. - return findColumn(fd, column, code); -} - -function getCode(fd, line, column) { - let bytesRead = 0; - if (line === 0) { - // Special handle line number one. This is more efficient and simplifies the - // rest of the algorithm. Read more than the regular column number in bytes - // to prevent multiple reads in case multi byte characters are used. - return findColumn(fd, column, ''); - } - let lines = 0; - // Prevent blocking the event loop by limiting the maximum amount of - // data that may be read. - let maxReads = 32; // bytesPerRead * maxReads = 512 KiB - const bytesPerRead = 16384; - // Use a single buffer up front that is reused until the call site is found. - let buffer = Buffer.allocUnsafe(bytesPerRead); - while (maxReads-- !== 0) { - // Only allocate a new buffer in case the needed line is found. All data - // before that can be discarded. - buffer = lines < line ? buffer : Buffer.allocUnsafe(bytesPerRead); - bytesRead = readSync(fd, buffer, 0, bytesPerRead); - // Read the buffer until the required code line is found. - for (let i = 0; i < bytesRead; i++) { - if (buffer[i] === 10 && ++lines === line) { - // If the end of file is reached, directly parse the code and return. - if (bytesRead < bytesPerRead) { - return parseCode(buffer.toString('utf8', i + 1, bytesRead), column); - } - // Check if the read code is sufficient or read more until the whole - // expression is read. Make sure multi byte characters are preserved - // properly by using the decoder. - const code = decoder.write(buffer.slice(i + 1, bytesRead)); - return findColumn(fd, column, code); - } - } - } -} - -function parseCode(code, offset) { - // Lazy load acorn. - if (parseExpressionAt === undefined) { - const Parser = require('internal/deps/acorn/acorn/dist/acorn').Parser; - ({ findNodeAround } = require('internal/deps/acorn/acorn-walk/dist/walk')); - - parseExpressionAt = FunctionPrototypeBind(Parser.parseExpressionAt, Parser); - tokenizer = FunctionPrototypeBind(Parser.tokenizer, Parser); - } - let node; - let start; - // Parse the read code until the correct expression is found. - for (const token of tokenizer(code, { ecmaVersion: 'latest' })) { - start = token.start; - if (start > offset) { - // No matching expression found. This could happen if the assert - // expression is bigger than the provided buffer. - break; - } - try { - node = parseExpressionAt(code, start, { ecmaVersion: 'latest' }); - // Find the CallExpression in the tree. - node = findNodeAround(node, offset, 'CallExpression'); - if (node?.node.end >= offset) { - return [ - node.node.start, - StringPrototypeReplace(StringPrototypeSlice(code, - node.node.start, node.node.end), - escapeSequencesRegExp, escapeFn), - ]; - } - // eslint-disable-next-line no-unused-vars - } catch (err) { - continue; - } - } - // eslint-disable-next-line no-throw-literal - throw null; -} - -function getErrMessage(message, fn) { +function getErrMessage(fn) { const tmpLimit = Error.stackTraceLimit; const errorStackTraceLimitIsWritable = isErrorStackTraceLimitWritable(); // Make sure the limit is set to 1. Otherwise it could fail (<= 0) or it @@ -166,85 +45,10 @@ function getErrMessage(message, fn) { ErrorCaptureStackTrace(err, fn); if (errorStackTraceLimitIsWritable) Error.stackTraceLimit = tmpLimit; - overrideStackTrace.set(err, (_, stack) => stack); - const call = err.stack[0]; - - let filename = call.getFileName(); - const line = call.getLineNumber() - 1; - let column = call.getColumnNumber() - 1; - let identifier; - - if (filename) { - identifier = `${filename}${line}${column}`; - - // Skip Node.js modules! - if (StringPrototypeStartsWith(filename, 'node:') && - BuiltinModule.exists(StringPrototypeSlice(filename, 5))) { - errorCache.set(identifier, undefined); - return; - } - } else { - return message; - } - - if (errorCache.has(identifier)) { - return errorCache.get(identifier); - } - - let fd; - try { - // Set the stack trace limit to zero. This makes sure unexpected token - // errors are handled faster. - if (errorStackTraceLimitIsWritable) Error.stackTraceLimit = 0; - - if (decoder === undefined) { - const { StringDecoder } = require('string_decoder'); - decoder = new StringDecoder('utf8'); - } - - // ESM file prop is a file proto. Convert that to path. - // This ensure opensync will not throw ENOENT for ESM files. - const fileProtoPrefix = 'file://'; - if (StringPrototypeStartsWith(filename, fileProtoPrefix)) { - filename = fileURLToPath(filename); - } - - fd = openSync(filename, 'r', 0o666); - // Reset column and message. - ({ 0: column, 1: message } = getCode(fd, line, column)); - // Flush unfinished multi byte characters. - decoder.end(); - - // Always normalize indentation, otherwise the message could look weird. - if (StringPrototypeIncludes(message, '\n')) { - if (EOL === '\r\n') { - message = RegExpPrototypeSymbolReplace(/\r\n/g, message, '\n'); - } - const frames = StringPrototypeSplit(message, '\n'); - message = ArrayPrototypeShift(frames); - for (let i = 0; i < frames.length; i++) { - const frame = frames[i]; - let pos = 0; - while (pos < column && (frame[pos] === ' ' || frame[pos] === '\t')) { - pos++; - } - message += `\n ${StringPrototypeSlice(frame, pos)}`; - } - } - message = `The expression evaluated to a falsy value:\n\n ${message}\n`; - // Make sure to always set the cache! No matter if the message is - // undefined or not - errorCache.set(identifier, message); - - return message; - } catch { - // Invalidate cache to prevent trying to read this part again. - errorCache.set(identifier, undefined); - } finally { - // Reset limit. - if (errorStackTraceLimitIsWritable) Error.stackTraceLimit = tmpLimit; - if (fd !== undefined) - closeSync(fd); + let source = getErrorSourceExpression(err); + if (source) { + source = StringPrototypeReplace(source, escapeSequencesRegExp, escapeFn); + return `The expression evaluated to a falsy value:\n\n ${source}\n`; } } @@ -257,7 +61,7 @@ function innerOk(fn, argLen, value, message) { message = 'No value argument passed to `assert.ok()`'; } else if (message == null) { generatedMessage = true; - message = getErrMessage(message, fn); + message = getErrMessage(fn); } else if (isError(message)) { throw message; } diff --git a/lib/internal/errors/error_source.js b/lib/internal/errors/error_source.js new file mode 100644 index 00000000000..9384d4a8361 --- /dev/null +++ b/lib/internal/errors/error_source.js @@ -0,0 +1,133 @@ +'use strict'; + +const { + FunctionPrototypeBind, + StringPrototypeSlice, +} = primordials; + +const { + getErrorSourcePositions, +} = internalBinding('errors'); + +/** + * Get the source location of an error. + * + * The `error.stack` must not have been accessed. The resolution is based on the structured + * error stack data. + * @param {Error|object} error An error object, or an object being invoked with ErrorCaptureStackTrace + * @returns {{sourceLine: string, startColumn: number}|undefined} + */ +function getErrorSourceLocation(error) { + const pos = getErrorSourcePositions(error); + const { + sourceLine, + startColumn, + } = pos; + + return { sourceLine, startColumn }; +} + +const memberAccessTokens = [ '.', '?.', '[', ']' ]; +const memberNameTokens = [ 'name', 'string', 'num' ]; +let tokenizer; +/** + * Get the first expression in a code string at the startColumn. + * @param {string} code source code line + * @param {number} startColumn which column the error is constructed + * @returns {string} + */ +function getFirstExpression(code, startColumn) { + // Lazy load acorn. + if (tokenizer === undefined) { + const Parser = require('internal/deps/acorn/acorn/dist/acorn').Parser; + tokenizer = FunctionPrototypeBind(Parser.tokenizer, Parser); + } + + let lastToken; + let firstMemberAccessNameToken; + let terminatingCol; + let parenLvl = 0; + // Tokenize the line to locate the expression at the startColumn. + // The source line may be an incomplete JavaScript source, so do not parse the source line. + for (const token of tokenizer(code, { ecmaVersion: 'latest' })) { + // Peek before the startColumn. + if (token.start < startColumn) { + // There is a semicolon. This is a statement before the startColumn, so reset the memo. + if (token.type.label === ';') { + firstMemberAccessNameToken = null; + continue; + } + // Try to memo the member access expressions before the startColumn, so that the + // returned source code contains more info: + // assert.ok(value) + // ^ startColumn + // The member expression can also be like + // assert['ok'](value) or assert?.ok(value) + // ^ startColumn ^ startColumn + if (memberAccessTokens.includes(token.type.label) && lastToken?.type.label === 'name') { + // First member access name token must be a 'name'. + firstMemberAccessNameToken ??= lastToken; + } else if (!memberAccessTokens.includes(token.type.label) && + !memberNameTokens.includes(token.type.label)) { + // Reset the memo if it is not a simple member access. + // For example: assert[(() => 'ok')()](value) + // ^ startColumn + firstMemberAccessNameToken = null; + } + lastToken = token; + continue; + } + // Now after the startColumn, this must be an expression. + if (token.type.label === '(') { + parenLvl++; + continue; + } + if (token.type.label === ')') { + parenLvl--; + if (parenLvl === 0) { + // A matched closing parenthesis found after the startColumn, + // terminate here. Include the token. + // (assert.ok(false), assert.ok(true)) + // ^ startColumn + terminatingCol = token.start + 1; + break; + } + continue; + } + if (token.type.label === ';') { + // A semicolon found after the startColumn, terminate here. + // assert.ok(false); assert.ok(true)); + // ^ startColumn + terminatingCol = token; + break; + } + // If no semicolon found after the startColumn. The string after the + // startColumn must be the expression. + // assert.ok(false) + // ^ startColumn + } + const start = firstMemberAccessNameToken?.start ?? startColumn; + return StringPrototypeSlice(code, start, terminatingCol); +} + +/** + * Get the source expression of an error. + * + * The `error.stack` must not have been accessed, or the source location may be incorrect. The + * resolution is based on the structured error stack data. + * @param {Error|object} error An error object, or an object being invoked with ErrorCaptureStackTrace + * @returns {string|undefined} + */ +function getErrorSourceExpression(error) { + const loc = getErrorSourceLocation(error); + if (loc === undefined) { + return; + } + const { sourceLine, startColumn } = loc; + return getFirstExpression(sourceLine, startColumn); +} + +module.exports = { + getErrorSourceLocation, + getErrorSourceExpression, +}; diff --git a/src/node_errors.cc b/src/node_errors.cc index 5f51add4cdf..ae8553ee202 100644 --- a/src/node_errors.cc +++ b/src/node_errors.cc @@ -1041,6 +1041,46 @@ void PerIsolateMessageListener(Local message, Local error) { } } +void GetErrorSourcePositions(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + HandleScope scope(isolate); + Realm* realm = Realm::GetCurrent(args); + Local context = realm->context(); + + CHECK(args[0]->IsObject()); + + Local msg = Exception::CreateMessage(isolate, args[0]); + + // Message::GetEndColumn may not reflect the actual end column in all cases. + // So only expose startColumn to JS land. + Local names[] = { + OneByteString(isolate, "sourceLine"), + OneByteString(isolate, "scriptResourceName"), + OneByteString(isolate, "lineNumber"), + OneByteString(isolate, "startColumn"), + }; + + Local source_line; + if (!msg->GetSourceLine(context).ToLocal(&source_line)) { + return; + } + int line_number; + if (!msg->GetLineNumber(context).To(&line_number)) { + return; + } + + Local values[] = { + source_line, + msg->GetScriptOrigin().ResourceName(), + v8::Integer::New(isolate, line_number), + v8::Integer::New(isolate, msg->GetStartColumn()), + }; + Local info = + Object::New(isolate, v8::Null(isolate), names, values, arraysize(names)); + + args.GetReturnValue().Set(info); +} + void SetPrepareStackTraceCallback(const FunctionCallbackInfo& args) { Realm* realm = Realm::GetCurrent(args); CHECK(args[0]->IsFunction()); @@ -1106,6 +1146,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(SetEnhanceStackForFatalException); registry->Register(NoSideEffectsToString); registry->Register(TriggerUncaughtException); + registry->Register(GetErrorSourcePositions); } void Initialize(Local target, @@ -1133,6 +1174,8 @@ void Initialize(Local target, context, target, "noSideEffectsToString", NoSideEffectsToString); SetMethod( context, target, "triggerUncaughtException", TriggerUncaughtException); + SetMethod( + context, target, "getErrorSourcePositions", GetErrorSourcePositions); Isolate* isolate = context->GetIsolate(); Local exit_codes = Object::New(isolate); diff --git a/test/parallel/test-assert-builtins-not-read-from-filesystem.js b/test/parallel/test-assert-builtins-not-read-from-filesystem.js deleted file mode 100644 index 7a713a2ea43..00000000000 --- a/test/parallel/test-assert-builtins-not-read-from-filesystem.js +++ /dev/null @@ -1,48 +0,0 @@ -'use strict'; - -// Do not read filesystem when creating AssertionError messages for code in -// builtin modules. - -require('../common'); -const assert = require('assert'); -const EventEmitter = require('events'); -const e = new EventEmitter(); -e.on('hello', assert); - -if (process.argv[2] !== 'child') { - const tmpdir = require('../common/tmpdir'); - tmpdir.refresh(); - const { spawnSync } = require('child_process'); - - let threw = false; - try { - e.emit('hello', false); - } catch (err) { - const frames = err.stack.split('\n'); - const [, filename, line, column] = frames[1].match(/\((.+):(\d+):(\d+)\)/); - // Spawn a child process to avoid the error having been cached in the assert - // module's `errorCache` Map. - - const { output, status, error } = - spawnSync(process.execPath, - [process.argv[1], 'child', filename, line, column], - { cwd: tmpdir.path, env: process.env }); - assert.ifError(error); - assert.strictEqual(status, 0, `Exit code: ${status}\n${output}`); - threw = true; - } - assert.ok(threw); -} else { - const { writeFileSync } = require('fs'); - const [, , , filename, line, column] = process.argv; - const data = `${'\n'.repeat(line - 1)}${' '.repeat(column - 1)}` + - 'ok(failed(badly));'; - - writeFileSync(filename, data); - assert.throws( - () => e.emit('hello', false), - { - message: 'false == true' - } - ); -} diff --git a/test/parallel/test-assert.js b/test/parallel/test-assert.js index 0c3571a80c1..916bbf15654 100644 --- a/test/parallel/test-assert.js +++ b/test/parallel/test-assert.js @@ -624,7 +624,7 @@ test('Test strict assert', () => { code: 'ERR_ASSERTION', constructor: strict.AssertionError, message: 'The expression evaluated to a falsy value:\n\n ' + - "strict.ok(\n typeof 123 === 'string'\n )\n" + 'strict.ok(\n' } ); Error.stackTraceLimit = tmpLimit; @@ -1017,20 +1017,20 @@ test('Additional asserts', () => { } ); - // Works in eval. + // Works in eval. Source code in eval can be shown. assert.throws( () => new Function('assert', 'assert(1 === 2);')(assert), { code: 'ERR_ASSERTION', constructor: assert.AssertionError, - message: 'false == true' + message: 'The expression evaluated to a falsy value:\n\n assert(1 === 2)\n' } ); assert.throws( () => eval('console.log("FOO");\nassert.ok(1 === 2);'), { code: 'ERR_ASSERTION', - message: 'false == true' + message: 'The expression evaluated to a falsy value:\n\n assert.ok(1 === 2)\n' } );