diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 37b865597e5d25..c63475dc60f5d2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -52,6 +52,8 @@ updates: semver-major-days: 5 semver-minor-days: 5 semver-patch-days: 5 + exclude: + - '@node-core/doc-kit' commit-message: prefix: tools open-pull-requests-limit: 10 diff --git a/benchmark/ffi/getpid.js b/benchmark/ffi/getpid.js new file mode 100644 index 00000000000000..c2e7d4ed89c34e --- /dev/null +++ b/benchmark/ffi/getpid.js @@ -0,0 +1,25 @@ +'use strict'; + +const common = require('../common.js'); +const ffi = require('node:ffi'); + +const bench = common.createBenchmark(main, { + n: [1e7], +}, { + flags: ['--experimental-ffi'], +}); + +const { lib, functions } = ffi.dlopen(null, { + uv_os_getpid: { result: 'i32', parameters: [] }, +}); + +const getpid = functions.uv_os_getpid; + +function main({ n }) { + bench.start(); + for (let i = 0; i < n; ++i) + getpid(); + bench.end(n); + + lib.close(); +} diff --git a/common.gypi b/common.gypi index b9baa1f35ed5e1..56f97a39497b5a 100644 --- a/common.gypi +++ b/common.gypi @@ -15,6 +15,7 @@ 'python%': 'python', 'node_shared%': 'false', + 'node_enable_experimentals%': 'false', 'force_dynamic_crt%': 0, 'node_use_v8_platform%': 'true', 'node_use_bundled_v8%': 'true', @@ -437,6 +438,9 @@ }], # The defines bellow must include all things from the external_v8_defines # list in v8/BUILD.gn. + ['node_enable_experimentals == "true"', { + 'defines': ['EXPERIMENTALS_DEFAULT_VALUE=true'], + }], ['v8_enable_v8_checks == 1', { 'defines': ['V8_ENABLE_CHECKS'], }], diff --git a/configure.py b/configure.py index 5e31fa03558819..f78f6eb65b31ec 100755 --- a/configure.py +++ b/configure.py @@ -797,6 +797,12 @@ default=None, help='Enable the --trace-maps flag in V8 (use at your own risk)') +parser.add_argument('--enable-all-experimentals', + action='store_true', + dest='enable_all_experimentals', + default=None, + help='Enable all experimental features by default') + parser.add_argument('--experimental-enable-pointer-compression', action='store_true', dest='enable_pointer_compression', @@ -1803,6 +1809,7 @@ def configure_node_cctest_sources(o): def configure_node(o): if options.dest_os == 'android': o['variables']['OS'] = 'android' + o['variables']['node_enable_experimentals'] = b(options.enable_all_experimentals) o['variables']['node_prefix'] = options.prefix o['variables']['node_install_npm'] = b(not options.without_npm) o['variables']['node_install_corepack'] = b(options.with_corepack) diff --git a/doc/api/debugger.md b/doc/api/debugger.md index ce25c927d8ef81..5a56667a18df8c 100644 --- a/doc/api/debugger.md +++ b/doc/api/debugger.md @@ -90,6 +90,157 @@ steps to the next line. Type `help` to see what other commands are available. Pressing `enter` without typing a command will repeat the previous debugger command. +## Probe mode + + + +> Stability: 1 - Experimental + +`node inspect` supports a non-interactive probe mode for inspecting runtime values +in an application via the flag `--probe`. Probe mode launches the application, +sets one or more source breakpoints, evaluates one expression whenever a +matching breakpoint is hit, and prints one final report when the session ends +(either on normal completion or timeout). This allows developers to perform +printf-style debugging without having to modify the application code and +clean up afterwards, and it supports structured output for tool use. + +```console +$ node inspect [--json] [--preview] [--timeout=] [--port=] \ + --probe app.js:10 --expr 'x' \ + [--probe app.js:20 --expr 'y' ...] \ + [--] [ ...] [args...] +``` + +* `--probe :[:]`: Source location to probe. Line and column number + are 1-based. +* `--timeout=`: A global wall-clock deadline for the entire probe session. + The default is `30000`. This can be used to probe a long-running application + that can be terminated externally. +* `--json`: If used, prints a structured JSON report instead of the default text report. +* `--preview`: If used, non-primitive values will include CDP property previews for + object-like JSON probe values. +* `--port=`: Selects the local inspector port used for the `--inspect-brk` + launch path. Probe mode defaults to `0`, which requests a random port. +* `--` is optional unless the child needs its own Node.js flags. + +Additional rules about the `--probe` and `--expr` arguments: + +* `--probe :[:]` and `--expr ` are strict pairs. Each + `--probe` must be followed immediately by exactly one `--expr`. +* `--timeout`, `--json`, `--preview`, and `--port` are global probe options + for the whole probe session. They may appear before or between probe pairs, + but not between a `--probe` and its matching `--expr`. + +If a single probe needs to evaluate more than one value, +evaluate a structured value in `--expr`, for example `--expr "{ foo, bar }"` +or `--expr "[foo, bar]"`, and use `--preview` to include property previews for +any object-like values in the output. + +Probe mode only prints the final probe report to stdout, and otherwise silences +stdout/stderr from the child process. If the child exits with an error after the +probe session starts, the final report records a terminal `error` event with the +exit code and captured child stderr. Invalid arguments and fatal launch or +connect failures may still print diagnostics to stderr without a final probe +result. + +Consider this script: + +```js +// cli.js +let maxRSS = 0; +for (let i = 0; i < 2; i++) { + const { rss } = process.memoryUsage(); + maxRSS = Math.max(maxRSS, rss); +} +``` + +If `--json` is not used, the output is printed in a human-readable text format: + +```console +$ node inspect --probe cli.js:5 --expr 'rss' cli.js +Hit 1 at cli.js:5 + rss = 54935552 +Hit 2 at cli.js:5 + rss = 55083008 +Completed +``` + +Primitive results are printed directly, while objects and arrays use Chrome +DevTools Protocol preview data when available. Other non-primitive values +fall back to the Chrome DevTools Protocol `description` string. +Expression failures are recorded as `[error] ...` lines and do not fail +the overall session. If richer text formatting is needed, wrap the expression +in `JSON.stringify(...)` or `util.inspect(...)`. + +When `--json` is used, the output shape looks like this: + +```console +$ node inspect --json --probe cli.js:5 --expr 'rss' cli.js +{"v":1,"probes":[{"expr":"rss","target":["cli.js",5]}],"results":[{"probe":0,"event":"hit","hit":1,"result":{"type":"number","value":55443456,"description":"55443456"}},{"probe":0,"event":"hit","hit":2,"result":{"type":"number","value":55574528,"description":"55574528"}},{"event":"completed"}]} +``` + +```json +{ + "v": 1, // Probe JSON schema version. + "probes": [ + { + "expr": "rss", // The expression paired with --probe. + "target": ["cli.js", 5] // [file, line] or [file, line, col]. + } + ], + "results": [ + { + "probe": 0, // Index into probes[]. + "event": "hit", // Hit events are recorded in observation order. + "hit": 1, // 1-based hit count for this probe. + "result": { + "type": "number", + "value": 55443456, + "description": "55443456" + } + // If the expression throws, "error" is present instead of "result". + }, + { + "probe": 0, + "event": "hit", + "hit": 2, + "result": { + "type": "number", + "value": 55574528, + "description": "55574528" + } + }, + { + "event": "completed" + // The final entry is always a terminal event, for example: + // 1. { "event": "completed" } + // 2. { "event": "miss", "pending": [0, 1] } + // 3. { + // "event": "timeout", + // "pending": [0], + // "error": { + // "code": "probe_timeout", + // "message": "Timed out after 30000ms waiting for probes: app.js:10" + // } + // } + // 4. { + // "event": "error", + // "pending": [0], + // "error": { + // "code": "probe_target_exit", + // "exitCode": 1, + // "stderr": "[Error: boom]", + // "message": "Target exited with code 1 before probes: app.js:10" + // } + // } + } + ] +} +``` + ## Watchers It is possible to watch expression and variable values while debugging. On diff --git a/doc/api/errors.md b/doc/api/errors.md index 00c8af84d470a1..540e8122b9f876 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -1332,6 +1332,24 @@ added: v14.0.0 Used when a feature that is not available to the current platform which is running Node.js is used. + + +### `ERR_FFI_CALL_FAILED` + +A low-level FFI call failed. + + + +### `ERR_FFI_INVALID_POINTER` + +An invalid pointer was passed to an FFI operation. + + + +### `ERR_FFI_LIBRARY_CLOSED` + +An operation was attempted on an FFI dynamic library after it was closed. + ### `ERR_FS_CP_DIR_TO_NON_DIR` diff --git a/doc/api/ffi.md b/doc/api/ffi.md index 33eef15106124f..6f458e8a7b2f38 100644 --- a/doc/api/ffi.md +++ b/doc/api/ffi.md @@ -165,12 +165,15 @@ const path = `libsqlite3.${suffix}`; added: REPLACEME --> -* `path` {string} Path to a dynamic library. +* `path` {string|null} Path to a dynamic library, or `null` to resolve symbols + from the current process image. * `definitions` {Object} Symbol definitions to resolve immediately. * Returns: {Object} Loads a dynamic library and resolves the requested function definitions. +On Windows passing `null` is not supported. + When `definitions` is omitted, `functions` is returned as an empty object until symbols are resolved explicitly. @@ -237,10 +240,13 @@ Represents a loaded dynamic library. ### `new DynamicLibrary(path)` -* `path` {string} Path to a dynamic library. +* `path` {string|null} Path to a dynamic library, or `null` to resolve symbols + from the current process image. Loads the dynamic library without resolving any functions eagerly. +On Windows passing `null` is not supported. + ```cjs const { DynamicLibrary } = require('node:ffi'); @@ -603,6 +609,55 @@ available storage. This function does not allocate memory on its own. `buffer` must be a Node.js `Buffer`. +## `ffi.exportArrayBuffer(arrayBuffer, pointer, length)` + + + +* `arrayBuffer` {ArrayBuffer} +* `pointer` {bigint} +* `length` {number} + +Copies bytes from an `ArrayBuffer` into native memory. + +`length` must be at least `arrayBuffer.byteLength`. + +`pointer` must refer to writable native memory with at least `length` bytes of +available storage. This function does not allocate memory on its own. + +## `ffi.exportArrayBufferView(arrayBufferView, pointer, length)` + + + +* `arrayBufferView` {ArrayBufferView} +* `pointer` {bigint} +* `length` {number} + +Copies bytes from an `ArrayBufferView` into native memory. + +`length` must be at least `arrayBufferView.byteLength`. + +`pointer` must refer to writable native memory with at least `length` bytes of +available storage. This function does not allocate memory on its own. + +## `ffi.getRawPointer(source)` + + + +* `source` {Buffer|ArrayBuffer|ArrayBufferView} +* Returns: {bigint} + +Returns the raw memory address of JavaScript-managed byte storage. + +This is unsafe and dangerous. The returned pointer can become invalid if the +underlying memory is detached, resized, transferred, or otherwise invalidated. +Using stale pointers can cause memory corruption or process crashes. + ## Safety notes The `node:ffi` module does not track pointer validity, memory ownership, or diff --git a/lib/ffi.js b/lib/ffi.js index 944a01330d6ba0..b276f4b29dfcdc 100644 --- a/lib/ffi.js +++ b/lib/ffi.js @@ -2,9 +2,13 @@ const { ObjectFreeze, + ObjectPrototypeToString, } = primordials; const { Buffer } = require('buffer'); const { emitExperimentalWarning } = require('internal/util'); +const { + isArrayBufferView, +} = require('internal/util/types'); const { codes: { ERR_ACCESS_DENIED, @@ -32,6 +36,8 @@ const { getUint64, getFloat32, getFloat64, + exportBytes, + getRawPointer, setInt8, setUint8, setInt16, @@ -114,21 +120,52 @@ function exportString(str, data, len, encoding = 'utf8') { targetBuffer.fill(0, dataLength, dataLength + terminatorSize); } -function exportBuffer(buffer, data, len) { +function exportBuffer(source, data, len) { checkFFIPermission(); - if (!Buffer.isBuffer(buffer)) { - throw new ERR_INVALID_ARG_TYPE('buffer', 'Buffer', buffer); + if (!Buffer.isBuffer(source)) { + throw new ERR_INVALID_ARG_TYPE('buffer', 'Buffer', source); } validateInteger(len, 'len', 0); - if (len < buffer.length) { - throw new ERR_OUT_OF_RANGE('len', `>= ${buffer.length}`, len); + if (len < source.length) { + throw new ERR_OUT_OF_RANGE('len', `>= ${source.length}`, len); } - const targetBuffer = toBuffer(data, len, false); - buffer.copy(targetBuffer, 0, 0, buffer.length); + exportBytes(source, data, len); +} + +function exportArrayBuffer(source, data, len) { + checkFFIPermission(); + + if (ObjectPrototypeToString(source) !== '[object ArrayBuffer]') { + throw new ERR_INVALID_ARG_TYPE('arrayBuffer', 'ArrayBuffer', source); + } + + validateInteger(len, 'len', 0); + + if (len < source.byteLength) { + throw new ERR_OUT_OF_RANGE('len', `>= ${source.byteLength}`, len); + } + + exportBytes(source, data, len); +} + +function exportArrayBufferView(source, data, len) { + checkFFIPermission(); + + if (!isArrayBufferView(source)) { + throw new ERR_INVALID_ARG_TYPE('arrayBufferView', 'ArrayBufferView', source); + } + + validateInteger(len, 'len', 0); + + if (len < source.byteLength) { + throw new ERR_OUT_OF_RANGE('len', `>= ${source.byteLength}`, len); + } + + exportBytes(source, data, len); } const suffix = process.platform === 'win32' ? 'dll' : process.platform === 'darwin' ? 'dylib' : 'so'; @@ -163,6 +200,8 @@ module.exports = { dlopen, dlclose, dlsym, + exportArrayBuffer, + exportArrayBufferView, exportString, exportBuffer, getInt8, @@ -175,6 +214,7 @@ module.exports = { getUint64, getFloat32, getFloat64, + getRawPointer, setInt8, setUint8, setInt16, diff --git a/lib/internal/debugger/inspect.js b/lib/internal/debugger/inspect.js index 6a763b7770c0ed..259156dce21d85 100644 --- a/lib/internal/debugger/inspect.js +++ b/lib/internal/debugger/inspect.js @@ -1,28 +1,41 @@ 'use strict'; const { + ArrayFrom, + ArrayIsArray, ArrayPrototypeForEach, ArrayPrototypeJoin, ArrayPrototypeMap, ArrayPrototypePop, + ArrayPrototypePush, ArrayPrototypePushApply, ArrayPrototypeShift, ArrayPrototypeSlice, FunctionPrototypeBind, + JSONStringify, Number, + NumberIsNaN, + NumberParseInt, + ObjectEntries, Promise, PromisePrototypeThen, PromiseResolve, Proxy, RegExpPrototypeExec, RegExpPrototypeSymbolSplit, + SafeMap, + SafeSet, StringPrototypeEndsWith, + StringPrototypeIncludes, + StringPrototypeSlice, StringPrototypeSplit, + StringPrototypeStartsWith, } = primordials; const { spawn } = require('child_process'); const { EventEmitter } = require('events'); const net = require('net'); +const { clearTimeout, setTimeout } = require('timers'); const util = require('util'); const { setInterval: pSetInterval, @@ -31,6 +44,7 @@ const { const { AbortController, } = require('internal/abort_controller'); +const { SideEffectFreeRegExpPrototypeSymbolReplace } = require('internal/util'); const InspectClient = require('internal/debugger/inspect_client'); const createRepl = require('internal/debugger/inspect_repl'); @@ -46,6 +60,23 @@ const { }, } = internalBinding('errors'); +const kProbeDefaultTimeout = 30000; +const kProbeVersion = 1; +const kProbeDisconnectSentinel = 'Waiting for the debugger to disconnect...'; +const kDigitsRegex = /^\d+$/; +const kInspectPortRegex = /^--inspect-port=(\d+)$/; +const kProbeArgOptions = { + __proto__: null, + expr: { type: 'string' }, + json: { type: 'boolean' }, + // Port and timeout use type 'string' because parseArgs has no + // numeric type; the values are parsed to integers in parseProbeArgv(). + port: { type: 'string' }, + preview: { type: 'boolean' }, + probe: { type: 'string' }, + timeout: { type: 'string' }, +}; + async function portIsFree(host, port, timeout = 3000) { if (port === 0) return; // Binding to a random port. @@ -77,34 +108,769 @@ async function portIsFree(host, port, timeout = 3000) { } const debugRegex = /Debugger listening on ws:\/\/\[?(.+?)\]?:(\d+)\//; -async function runScript(script, scriptArgs, inspectHost, inspectPort, - childPrint) { - await portIsFree(inspectHost, inspectPort); - const args = [`--inspect-brk=${inspectPort}`, script]; - ArrayPrototypePushApply(args, scriptArgs); + +function getInspectUsage(invokedAs) { + return `Usage: ${invokedAs} script.js\n` + + ` ${invokedAs} :\n` + + ` ${invokedAs} --port= Use 0 for random port assignment\n` + + ` ${invokedAs} -p \n` + + ` ${invokedAs} [--json] [--timeout=] [--port=] ` + + `--probe :[:] --expr ` + + `[--probe :[:] --expr ...] ` + + `[--] [ ...] [args...]\n`; +} + +function writeUsageAndExit(invokedAs, message, exitCode = kInvalidCommandLineArgument) { + if (message) { + process.stderr.write(`${message}\n`); + } + process.stderr.write(getInspectUsage(invokedAs)); + process.exit(exitCode); +} + +function ensureTrailingNewline(text) { + return StringPrototypeEndsWith(text, '\n') ? text : `${text}\n`; +} + +function parseUnsignedInteger(value, name, allowZero = false) { + if (typeof value !== 'string' || RegExpPrototypeExec(kDigitsRegex, value) === null) { + throw new ERR_DEBUGGER_STARTUP_ERROR(`Invalid ${name}: ${value}`); + } + const parsed = NumberParseInt(value, 10); + if (NumberIsNaN(parsed) || (!allowZero && parsed < 1)) { + throw new ERR_DEBUGGER_STARTUP_ERROR(`Invalid ${name}: ${value}`); + } + return parsed; +} + +// Accepts file:line or file:line:column formats. +// Non-greedy (.+?) allows Windows drive-letter paths like C:\foo.js:10. +function parseProbeLocation(text) { + const match = RegExpPrototypeExec(/^(.+?):(\d+)(?::(\d+))?$/, text); + if (match === null) { + throw new ERR_DEBUGGER_STARTUP_ERROR(`Invalid probe location: ${text}`); + } + + const file = match[1]; + const line = parseUnsignedInteger(match[2], 'probe location'); + const column = match[3] !== undefined ? + parseUnsignedInteger(match[3], 'probe location') : undefined; + const target = column === undefined ? [file, line] : [file, line, column]; + + return { + file, + lineNumber: line - 1, + columnNumber: column === undefined ? undefined : column - 1, + target, + }; +} + +function formatPendingProbeLocations(probes, pending) { + const seen = new SafeSet(); + for (const probeIndex of pending) { + seen.add(ArrayPrototypeJoin(probes[probeIndex].target, ':')); + } + return ArrayPrototypeJoin(ArrayFrom(seen), ', '); +} + +function formatTargetExitMessage(probes, pending, exitCode, signal) { + const status = signal === null ? + `Target exited with code ${exitCode}` : + `Target exited with signal ${signal}`; + if (pending.length === 0) { + return `${status} before target completion`; + } + return `${status} before probes: ${formatPendingProbeLocations(probes, pending)}`; +} + +// Trim the "Waiting for the debugger to disconnect..." message from stderr for reporting child errors. +function trimProbeChildStderr(stderr) { + const lines = RegExpPrototypeSymbolSplit(/\r\n|\r|\n/g, stderr); + const kept = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === '' && i === lines.length - 1) { continue; } + if (line === kProbeDisconnectSentinel) { continue; } + ArrayPrototypePush(kept, line); + } + return ArrayPrototypeJoin(kept, '\n'); +} + +function formatPreviewPropertyValue(property) { + if (property.type === 'string') { + return JSONStringify(property.value ?? ''); + } + return property.value ?? property.type; +} + +function trimRemoteObject(result) { + if (result === undefined || result === null || typeof result !== 'object') { + return result; + } + + if (ArrayIsArray(result)) { + return ArrayPrototypeMap(result, trimRemoteObject); + } + + const trimmed = { __proto__: null }; + for (const { 0: key, 1: value } of ObjectEntries(result)) { + if (key === 'objectId' || key === 'className') { + continue; + } + trimmed[key] = trimRemoteObject(value); + } + return trimmed; +} + +function stripProbePreviews(value) { + if (value === undefined || value === null || typeof value !== 'object') { + return value; + } + + if (ArrayIsArray(value)) { + return ArrayPrototypeMap(value, stripProbePreviews); + } + + const stripped = { __proto__: null }; + for (const { 0: key, 1: entry } of ObjectEntries(value)) { + if (key === 'preview') { + continue; + } + stripped[key] = stripProbePreviews(entry); + } + return stripped; +} + +// Format CDP RemoteObject values into more readable formats. +function formatRemoteObject(result) { + if (result === undefined) { return 'undefined'; } + + switch (result.type) { + case 'undefined': + return 'undefined'; + case 'string': + return JSONStringify(result.value); + case 'number': + if (result.unserializableValue !== undefined) { + return result.unserializableValue; + } + return `${result.value}`; + case 'boolean': + return `${result.value}`; + case 'symbol': + return result.description || 'Symbol()'; + case 'bigint': + return result.unserializableValue ?? result.description ?? '0n'; + case 'function': + return result.description || 'function()'; + case 'object': + if (result.subtype === 'null') { + return 'null'; + } + if (result.subtype === 'error') { + return result.description || 'Error'; + } + if (result.preview !== undefined) { + const properties = ArrayPrototypeJoin(ArrayPrototypeMap( + result.preview.properties, + result.preview.subtype === 'array' ? + (property) => formatPreviewPropertyValue(property) : + (property) => `${property.name}: ${formatPreviewPropertyValue(property)}`, + ), ', '); + const suffix = result.preview.overflow ? ', ...' : ''; + if (result.preview.subtype === 'array') { + return `[${properties}${suffix}]`; + } + return `{${properties}${suffix}}`; + } + return result.description || result.className || 'Object'; + default: + return `${result.value ?? result.description ?? ''}`; + } +} + +// Built human-readable text output for probe reports. +function buildProbeTextReport(report) { + const lines = []; + + for (const result of report.results) { + if (result.event === 'hit') { + const probe = report.probes[result.probe]; + const location = ArrayPrototypeJoin(probe.target, ':'); + ArrayPrototypePush(lines, `Hit ${result.hit} at ${location}`); + if (result.error !== undefined) { + ArrayPrototypePush(lines, + ` [error] ${probe.expr} = ` + + `${formatRemoteObject(result.error)}`); + } else { + ArrayPrototypePush(lines, + ` ${probe.expr} = ` + + `${formatRemoteObject(result.result)}`); + } + continue; + } + + if (result.event === 'completed') { + ArrayPrototypePush(lines, 'Completed'); + continue; + } + + if (result.event === 'miss') { + ArrayPrototypePush(lines, + `Missed probes: ` + + `${formatPendingProbeLocations(report.probes, result.pending)}`); + continue; + } + + if (result.event === 'timeout') { + ArrayPrototypePush(lines, result.error.message); + continue; + } + + if (result.event === 'error') { + ArrayPrototypePush(lines, result.error.message); + if (result.error.stderr !== undefined) { + ArrayPrototypePush(lines, ' [stderr]'); + const stderrLines = RegExpPrototypeSymbolSplit( + /\r\n|\r|\n/g, + result.error.stderr, + ); + for (let i = 0; i < stderrLines.length; i++) { + if (stderrLines[i] === '' && i === stderrLines.length - 1) { + continue; + } + ArrayPrototypePush(lines, ` ${stderrLines[i]}`); + } + } + } + } + + return ensureTrailingNewline(ArrayPrototypeJoin(lines, '\n')); +} + +function hasTopLevelProbeOption(args) { + const { tokens } = util.parseArgs({ + args, + allowPositionals: true, + options: kProbeArgOptions, + strict: false, + tokens: true, + }); + + for (const token of tokens) { + if (token.kind === 'option' && token.name === 'probe') { + return true; + } + + if (token.kind === 'option-terminator' || token.kind === 'positional') { + return false; + } + } + + return false; +} + +function parseProbeArgv(args) { + let port = 0; + let preview = false; + let timeout = kProbeDefaultTimeout; + let json = false; + let sawSeparator = false; + let childStartIndex = args.length; + let pendingLocation; + let expectedExprIndex = -1; + const probes = []; + + const { tokens } = util.parseArgs({ + args, + allowPositionals: true, + options: kProbeArgOptions, + strict: false, + tokens: true, + }); + + for (const token of tokens) { + if (token.kind === 'option-terminator') { + sawSeparator = true; + childStartIndex = token.index + 1; + break; + } + + if (pendingLocation !== undefined) { + if (token.kind === 'option' && + token.name === 'expr' && + token.index === expectedExprIndex && + token.value !== undefined) { + ArrayPrototypePush(probes, { + expr: token.value, + location: pendingLocation, + }); + pendingLocation = undefined; + continue; + } + + throw new ERR_DEBUGGER_STARTUP_ERROR( + 'Each --probe must be followed immediately by --expr '); + } + + if (token.kind === 'positional') { + childStartIndex = token.index; + break; + } + + switch (token.name) { + case 'json': + json = true; + break; + case 'timeout': + if (token.value === undefined) { + throw new ERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`); + } + timeout = parseUnsignedInteger(token.value, 'timeout', true); + break; + case 'port': + if (token.value === undefined) { + throw new ERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`); + } + port = parseUnsignedInteger(token.value, 'inspector port', true); + break; + case 'preview': + preview = true; + break; + case 'probe': + pendingLocation = parseProbeLocation(token.value); + expectedExprIndex = token.index + (token.inlineValue ? 1 : 2); + break; + case 'expr': + throw new ERR_DEBUGGER_STARTUP_ERROR('Unexpected --expr before --probe'); + default: + if (probes.length > 0) { + throw new ERR_DEBUGGER_STARTUP_ERROR( + 'Use -- before child Node.js flags in probe mode'); + } + throw new ERR_DEBUGGER_STARTUP_ERROR(`Unknown probe option: ${token.rawName}`); + } + } + + if (pendingLocation !== undefined) { + throw new ERR_DEBUGGER_STARTUP_ERROR( + 'Each --probe must be followed immediately by --expr '); + } + + if (probes.length === 0) { + throw new ERR_DEBUGGER_STARTUP_ERROR( + 'Probe mode requires at least one --probe --expr group'); + } + + const childArgv = ArrayPrototypeSlice(args, childStartIndex); + if (childArgv.length === 0) { + throw new ERR_DEBUGGER_STARTUP_ERROR('Probe mode requires a child script'); + } + + if (!sawSeparator && StringPrototypeStartsWith(childArgv[0], '-')) { + throw new ERR_DEBUGGER_STARTUP_ERROR('Use -- before child Node.js flags in probe mode'); + } + + let skipPortPreflight = port === 0; + for (const arg of childArgv) { + const inspectPortMatch = RegExpPrototypeExec(kInspectPortRegex, arg); + if (inspectPortMatch === null) { + continue; + } + if (inspectPortMatch[1] === '0') { + skipPortPreflight = true; + continue; + } + throw new ERR_DEBUGGER_STARTUP_ERROR( + 'Only child --inspect-port=0 is supported in probe mode'); + } + + return { + host: '127.0.0.1', + port, + preview, + timeout, + json, + probes, + childArgv, + skipPortPreflight, + }; +} + +async function launchChildProcess(childArgs, inspectHost, inspectPort, + childOutput, options = { __proto__: null }) { + if (!options.skipPortPreflight) { + await portIsFree(inspectHost, inspectPort); + } + + const args = [`--inspect-brk=${inspectPort}`]; + ArrayPrototypePushApply(args, childArgs); + const child = spawn(process.execPath, args); child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk) => childPrint(chunk, 'stdout')); - child.stderr.on('data', (chunk) => childPrint(chunk, 'stderr')); - - let output = ''; - return new Promise((resolve) => { - function waitForListenHint(text) { - output += text; - const debug = RegExpPrototypeExec(debugRegex, output); + child.stdout.on('data', (chunk) => childOutput(chunk, 'stdout')); + child.stderr.on('data', (chunk) => childOutput(chunk, 'stderr')); + + let stderrOutput = ''; + return new Promise((resolve, reject) => { + function rejectLaunch(message) { + reject(new ERR_DEBUGGER_STARTUP_ERROR(message, { childStderr: stderrOutput })); + } + + function onExit(code, signal) { + const suffix = signal !== null ? ` (${signal})` : ` (code ${code})`; + rejectLaunch(`Target exited before the inspector was ready${suffix}`); + } + + function onError(error) { + rejectLaunch(error.message); + } + + function onStderr(text) { + stderrOutput += text; + const debug = RegExpPrototypeExec(debugRegex, stderrOutput); if (debug) { - const host = debug[1]; - const port = Number(debug[2]); - child.stderr.removeListener('data', waitForListenHint); - resolve([child, port, host]); + child.stderr.removeListener('data', onStderr); + child.removeListener('exit', onExit); + child.removeListener('error', onError); + resolve([child, Number(debug[2]), debug[1]]); } } - child.stderr.on('data', waitForListenHint); + child.once('exit', onExit); + child.once('error', onError); + child.stderr.on('data', onStderr); }); } +// TODO(joyeecheung): move it to a separate file. Currently moving it +// somehow breaks the snapshot reproducibility which probably involves a +// pre-existing V8 bug. +class ProbeInspectorSession { + constructor(options) { + this.options = options; + this.client = new InspectClient(); + this.child = null; + this.cleanupStarted = false; + this.childStderr = ''; + this.disconnectRequested = false; + this.finished = false; + this.started = false; + this.stderrBuffer = ''; + this.breakpointDefinitions = new SafeMap(); + this.results = []; + this.timeout = null; + this.resolveCompletion = null; + this.completionPromise = new Promise((resolve) => { + this.resolveCompletion = resolve; + }); + this.probes = ArrayPrototypeMap(options.probes, (probe) => ({ + expr: probe.expr, + target: probe.location.target, + location: probe.location, + hits: 0, + })); + + this.onChildOutput = FunctionPrototypeBind(this.onChildOutput, this); + this.onChildExit = FunctionPrototypeBind(this.onChildExit, this); + this.onClientClose = FunctionPrototypeBind(this.onClientClose, this); + this.onPaused = FunctionPrototypeBind(this.onPaused, this); + } + + finish(state) { + if (this.finished) { return; } + this.finished = true; + if (this.timeout !== null) { + clearTimeout(this.timeout); + this.timeout = null; + } + this.resolveCompletion(state); + } + + onChildOutput(text, which) { + if (which !== 'stderr') { return; } + + if (this.started) { + this.childStderr += text; + } + + const combined = this.stderrBuffer + text; + if (this.started && + StringPrototypeIncludes(combined, kProbeDisconnectSentinel)) { + this.disconnectRequested = true; + this.client.reset(); + } + + if (combined.length > kProbeDisconnectSentinel.length) { + this.stderrBuffer = StringPrototypeSlice(combined, + combined.length - + kProbeDisconnectSentinel.length); + } else { + this.stderrBuffer = combined; + } + } + + onChildExit(code, signal) { + if (this.started) { + if (code !== 0 || signal !== null) { + this.finish({ + __proto__: null, + event: 'error', + exitCode: code, + signal, + stderr: trimProbeChildStderr(this.childStderr), + }); + } else { + this.finish('complete'); + } + } + } + + onClientClose() { + if (!this.started || this.child === null) { return; } + + // TODO(joyeecheung): Surface mid-probe inspector disconnects as terminal probe errors + // instead of deferring to timeout or miss classification. + if (this.disconnectRequested) { return; } + + if (this.child.exitCode !== null || this.child.signalCode !== null) { + this.onChildExit(this.child.exitCode, this.child.signalCode); + } + } + + onPaused(params) { + // TODO(joyeecheung): Preserve evaluation and resume failures as terminal probe errors + // instead of collapsing them into a synthetic completion. + this.handlePaused(params).catch((error) => { + if (!this.finished) { + if (error?.code === 'ERR_DEBUGGER_ERROR') { + if (this.child !== null && + (this.child.exitCode !== null || this.child.signalCode !== null)) { + this.onChildExit(this.child.exitCode, this.child.signalCode); + } + return; + } + this.finish('complete'); + } + }); + } + + async handlePaused(params) { + if (this.finished) { return; } + + const hitBreakpoints = params.hitBreakpoints; + if (hitBreakpoints === undefined || hitBreakpoints.length === 0) { + await this.resume(); + return; + } + + const callFrameId = params.callFrames?.[0]?.callFrameId; + if (callFrameId === undefined) { + await this.resume(); + return; + } + + for (const breakpointId of hitBreakpoints) { + const definition = this.breakpointDefinitions.get(breakpointId); + if (definition === undefined) { continue; } + for (const probeIndex of definition.probeIndices) { + await this.evaluateProbe(callFrameId, probeIndex); + } + } + + await this.resume(); + } + + async evaluateProbe(callFrameId, probeIndex) { + const probe = this.probes[probeIndex]; + const evaluation = await this.client.callMethod('Debugger.evaluateOnCallFrame', { + callFrameId, + expression: probe.expr, + generatePreview: true, + }); + + probe.hits++; + const result = { probe: probeIndex, event: 'hit', hit: probe.hits }; + + if (evaluation.exceptionDetails !== undefined) { + result.error = evaluation.result === undefined ? { + type: 'object', + subtype: 'error', + description: 'Probe expression failed', + } : trimRemoteObject(evaluation.result); + } else { + result.result = trimRemoteObject(evaluation.result); + } + + ArrayPrototypePush(this.results, result); + } + + async resume() { + if (this.finished) { return; } + await this.client.callMethod('Debugger.resume'); + } + + startTimeout() { + this.timeout = setTimeout(() => { this.finish('timeout'); }, this.options.timeout); + this.timeout.unref(); + } + + attachListeners() { + this.child.on('exit', this.onChildExit); + this.client.on('close', this.onClientClose); + this.client.on('Debugger.paused', this.onPaused); + } + + async bindBreakpoints() { + const uniqueLocations = new SafeMap(); + + for (let probeIndex = 0; probeIndex < this.probes.length; probeIndex++) { + const probe = this.probes[probeIndex]; + const key = `${probe.location.file}\n${probe.location.lineNumber}\n` + + `${probe.location.columnNumber === undefined ? '' : probe.location.columnNumber}`; + let entry = uniqueLocations.get(key); + if (entry === undefined) { + entry = { location: probe.location, probeIndices: [] }; + uniqueLocations.set(key, entry); + } + ArrayPrototypePush(entry.probeIndices, probeIndex); + } + + for (const { location, probeIndices } of uniqueLocations.values()) { + // TODO(joyeecheung): Normalize relative probe paths and avoid suffix matches that can + // bind unrelated loaded scripts with the same basename. + // On Windows, normalize backslashes to forward slashes so the regex matches + // V8 script URLs which always use forward slashes. + const normalizedFile = process.platform === 'win32' ? + SideEffectFreeRegExpPrototypeSymbolReplace(/\\/g, location.file, '/') : + location.file; + const escapedPath = SideEffectFreeRegExpPrototypeSymbolReplace( + /([/\\.?*()^${}|[\]])/g, + normalizedFile, + '\\$1', + ); + const params = { + urlRegex: `^(.*[\\/\\\\])?${escapedPath}$`, + lineNumber: location.lineNumber, + }; + if (location.columnNumber !== undefined) { + params.columnNumber = location.columnNumber; + } + + const result = await this.client.callMethod('Debugger.setBreakpointByUrl', params); + this.breakpointDefinitions.set(result.breakpointId, { probeIndices }); + } + } + + getPendingProbeIndices() { + const pending = []; + for (let probeIndex = 0; probeIndex < this.probes.length; probeIndex++) { + if (this.probes[probeIndex].hits === 0) { + ArrayPrototypePush(pending, probeIndex); + } + } + return pending; + } + + buildReport(state) { + const pending = this.getPendingProbeIndices(); + const report = { + v: kProbeVersion, + probes: ArrayPrototypeMap(this.probes, (probe) => { + return { expr: probe.expr, target: probe.target }; + }), + results: ArrayPrototypeSlice(this.results), + }; + + if (state === 'timeout') { + ArrayPrototypePush(report.results, { + event: 'timeout', + pending, + error: { + code: 'probe_timeout', + message: pending.length === 0 ? + `Timed out after ${this.options.timeout}ms waiting for target completion` : + `Timed out after ${this.options.timeout}ms waiting for probes: ` + + `${formatPendingProbeLocations(this.probes, pending)}`, + }, + }); + return { code: kGenericUserError, report }; + } + + if (state?.event === 'error') { + const error = { + __proto__: null, + code: 'probe_target_exit', + message: formatTargetExitMessage(this.probes, pending, state.exitCode, state.signal), + }; + if (state.exitCode !== null) { + error.exitCode = state.exitCode; + } + if (state.signal !== null) { + error.signal = state.signal; + } + error.stderr = state.stderr; + ArrayPrototypePush(report.results, { event: 'error', pending, error }); + return { code: kNoFailure, report }; + } + + if (pending.length === 0) { + ArrayPrototypePush(report.results, { event: 'completed' }); + } else { + ArrayPrototypePush(report.results, { event: 'miss', pending }); + } + + return { code: kNoFailure, report }; + } + + async cleanup() { + if (this.cleanupStarted) { return; } + this.cleanupStarted = true; + + if (this.timeout !== null) { + clearTimeout(this.timeout); + this.timeout = null; + } + + this.client.reset(); + + if (this.child === null) { return; } + + if (this.child.exitCode === null && this.child.signalCode === null) { + this.child.kill(); + } + } + + async run() { + try { + const { childArgv, host, port, skipPortPreflight } = this.options; + const { 0: child, 1: actualPort, 2: actualHost } = + await launchChildProcess(childArgv, + host, + port, + this.onChildOutput, + { skipPortPreflight }); + this.child = child; + this.attachListeners(); + + await this.client.connect(actualPort, actualHost); + await this.client.callMethod('Runtime.enable'); + await this.client.callMethod('Debugger.enable'); + await this.bindBreakpoints(); + this.started = true; + this.startTimeout(); + + await this.client.callMethod('Runtime.runIfWaitingForDebugger'); + + const state = await this.completionPromise; + return this.buildReport(state); + } finally { + await this.cleanup(); + } + } +} + function createAgentProxy(domain, client) { const agent = new EventEmitter(); agent.then = (then, _catch) => { @@ -139,9 +905,8 @@ class NodeInspector { if (options.script) { this._runScript = FunctionPrototypeBind( - runScript, null, - options.script, - options.scriptArgs, + launchChildProcess, null, + [options.script, ...options.scriptArgs], options.host, options.port, FunctionPrototypeBind(this.childPrint, this)); @@ -330,14 +1095,35 @@ function parseArgv(args) { function startInspect(argv = ArrayPrototypeSlice(process.argv, 2), stdin = process.stdin, stdout = process.stdout) { + const invokedAs = `${process.argv0} ${process.argv[1]}`; + if (argv.length < 1) { - const invokedAs = `${process.argv0} ${process.argv[1]}`; + writeUsageAndExit(invokedAs); + } + + if (hasTopLevelProbeOption(argv)) { + let probeOptions; + try { + probeOptions = parseProbeArgv(argv); + } catch (error) { + writeUsageAndExit(invokedAs, error.message, kGenericUserError); + } - process.stderr.write(`Usage: ${invokedAs} script.js\n` + - ` ${invokedAs} :\n` + - ` ${invokedAs} --port= Use 0 for random port assignment\n` + - ` ${invokedAs} -p \n`); - process.exit(kInvalidCommandLineArgument); + (async () => { + const session = new ProbeInspectorSession(probeOptions); + const { code, report } = await session.run(); + stdout.write(probeOptions.json ? + `${JSONStringify(probeOptions.preview ? report : stripProbePreviews(report))}\n` : + buildProbeTextReport(report)); + process.exit(code); + })().catch((error) => { + if (error.childStderr) { + process.stderr.write(error.childStderr); + } + process.stderr.write(ensureTrailingNewline(error.message)); + process.exit(kGenericUserError); + }); + return; } const options = parseArgv(argv); diff --git a/lib/internal/debugger/inspect_client.js b/lib/internal/debugger/inspect_client.js index 839be084d7e58b..1e5794ecc3a87e 100644 --- a/lib/internal/debugger/inspect_client.js +++ b/lib/internal/debugger/inspect_client.js @@ -1,12 +1,14 @@ 'use strict'; const { + ArrayPrototypeForEach, ArrayPrototypePush, ErrorCaptureStackTrace, FunctionPrototypeBind, JSONParse, JSONStringify, ObjectKeys, + ObjectValues, Promise, } = primordials; @@ -224,6 +226,15 @@ class Client extends EventEmitter { } reset() { + const pending = this._pending; + if (pending) { + ArrayPrototypeForEach(ObjectValues(pending), (handler) => { + handler({ + code: 'ERR_DEBUGGER_ERROR', + message: 'Debugger session ended', + }); + }); + } if (this._http) { this._http.destroy(); } diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 2b222c954d7be8..c40eed86bca834 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1192,7 +1192,12 @@ E('ERR_CRYPTO_SCRYPT_NOT_SUPPORTED', 'Scrypt algorithm not supported', Error); // Switch to TypeError. The current implementation does not seem right. E('ERR_CRYPTO_SIGN_KEY_REQUIRED', 'No key provided to sign', Error); E('ERR_DEBUGGER_ERROR', '%s', Error); -E('ERR_DEBUGGER_STARTUP_ERROR', '%s', Error); +E('ERR_DEBUGGER_STARTUP_ERROR', function(message, details = undefined) { + if (details !== undefined) { + ObjectAssign(this, details); + } + return message; +}, Error); E('ERR_DIR_CLOSED', 'Directory handle was closed', Error); E('ERR_DIR_CONCURRENT_OPERATION', 'Cannot do synchronous work on directory handle with concurrent ' + diff --git a/src/ffi/data.cc b/src/ffi/data.cc index 5ffedf0ffb5650..0d92981bf54ceb 100644 --- a/src/ffi/data.cc +++ b/src/ffi/data.cc @@ -13,6 +13,7 @@ #include using v8::ArrayBuffer; +using v8::ArrayBufferView; using v8::BackingStore; using v8::BigInt; using v8::Context; @@ -36,23 +37,19 @@ bool GetValidatedSize(Environment* env, const char* label, size_t* out) { if (!value->IsNumber()) { - THROW_ERR_INVALID_ARG_VALUE( - env, (std::string("The ") + label + " must be a number").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "The %s must be a number", label); return false; } double length = value.As()->Value(); if (!std::isfinite(length) || length < 0 || std::floor(length) != length) { THROW_ERR_INVALID_ARG_VALUE( - env, - (std::string("The ") + label + " must be a non-negative integer") - .c_str()); + env, "The %s must be a non-negative integer", label); return false; } if (length > static_cast(std::numeric_limits::max())) { - env->ThrowRangeError( - (std::string("The ") + label + " is too large").c_str()); + THROW_ERR_OUT_OF_RANGE(env, "The %s is too large", label); return false; } @@ -65,8 +62,7 @@ bool GetValidatedPointerAddress(Environment* env, const char* label, uintptr_t* out) { if (!value->IsBigInt()) { - THROW_ERR_INVALID_ARG_VALUE( - env, (std::string("The ") + label + " must be a bigint").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "The %s must be a bigint", label); return false; } @@ -74,16 +70,13 @@ bool GetValidatedPointerAddress(Environment* env, uint64_t address = value.As()->Uint64Value(&lossless); if (!lossless) { THROW_ERR_INVALID_ARG_VALUE( - env, - (std::string("The ") + label + " must be a non-negative bigint") - .c_str()); + env, "The %s must be a non-negative bigint", label); return false; } if (address > static_cast(std::numeric_limits::max())) { - env->ThrowRangeError( - (std::string("The ") + label + " exceeds the platform pointer range") - .c_str()); + THROW_ERR_INVALID_ARG_VALUE( + env, "The %s exceeds the platform pointer range", label); return false; } @@ -145,14 +138,14 @@ bool ValidatePointerSpan(Environment* env, size_t length, const char* error_message) { if (offset > std::numeric_limits::max() - raw_ptr) { - env->ThrowRangeError(error_message); + THROW_ERR_INVALID_ARG_VALUE(env, error_message); return false; } uintptr_t start = raw_ptr + offset; if (length > 0 && length - 1 > std::numeric_limits::max() - start) { - env->ThrowRangeError(error_message); + THROW_ERR_INVALID_ARG_VALUE(env, error_message); return false; } @@ -188,7 +181,7 @@ bool GetValidatedPointerAndOffset(Environment* env, } if (raw_ptr == 0) { - env->ThrowError("Cannot dereference a null pointer"); + THROW_ERR_FFI_INVALID_POINTER(env, "Cannot dereference a null pointer"); return false; } @@ -224,7 +217,7 @@ bool GetValidatedPointerValueAndOffset(Environment* env, } if (raw_ptr == 0) { - env->ThrowError("Cannot dereference a null pointer"); + THROW_ERR_FFI_INVALID_POINTER(env, "Cannot dereference a null pointer"); return false; } @@ -570,7 +563,8 @@ void ToBuffer(const FunctionCallbackInfo& args) { } if (ptr == 0 && len > 0) { - env->ThrowError("Cannot create a buffer from a null pointer"); + THROW_ERR_FFI_INVALID_POINTER(env, + "Cannot create a buffer from a null pointer"); return; } @@ -630,7 +624,8 @@ void ToArrayBuffer(const FunctionCallbackInfo& args) { } if (ptr == 0 && len > 0) { - env->ThrowError("Cannot create an ArrayBuffer from a null pointer"); + THROW_ERR_FFI_INVALID_POINTER( + env, "Cannot create an ArrayBuffer from a null pointer"); return; } @@ -668,6 +663,122 @@ void ToArrayBuffer(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(ab); } +void ExportBytes(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + + THROW_IF_INSUFFICIENT_PERMISSIONS(env, permission::PermissionScope::kFFI, ""); + + if (args.Length() < 1) { + THROW_ERR_INVALID_ARG_TYPE( + env, + "The first argument must be a Buffer, ArrayBuffer, or ArrayBufferView"); + return; + } + + const uint8_t* source_data = nullptr; + size_t source_len = 0; + + if (args[0]->IsArrayBuffer()) { + Local array_buffer = args[0].As(); + std::shared_ptr store = array_buffer->GetBackingStore(); + if (!store) { + THROW_ERR_INVALID_ARG_VALUE(env, "Invalid ArrayBuffer backing store"); + return; + } + source_data = static_cast(store->Data()); + source_len = array_buffer->ByteLength(); + } else if (args[0]->IsArrayBufferView()) { + ArrayBufferViewContents view(args[0]); + if (view.WasDetached()) { + THROW_ERR_INVALID_ARG_VALUE(env, "Invalid ArrayBufferView backing store"); + return; + } + source_data = view.data(); + source_len = view.length(); + } else { + THROW_ERR_INVALID_ARG_TYPE( + env, + "The first argument must be a Buffer, ArrayBuffer, or ArrayBufferView"); + return; + } + + uintptr_t ptr; + if (args.Length() < 2 || + !GetValidatedPointerAddress(env, args[1], "pointer", &ptr)) { + return; + } + + size_t len; + if (args.Length() < 3 || !GetValidatedSize(env, args[2], "length", &len)) { + return; + } + + if (len < source_len) { + THROW_ERR_OUT_OF_RANGE(env, "The length must be >= source byte length"); + return; + } + + if (ptr == 0 && source_len > 0) { + THROW_ERR_FFI_INVALID_POINTER(env, + "Cannot create a buffer from a null pointer"); + return; + } + + if (!ValidatePointerSpan( + env, + ptr, + 0, + len, + "The pointer and length exceed the platform address range")) { + return; + } + + if (source_len > 0) { + std::memcpy(reinterpret_cast(ptr), source_data, source_len); + } +} + +void GetRawPointer(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + Isolate* isolate = env->isolate(); + + THROW_IF_INSUFFICIENT_PERMISSIONS(env, permission::PermissionScope::kFFI, ""); + + if (args.Length() < 1) { + THROW_ERR_INVALID_ARG_TYPE( + env, + "The first argument must be a Buffer, ArrayBuffer, or ArrayBufferView"); + return; + } + + uintptr_t ptr = 0; + + if (args[0]->IsArrayBuffer()) { + Local array_buffer = args[0].As(); + std::shared_ptr store = array_buffer->GetBackingStore(); + if (!store) { + THROW_ERR_INVALID_ARG_VALUE(env, "Invalid ArrayBuffer backing store"); + return; + } + ptr = reinterpret_cast(store->Data()); + } else if (args[0]->IsArrayBufferView()) { + ArrayBufferViewContents view(args[0]); + if (view.WasDetached()) { + THROW_ERR_INVALID_ARG_VALUE(env, "Invalid ArrayBufferView backing store"); + return; + } + ptr = reinterpret_cast(view.data()); + } else { + THROW_ERR_INVALID_ARG_TYPE( + env, + "The first argument must be a Buffer, ArrayBuffer, or ArrayBufferView"); + return; + } + + args.GetReturnValue().Set( + BigInt::NewFromUnsigned(isolate, static_cast(ptr))); +} + } // namespace ffi } // namespace node diff --git a/src/ffi/types.cc b/src/ffi/types.cc index f70fa8d09a05dd..b5d9d762164c89 100644 --- a/src/ffi/types.cc +++ b/src/ffi/types.cc @@ -4,6 +4,7 @@ #include "base_object-inl.h" #include "data.h" #include "ffi.h" +#include "node_errors.h" #include "node_ffi.h" #include "v8.h" @@ -37,7 +38,8 @@ bool ThrowIfContainsNullBytes(Environment* env, const std::string& label) { if (value.length() != 0 && std::memchr(*value, '\0', value.length()) != nullptr) { - env->ThrowTypeError((label + " must not contain null bytes").c_str()); + THROW_ERR_INVALID_ARG_VALUE( + env, "%s must not contain null bytes", label.c_str()); return true; } @@ -120,7 +122,7 @@ bool ParseFunctionSignature(Environment* env, std::string msg = "Function signature of " + name + " must have either 'returns', 'return' or 'result' " "property"; - env->ThrowTypeError(msg.c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, msg); return false; } @@ -128,7 +130,7 @@ bool ParseFunctionSignature(Environment* env, std::string msg = "Function signature of " + name + " must have either 'parameters' or 'arguments' " "property"; - env->ThrowTypeError(msg.c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, msg); return false; } @@ -154,7 +156,7 @@ bool ParseFunctionSignature(Environment* env, if (!return_type_val->IsString()) { std::string msg = "Return value type of function " + name + " must be a string"; - env->ThrowTypeError(msg.c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, msg); return false; } @@ -178,7 +180,7 @@ bool ParseFunctionSignature(Environment* env, if (!arguments_val->IsArray()) { std::string msg = "Arguments list of function " + name + " must be an array"; - env->ThrowTypeError(msg.c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, msg); return false; } @@ -195,7 +197,7 @@ bool ParseFunctionSignature(Environment* env, if (!arg->IsString()) { std::string msg = "Argument " + std::to_string(i) + " of function " + name + " must be a string"; - env->ThrowTypeError(msg.c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, msg.c_str()); return false; } @@ -236,7 +238,7 @@ bool SignaturesMatch(const FFIFunction& fn, bool ToFFIType(Environment* env, const std::string& type_str, ffi_type** ret) { if (ret == nullptr) { - env->ThrowTypeError("ret must not be null"); + THROW_ERR_INVALID_ARG_VALUE(env, "ret must not be null"); return false; } @@ -271,7 +273,7 @@ bool ToFFIType(Environment* env, const std::string& type_str, ffi_type** ret) { *ret = &ffi_type_pointer; } else { std::string msg = std::string("Unsupported FFI type: ") + type_str; - env->ThrowTypeError(msg.c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, msg); return false; } @@ -291,8 +293,7 @@ uint8_t ToFFIArgument(Environment* env, int64_t value; if (!GetValidatedSignedInt(env, arg, INT8_MIN, INT8_MAX, "int8", &value)) { if (env->isolate()->IsExecutionTerminating()) return 0; - env->ThrowTypeError( - ("Argument " + std::to_string(index) + " must be an int8").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "Argument %u must be an int8", index); return 0; } @@ -301,8 +302,7 @@ uint8_t ToFFIArgument(Environment* env, uint64_t value; if (!GetValidatedUnsignedInt(env, arg, UINT8_MAX, "uint8", &value)) { if (env->isolate()->IsExecutionTerminating()) return 0; - env->ThrowTypeError( - ("Argument " + std::to_string(index) + " must be a uint8").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "Argument %u must be a uint8", index); return 0; } @@ -312,8 +312,7 @@ uint8_t ToFFIArgument(Environment* env, if (!GetValidatedSignedInt( env, arg, INT16_MIN, INT16_MAX, "int16", &value)) { if (env->isolate()->IsExecutionTerminating()) return 0; - env->ThrowTypeError( - ("Argument " + std::to_string(index) + " must be an int16").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "Argument %u must be an int16", index); return 0; } @@ -322,60 +321,52 @@ uint8_t ToFFIArgument(Environment* env, uint64_t value; if (!GetValidatedUnsignedInt(env, arg, UINT16_MAX, "uint16", &value)) { if (env->isolate()->IsExecutionTerminating()) return 0; - env->ThrowTypeError( - ("Argument " + std::to_string(index) + " must be a uint16").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "Argument %u must be a uint16", index); return 0; } *static_cast(ret) = static_cast(value); } else if (type == &ffi_type_sint32) { if (!arg->IsInt32()) { - env->ThrowTypeError( - ("Argument " + std::to_string(index) + " must be an int32").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "Argument %u must be an int32", index); return 0; } *static_cast(ret) = arg->Int32Value(context).FromJust(); } else if (type == &ffi_type_uint32) { if (!arg->IsUint32()) { - env->ThrowTypeError( - ("Argument " + std::to_string(index) + " must be a uint32").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "Argument %u must be a uint32", index); return 0; } *static_cast(ret) = arg->Uint32Value(context).FromJust(); } else if (type == &ffi_type_sint64) { if (!arg->IsBigInt()) { - env->ThrowTypeError( - ("Argument " + std::to_string(index) + " must be an int64").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "Argument %u must be an int64", index); return 0; } bool lossless; *static_cast(ret) = arg.As()->Int64Value(&lossless); if (!lossless) { - env->ThrowTypeError( - ("Argument " + std::to_string(index) + " must be an int64").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "Argument %u must be an int64", index); return 0; } } else if (type == &ffi_type_uint64) { if (!arg->IsBigInt()) { - env->ThrowTypeError( - ("Argument " + std::to_string(index) + " must be a uint64").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "Argument %u must be a uint64", index); return 0; } bool lossless; *static_cast(ret) = arg.As()->Uint64Value(&lossless); if (!lossless) { - env->ThrowTypeError( - ("Argument " + std::to_string(index) + " must be a uint64").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "Argument %u must be a uint64", index); return 0; } } else if (type == &ffi_type_float) { if (!arg->IsNumber()) { - env->ThrowTypeError( - ("Argument " + std::to_string(index) + " must be a float").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "Argument %u must be a float", index); return 0; } @@ -383,8 +374,7 @@ uint8_t ToFFIArgument(Environment* env, static_cast(arg->NumberValue(context).FromJust()); } else if (type == &ffi_type_double) { if (!arg->IsNumber()) { - env->ThrowTypeError( - ("Argument " + std::to_string(index) + " must be a double").c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, "Argument %u must be a double", index); return 0; } @@ -405,10 +395,10 @@ uint8_t ToFFIArgument(Environment* env, std::shared_ptr store = view->Buffer()->GetBackingStore(); if (!store) { - env->ThrowTypeError( - ("Invalid ArrayBufferView backing store for argument " + - std::to_string(index)) - .c_str()); + THROW_ERR_INVALID_ARG_VALUE( + env, + "Invalid ArrayBufferView backing store for argument %u", + index); return 0; } @@ -426,9 +416,8 @@ uint8_t ToFFIArgument(Environment* env, std::shared_ptr store = buffer->GetBackingStore(); if (!store) { - env->ThrowTypeError(("Invalid ArrayBuffer backing store for argument " + - std::to_string(index)) - .c_str()); + THROW_ERR_INVALID_ARG_VALUE( + env, "Invalid ArrayBuffer backing store for argument %u", index); return 0; } @@ -438,23 +427,22 @@ uint8_t ToFFIArgument(Environment* env, uint64_t pointer = arg.As()->Uint64Value(&lossless); if (!lossless || pointer > static_cast( std::numeric_limits::max())) { - env->ThrowTypeError(("Argument " + std::to_string(index) + - " must be a non-negative pointer bigint") - .c_str()); + THROW_ERR_INVALID_ARG_VALUE( + env, "Argument %u must be a non-negative pointer bigint", index); return 0; } *static_cast(ret) = pointer; } else { - env->ThrowTypeError( - ("Argument " + std::to_string(index) + - " must be a buffer, an ArrayBuffer, a string, or a bigint") - .c_str()); + THROW_ERR_INVALID_ARG_VALUE( + env, + "Argument %u must be a buffer, an ArrayBuffer, a string, or a bigint", + index); return 0; } } else { - env->ThrowTypeError( - ("Unsupported FFI type for argument " + std::to_string(index)).c_str()); + THROW_ERR_INVALID_ARG_VALUE( + env, "Unsupported FFI type for argument %u", index); return 0; } diff --git a/src/node_errors.h b/src/node_errors.h index 8f14b75b10493c..6734e7807f5280 100644 --- a/src/node_errors.h +++ b/src/node_errors.h @@ -79,6 +79,9 @@ void OOMErrorHandler(const char* location, const v8::OOMDetails& details); V(ERR_DLOPEN_FAILED, Error) \ V(ERR_ENCODING_INVALID_ENCODED_DATA, TypeError) \ V(ERR_EXECUTION_ENVIRONMENT_NOT_AVAILABLE, Error) \ + V(ERR_FFI_CALL_FAILED, Error) \ + V(ERR_FFI_INVALID_POINTER, Error) \ + V(ERR_FFI_LIBRARY_CLOSED, Error) \ V(ERR_FS_CP_EINVAL, Error) \ V(ERR_FS_CP_DIR_TO_NON_DIR, Error) \ V(ERR_FS_CP_NON_DIR_TO_DIR, Error) \ @@ -218,6 +221,7 @@ ERRORS_WITH_CODE(V) V(ERR_CRYPTO_UNSUPPORTED_OPERATION, "Unsupported crypto operation") \ V(ERR_CRYPTO_JOB_INIT_FAILED, "Failed to initialize crypto job config") \ V(ERR_DLOPEN_FAILED, "DLOpen failed") \ + V(ERR_FFI_LIBRARY_CLOSED, "Library is closed") \ V(ERR_EXECUTION_ENVIRONMENT_NOT_AVAILABLE, \ "Context not associated with Node.js environment") \ V(ERR_ILLEGAL_CONSTRUCTOR, "Illegal constructor") \ diff --git a/src/node_ffi.cc b/src/node_ffi.cc index d0c578cfedd46a..be5df0d736b5fc 100644 --- a/src/node_ffi.cc +++ b/src/node_ffi.cc @@ -87,7 +87,7 @@ bool DynamicLibrary::ResolveSymbol(Environment* env, const std::string& name, void** ret) { if (handle_ == nullptr) { - env->ThrowError("Library is closed"); + THROW_ERR_FFI_LIBRARY_CLOSED(env); return false; } @@ -99,7 +99,7 @@ bool DynamicLibrary::ResolveSymbol(Environment* env, } else { if (uv_dlsym(&lib_, name.c_str(), &ptr) != 0) { std::string msg = std::string("dlsym failed: ") + uv_dlerror(&lib_); - env->ThrowError(msg.c_str()); + THROW_ERR_FFI_CALL_FAILED(env, msg.c_str()); return false; } } @@ -160,7 +160,7 @@ bool DynamicLibrary::PrepareFunction(Environment* env, break; } - env->ThrowError(msg); + THROW_ERR_FFI_CALL_FAILED(env, msg); return false; } @@ -171,7 +171,7 @@ bool DynamicLibrary::PrepareFunction(Environment* env, if (!SignaturesMatch(*fn, return_type, args)) { std::string msg = "Function " + name + " was already requested with a different signature"; - env->ThrowError(msg.c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, msg.c_str()); return false; } } @@ -238,22 +238,34 @@ void DynamicLibrary::New(const FunctionCallbackInfo& args) { THROW_IF_INSUFFICIENT_PERMISSIONS(env, permission::PermissionScope::kFFI, ""); +#ifndef _WIN32 + if (args.Length() < 1 || (!args[0]->IsString() && !args[0]->IsNull())) { + THROW_ERR_INVALID_ARG_TYPE(env, "Library path must be a string or null"); + return; + } +#else if (args.Length() < 1 || !args[0]->IsString()) { - env->ThrowTypeError("Library path must be a string"); + THROW_ERR_INVALID_ARG_TYPE(env, "Library path must be a string"); return; } +#endif + const char* library_path = nullptr; DynamicLibrary* lib = new DynamicLibrary(env, args.This()); - Utf8Value filename(env->isolate(), args[0]); - if (ThrowIfContainsNullBytes(env, filename, "Library path")) { - return; + + if (args[0]->IsString()) { + Utf8Value filename(env->isolate(), args[0]); + if (ThrowIfContainsNullBytes(env, filename, "Library path")) { + return; + } + lib->path_ = filename.ToString(); + library_path = lib->path_.c_str(); } - lib->path_ = std::string(*filename); // Open the library - if (uv_dlopen(*filename, &lib->lib_) != 0) { + if (uv_dlopen(library_path, &lib->lib_) != 0) { std::string msg = std::string("dlopen failed: ") + uv_dlerror(&lib->lib_); - env->ThrowError(msg.c_str()); + THROW_ERR_FFI_CALL_FAILED(env, msg.c_str()); return; } @@ -274,7 +286,7 @@ void DynamicLibrary::InvokeFunction(const FunctionCallbackInfo& args) { FFIFunction* fn = info->fn.get(); if (fn == nullptr || fn->closed || fn->ptr == nullptr) { - env->ThrowError("Library is closed"); + THROW_ERR_FFI_LIBRARY_CLOSED(env); return; } @@ -286,7 +298,7 @@ void DynamicLibrary::InvokeFunction(const FunctionCallbackInfo& args) { std::string msg = "Invalid argument count: expected " + std::to_string(expected_args) + ", got " + std::to_string(provided_args); - env->ThrowError(msg.c_str()); + THROW_ERR_INVALID_ARG_VALUE(env, msg.c_str()); return; } @@ -307,7 +319,8 @@ void DynamicLibrary::InvokeFunction(const FunctionCallbackInfo& args) { Utf8Value str(env->isolate(), args[i]); if (*str == nullptr) { - env->ThrowTypeError( + THROW_ERR_INVALID_ARG_TYPE( + env, ("Argument " + std::to_string(i) + " must be a string").c_str()); return; } @@ -337,6 +350,14 @@ void DynamicLibrary::InvokeFunction(const FunctionCallbackInfo& args) { free(result); } +// This is the function that will be called by libffi when a callback +// is invoked from a dlopen library. It converts the arguments to JavaScript +// values and calls the original JavaScript callback function. +// It also handles the return value and exceptions properly. +// Note that since this function is called from native code, it must not throw +// exceptions or return promises, as there is no defined way to propagate them +// back to the caller. +// If such cases occur, the process will be aborted to avoid undefined behavior. void DynamicLibrary::InvokeCallback(ffi_cif* cif, void* ret, void** args, @@ -434,12 +455,12 @@ void DynamicLibrary::GetFunction(const FunctionCallbackInfo& args) { Isolate* isolate = env->isolate(); if (args.Length() < 1 || !args[0]->IsString()) { - env->ThrowTypeError("Function name must be a string"); + THROW_ERR_INVALID_ARG_TYPE(env, "Function name must be a string"); return; } if (args.Length() < 2 || !args[1]->IsObject() || args[1]->IsArray()) { - env->ThrowTypeError("Function signature must be an object"); + THROW_ERR_INVALID_ARG_TYPE(env, "Function signature must be an object"); return; } @@ -484,7 +505,7 @@ void DynamicLibrary::GetFunctions(const FunctionCallbackInfo& args) { DynamicLibrary* lib = Unwrap(args.This()); if (lib->handle_ == nullptr) { - env->ThrowError("Library is closed"); + THROW_ERR_FFI_LIBRARY_CLOSED(env); return; } @@ -495,7 +516,7 @@ void DynamicLibrary::GetFunctions(const FunctionCallbackInfo& args) { if (args.Length() > 0) { if (!args[0]->IsObject() || args[0]->IsArray()) { - env->ThrowTypeError("Functions signatures must be an object"); + THROW_ERR_INVALID_ARG_TYPE(env, "Functions signatures must be an object"); return; } @@ -528,7 +549,7 @@ void DynamicLibrary::GetFunctions(const FunctionCallbackInfo& args) { if (!signature->IsObject() || signature->IsArray()) { std::string msg = std::string("Signature of function ") + name.out() + " must be an object"; - env->ThrowTypeError(msg.c_str()); + THROW_ERR_INVALID_ARG_TYPE(env, msg.c_str()); return; } @@ -612,7 +633,7 @@ void DynamicLibrary::GetSymbol(const FunctionCallbackInfo& args) { Isolate* isolate = env->isolate(); if (args.Length() < 1 || !args[0]->IsString()) { - env->ThrowTypeError("Symbol name must be a string"); + THROW_ERR_INVALID_ARG_TYPE(env, "Symbol name must be a string"); return; } @@ -640,7 +661,7 @@ void DynamicLibrary::GetSymbols(const FunctionCallbackInfo& args) { DynamicLibrary* lib = Unwrap(args.This()); if (lib->handle_ == nullptr) { - env->ThrowError("Library is closed"); + THROW_ERR_FFI_LIBRARY_CLOSED(env); return; } @@ -680,8 +701,8 @@ void DynamicLibrary::RegisterCallback(const FunctionCallbackInfo& args) { Local fn; if (args.Length() < 1) { - env->ThrowTypeError( - "First argument must be a function or a signature object"); + THROW_ERR_INVALID_ARG_TYPE( + env, "First argument must be a function or a signature object"); return; } @@ -689,13 +710,13 @@ void DynamicLibrary::RegisterCallback(const FunctionCallbackInfo& args) { fn = args[0].As(); } else { if (!args[0]->IsObject() || args[0]->IsArray()) { - env->ThrowTypeError( - "First argument must be a function or a signature object"); + THROW_ERR_INVALID_ARG_TYPE( + env, "First argument must be a function or a signature object"); return; } if (args.Length() < 2 || !args[1]->IsFunction()) { - env->ThrowTypeError("Second argument must be a function"); + THROW_ERR_INVALID_ARG_TYPE(env, "Second argument must be a function"); return; } @@ -712,7 +733,7 @@ void DynamicLibrary::RegisterCallback(const FunctionCallbackInfo& args) { DynamicLibrary* lib = Unwrap(args.This()); if (lib->handle_ == nullptr) { - env->ThrowError("Library is closed"); + THROW_ERR_FFI_LIBRARY_CLOSED(env); return; } @@ -730,7 +751,7 @@ void DynamicLibrary::RegisterCallback(const FunctionCallbackInfo& args) { ffi_closure_alloc(sizeof(ffi_closure), &callback->ptr)); if (callback->closure == nullptr) { - env->ThrowError("ffi_closure_alloc failed"); + THROW_ERR_FFI_CALL_FAILED(env, "ffi_closure_alloc failed"); delete callback; return; } @@ -755,7 +776,7 @@ void DynamicLibrary::RegisterCallback(const FunctionCallbackInfo& args) { break; } - env->ThrowError(msg); + THROW_ERR_FFI_CALL_FAILED(env, msg); delete callback; return; } @@ -779,7 +800,7 @@ void DynamicLibrary::RegisterCallback(const FunctionCallbackInfo& args) { break; } - env->ThrowError(msg); + THROW_ERR_FFI_CALL_FAILED(env, msg); delete callback; return; } @@ -796,12 +817,12 @@ void DynamicLibrary::UnregisterCallback( DynamicLibrary* lib = Unwrap(args.This()); if (lib->handle_ == nullptr) { - env->ThrowError("Library is closed"); + THROW_ERR_FFI_LIBRARY_CLOSED(env); return; } if (args.Length() < 1 || !args[0]->IsBigInt()) { - env->ThrowTypeError("The first argument must be a bigint"); + THROW_ERR_INVALID_ARG_TYPE(env, "The first argument must be a bigint"); return; } @@ -814,7 +835,7 @@ void DynamicLibrary::UnregisterCallback( auto existing = lib->callbacks_.find(ptr); if (existing == lib->callbacks_.end()) { - env->ThrowError("Callback not found"); + THROW_ERR_INVALID_ARG_VALUE(env, "Callback not found"); return; } @@ -831,12 +852,12 @@ void DynamicLibrary::RefCallback(const FunctionCallbackInfo& args) { DynamicLibrary* lib = Unwrap(args.This()); if (lib->handle_ == nullptr) { - env->ThrowError("Library is closed"); + THROW_ERR_FFI_LIBRARY_CLOSED(env); return; } if (args.Length() < 1 || !args[0]->IsBigInt()) { - env->ThrowTypeError("The first argument must be a bigint"); + THROW_ERR_INVALID_ARG_TYPE(env, "The first argument must be a bigint"); return; } @@ -849,7 +870,7 @@ void DynamicLibrary::RefCallback(const FunctionCallbackInfo& args) { auto existing = lib->callbacks_.find(ptr); if (existing == lib->callbacks_.end()) { - env->ThrowError("Callback not found"); + THROW_ERR_INVALID_ARG_VALUE(env, "Callback not found"); return; } @@ -861,12 +882,12 @@ void DynamicLibrary::UnrefCallback(const FunctionCallbackInfo& args) { DynamicLibrary* lib = Unwrap(args.This()); if (lib->handle_ == nullptr) { - env->ThrowError("Library is closed"); + THROW_ERR_FFI_LIBRARY_CLOSED(env); return; } if (args.Length() < 1 || !args[0]->IsBigInt()) { - env->ThrowTypeError("The first argument must be a bigint"); + THROW_ERR_INVALID_ARG_TYPE(env, "The first argument must be a bigint"); return; } @@ -879,7 +900,7 @@ void DynamicLibrary::UnrefCallback(const FunctionCallbackInfo& args) { auto existing = lib->callbacks_.find(ptr); if (existing == lib->callbacks_.end()) { - env->ThrowError("Callback not found"); + THROW_ERR_INVALID_ARG_VALUE(env, "Callback not found"); return; } @@ -952,6 +973,8 @@ static void Initialize(Local target, SetMethod(context, target, "toString", ToString); SetMethod(context, target, "toBuffer", ToBuffer); SetMethod(context, target, "toArrayBuffer", ToArrayBuffer); + SetMethod(context, target, "exportBytes", ExportBytes); + SetMethod(context, target, "getRawPointer", GetRawPointer); SetMethod(context, target, "getInt8", GetInt8); SetMethod(context, target, "getUint8", GetUint8); diff --git a/src/node_ffi.h b/src/node_ffi.h index cbb0b82e4d22af..a4c518ee8171f0 100644 --- a/src/node_ffi.h +++ b/src/node_ffi.h @@ -144,6 +144,8 @@ void SetFloat64(const v8::FunctionCallbackInfo& args); void ToString(const v8::FunctionCallbackInfo& args); void ToBuffer(const v8::FunctionCallbackInfo& args); void ToArrayBuffer(const v8::FunctionCallbackInfo& args); +void ExportBytes(const v8::FunctionCallbackInfo& args); +void GetRawPointer(const v8::FunctionCallbackInfo& args); } // namespace node::ffi diff --git a/src/node_options.h b/src/node_options.h index 8ee0f55e65e8a6..e910cb011431ab 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -112,6 +112,10 @@ class DebugOptions : public Options { std::vector* argv) override; }; +#ifndef EXPERIMENTALS_DEFAULT_VALUE +#define EXPERIMENTALS_DEFAULT_VALUE false +#endif + class EnvironmentOptions : public Options { public: bool abort_on_uncaught_exception = false; @@ -122,18 +126,18 @@ class EnvironmentOptions : public Options { bool require_module = true; std::string dns_result_order; bool enable_source_maps = false; - bool experimental_addon_modules = false; - bool experimental_eventsource = false; - bool experimental_ffi = false; + bool experimental_addon_modules = EXPERIMENTALS_DEFAULT_VALUE; + bool experimental_eventsource = EXPERIMENTALS_DEFAULT_VALUE; + bool experimental_ffi = EXPERIMENTALS_DEFAULT_VALUE; bool experimental_websocket = true; bool experimental_sqlite = HAVE_SQLITE; - bool experimental_stream_iter = false; + bool experimental_stream_iter = EXPERIMENTALS_DEFAULT_VALUE; bool webstorage = HAVE_SQLITE; - bool experimental_quic = false; + bool experimental_quic = EXPERIMENTALS_DEFAULT_VALUE; std::string localstorage_file; bool experimental_global_navigator = true; bool experimental_global_web_crypto = true; - bool experimental_import_meta_resolve = false; + bool experimental_import_meta_resolve = EXPERIMENTALS_DEFAULT_VALUE; std::string input_type; // Value of --input-type bool entry_is_url = false; bool permission = false; @@ -148,7 +152,7 @@ class EnvironmentOptions : public Options { bool allow_ffi = false; bool allow_worker_threads = false; bool experimental_repl_await = true; - bool experimental_vm_modules = false; + bool experimental_vm_modules = EXPERIMENTALS_DEFAULT_VALUE; bool async_context_frame = true; bool expose_internals = false; bool force_node_api_uncaught_exceptions_policy = false; @@ -175,10 +179,10 @@ class EnvironmentOptions : public Options { uint64_t cpu_prof_interval = kDefaultCpuProfInterval; std::string cpu_prof_name; bool cpu_prof = false; - bool experimental_network_inspection = false; - bool experimental_worker_inspection = false; - bool experimental_storage_inspection = false; - bool experimental_inspector_network_resource = false; + bool experimental_network_inspection = EXPERIMENTALS_DEFAULT_VALUE; + bool experimental_worker_inspection = EXPERIMENTALS_DEFAULT_VALUE; + bool experimental_storage_inspection = EXPERIMENTALS_DEFAULT_VALUE; + bool experimental_inspector_network_resource = EXPERIMENTALS_DEFAULT_VALUE; std::string heap_prof_dir; std::string heap_prof_name; static const uint64_t kDefaultHeapProfInterval = 512 * 1024; diff --git a/test/common/debugger-probe.js b/test/common/debugger-probe.js new file mode 100644 index 00000000000000..4ad693e0363c26 --- /dev/null +++ b/test/common/debugger-probe.js @@ -0,0 +1,21 @@ +'use strict'; + +const fixtures = require('./fixtures'); +const path = require('path'); + +function debuggerFixturePath(name) { + return path.relative(process.cwd(), fixtures.path('debugger', name)); +} + +function escapeRegex(string) { + return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); +} + +module.exports = { + escapeRegex, + missScript: debuggerFixturePath('probe-miss.js'), + probeScript: debuggerFixturePath('probe.js'), + throwScript: debuggerFixturePath('probe-throw.js'), + probeTypesScript: debuggerFixturePath('probe-types.js'), + timeoutScript: debuggerFixturePath('probe-timeout.js'), +}; diff --git a/test/ffi/test-ffi-dynamic-library.js b/test/ffi/test-ffi-dynamic-library.js index b4ba6c3537f294..8ff2e9de816317 100644 --- a/test/ffi/test-ffi-dynamic-library.js +++ b/test/ffi/test-ffi-dynamic-library.js @@ -21,6 +21,21 @@ test('dlopen without definitions returns empty function map', () => { } }); +test('dlopen resolves symbols from the current process with null path', { + skip: common.isWindows, +}, () => { + const { lib, functions } = ffi.dlopen(null, { + uv_os_getpid: { result: 'i32', parameters: [] }, + }); + + try { + assert.ok(lib instanceof ffi.DynamicLibrary); + assert.strictEqual(functions.uv_os_getpid(), process.pid); + } finally { + lib.close(); + } +}); + test('dlopen resolves functions from definitions', () => { const { lib, functions } = ffi.dlopen(libraryPath, { add_i32: fixtureSymbols.add_i32, diff --git a/test/ffi/test-ffi-memory.js b/test/ffi/test-ffi-memory.js index 66a4bfd9d93e65..5a6667fa512bcb 100644 --- a/test/ffi/test-ffi-memory.js +++ b/test/ffi/test-ffi-memory.js @@ -12,6 +12,7 @@ const { fixtureSymbols, libraryPath } = require('./ffi-test-common'); const { lib, functions: symbols } = ffi.dlopen(libraryPath, { allocate_memory: fixtureSymbols.allocate_memory, deallocate_memory: fixtureSymbols.deallocate_memory, + pointer_to_usize: fixtureSymbols.pointer_to_usize, }); after(() => lib.close()); @@ -120,6 +121,24 @@ test('ffi toArrayBuffer supports copy and zero-copy views', () => { })); }); +test('ffi getRawPointer returns raw addresses for byte sources', () => { + const buffer = Buffer.from([1, 2, 3]); + const arrayBuffer = new Uint8Array([4, 5, 6, 7]).buffer; + const view = new Uint8Array(arrayBuffer, 2); + + const bufferPointer = ffi.getRawPointer(buffer); + const arrayBufferPointer = ffi.getRawPointer(arrayBuffer); + const viewPointer = ffi.getRawPointer(view); + + assert.strictEqual(typeof bufferPointer, 'bigint'); + assert.strictEqual(typeof arrayBufferPointer, 'bigint'); + assert.strictEqual(typeof viewPointer, 'bigint'); + + assert.strictEqual(bufferPointer, symbols.pointer_to_usize(buffer)); + assert.strictEqual(arrayBufferPointer, symbols.pointer_to_usize(arrayBuffer)); + assert.strictEqual(viewPointer, arrayBufferPointer + 2n); +}); + test('ffi exportString and exportBuffer copy data into native memory', () => { withAllocations(common.mustCall((alloc) => { const stringPtr = alloc(16); @@ -148,6 +167,22 @@ test('ffi exportString and exportBuffer copy data into native memory', () => { assert.throws(() => ffi.exportBuffer(Buffer.from([1, 2, 3, 4, 5, 6, 7]), bufferPtr, 6), { code: 'ERR_OUT_OF_RANGE', }); + + const arrayBufferPtr = alloc(8); + const arrayBuffer = new Uint8Array([8, 9, 10, 11]).buffer; + ffi.exportArrayBuffer(arrayBuffer, arrayBufferPtr, 4); + assert.deepStrictEqual([...ffi.toBuffer(arrayBufferPtr, 4)], [8, 9, 10, 11]); + + const viewPtr = alloc(8); + const viewSource = new Uint16Array([0x0102, 0x0304, 0x0506]); + const middleBytes = new Uint8Array(viewSource.buffer, 2, 2); + ffi.exportArrayBufferView(middleBytes, viewPtr, 2); + assert.deepStrictEqual([...ffi.toBuffer(viewPtr, 2)], [0x04, 0x03]); + + const bufferViewPtr = alloc(8); + const bufferView = Buffer.from([1, 7, 2, 8, 3]); + ffi.exportArrayBufferView(bufferView.subarray(1, 4), bufferViewPtr, 3); + assert.deepStrictEqual([...ffi.toBuffer(bufferViewPtr, 3)], [7, 2, 8]); })); }); @@ -169,6 +204,8 @@ test('ffi validates memory access arguments', () => { assert.throws(() => ffi.toArrayBuffer(ptr, 'bad'), /The length must be a number/); assert.throws(() => ffi.toArrayBuffer(-1n, 4), /The first argument must be a non-negative bigint/); assert.throws(() => ffi.toArrayBuffer(0n, 1), /Cannot create an ArrayBuffer from a null pointer/); + assert.throws(() => ffi.getRawPointer('bad'), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => ffi.getRawPointer(1), { code: 'ERR_INVALID_ARG_TYPE' }); assert.throws(() => ffi.getInt32(0n), /Cannot dereference a null pointer/); assert.throws(() => ffi.getInt32(-1n), /The pointer must be a non-negative bigint/); assert.throws(() => ffi.getInt8(maxPointer, 8), /pointer and offset exceed the platform address range/); @@ -201,6 +238,12 @@ test('ffi validates memory access arguments', () => { assert.throws(() => ffi.exportBuffer('bad', ptr, 4), { code: 'ERR_INVALID_ARG_TYPE' }); assert.throws(() => ffi.exportBuffer(Buffer.from([1]), ptr, -1), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => ffi.exportBuffer(Buffer.from([1, 2]), ptr, 1), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => ffi.exportArrayBuffer('bad', ptr, 4), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => ffi.exportArrayBuffer(new ArrayBuffer(1), ptr, -1), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => ffi.exportArrayBuffer(new ArrayBuffer(2), ptr, 1), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => ffi.exportArrayBufferView('bad', ptr, 4), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => ffi.exportArrayBufferView(new Uint8Array([1]), ptr, -1), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => ffi.exportArrayBufferView(new Uint8Array([1, 2]), ptr, 1), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => ffi.toBuffer(maxPointer, 8), /pointer and length exceed the platform address range/); assert.throws(() => ffi.toArrayBuffer(maxPointer, 8), /pointer and length exceed the platform address range/); assert.throws(() => ffi.toBuffer(1n, bufferConstants.MAX_LENGTH + 1), { code: 'ERR_BUFFER_TOO_LARGE' }); diff --git a/test/ffi/test-ffi-module.js b/test/ffi/test-ffi-module.js index 2dc18c9eaa41b7..1cd2b840097d6b 100644 --- a/test/ffi/test-ffi-module.js +++ b/test/ffi/test-ffi-module.js @@ -83,6 +83,8 @@ test('ffi exports expected API surface', () => { 'dlclose', 'dlopen', 'dlsym', + 'exportArrayBuffer', + 'exportArrayBufferView', 'exportBuffer', 'exportString', 'getFloat32', @@ -91,6 +93,7 @@ test('ffi exports expected API surface', () => { 'getInt32', 'getInt64', 'getInt8', + 'getRawPointer', 'getUint16', 'getUint32', 'getUint64', @@ -117,8 +120,11 @@ test('ffi exports expected API surface', () => { assert.strictEqual(typeof ffi.dlopen, 'function'); assert.strictEqual(typeof ffi.dlclose, 'function'); assert.strictEqual(typeof ffi.dlsym, 'function'); + assert.strictEqual(typeof ffi.exportArrayBuffer, 'function'); + assert.strictEqual(typeof ffi.exportArrayBufferView, 'function'); assert.strictEqual(typeof ffi.exportString, 'function'); assert.strictEqual(typeof ffi.exportBuffer, 'function'); + assert.strictEqual(typeof ffi.getRawPointer, 'function'); assert.strictEqual(typeof ffi.getInt8, 'function'); assert.strictEqual(typeof ffi.getUint8, 'function'); assert.strictEqual(typeof ffi.getInt16, 'function'); diff --git a/test/ffi/test-ffi-permissions.js b/test/ffi/test-ffi-permissions.js index f5b66c1d5d8ffe..c07f0bbdb439d7 100644 --- a/test/ffi/test-ffi-permissions.js +++ b/test/ffi/test-ffi-permissions.js @@ -58,6 +58,18 @@ test('permission model blocks ffi memory and helper APIs', () => { ffi.exportBuffer(Buffer.alloc(0), 1n, 0); }, denied); + assert.throws(() => { + ffi.exportArrayBuffer(new ArrayBuffer(0), 1n, 0); + }, denied); + + assert.throws(() => { + ffi.exportArrayBufferView(new Uint8Array(0), 1n, 0); + }, denied); + + assert.throws(() => { + ffi.getRawPointer(Buffer.alloc(0)); + }, denied); + assert.throws(() => { ffi.dlclose({ close() {} }); }, denied); diff --git a/test/fixtures/debugger/probe-miss.js b/test/fixtures/debugger/probe-miss.js new file mode 100644 index 00000000000000..1b647114a6e9ed --- /dev/null +++ b/test/fixtures/debugger/probe-miss.js @@ -0,0 +1,5 @@ +'use strict'; + +function neverCalled() { + console.log('unreachable'); +} diff --git a/test/fixtures/debugger/probe-timeout.js b/test/fixtures/debugger/probe-timeout.js new file mode 100644 index 00000000000000..8894305e3d905b --- /dev/null +++ b/test/fixtures/debugger/probe-timeout.js @@ -0,0 +1,7 @@ +'use strict'; + +function neverCalled() { + console.log('never'); +} + +setInterval(() => {}, 1000); diff --git a/test/fixtures/debugger/probe-types.js b/test/fixtures/debugger/probe-types.js new file mode 100644 index 00000000000000..d5d676a6b8dfa4 --- /dev/null +++ b/test/fixtures/debugger/probe-types.js @@ -0,0 +1,26 @@ +'use strict'; + +const stringValue = 'hello'; +const booleanValue = true; +const undefinedValue = undefined; +const nullValue = null; +const nanValue = NaN; +const bigintValue = 1n; +const symbolValue = Symbol('tag'); +const functionValue = () => 1; +const objectValue = { alpha: 1, beta: 'two' }; +const arrayValue = [1, 'two', 3]; +const errorValue = new Error('boom'); +errorValue.stack = 'Error: boom'; + +function consume() {} +consume(); + +// TODO(joyeecheung): add a test for evaluation errors. This can be +// somewhat flaky on slow CI machines. +// const holder = new class Foo { +// get throwingGetter() { +// throw new Error('foo'); +// } +// } +// probe holder.throwingGetter diff --git a/test/fixtures/debugger/probe.js b/test/fixtures/debugger/probe.js new file mode 100644 index 00000000000000..a847c7af62f0d1 --- /dev/null +++ b/test/fixtures/debugger/probe.js @@ -0,0 +1,12 @@ +'use strict'; + +console.log('probe stdout'); +console.error('probe stderr'); + +let total = 0; +for (let index = 0; index < 2; index++) { + total += index + 40; +} + +const finalValue = total; +console.log(finalValue); diff --git a/test/parallel/test-debugger-probe-activation.js b/test/parallel/test-debugger-probe-activation.js new file mode 100644 index 00000000000000..69eb1324ef882e --- /dev/null +++ b/test/parallel/test-debugger-probe-activation.js @@ -0,0 +1,38 @@ +// This tests that probe mode only activates when --probe is present, +// so that other options can be used as user script arguments without +// accidentally activating probe mode. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const fixtures = require('../common/fixtures'); +const startCLI = require('../common/debugger'); + +const assert = require('assert'); + +const script = fixtures.path('debugger', 'three-lines.js'); +const cli = startCLI([ + script, + '--json', + '--preview', + '--timeout=1', + '--expr', + 'value', +]); + +(async () => { + try { + await cli.waitForInitialBreak(); + await cli.waitForPrompt(); + await cli.command('exec JSON.stringify(process.argv.slice(2))'); + // Check that it's parsable as usual. + assert.match( + cli.output, + /\["--json","--preview","--timeout=1","--expr","value"\]/, + ); + } finally { + const code = await cli.quit(); + assert.strictEqual(code, 0); + } +})().then(common.mustCall()); diff --git a/test/parallel/test-debugger-probe-child-inspect-port-zero.js b/test/parallel/test-debugger-probe-child-inspect-port-zero.js new file mode 100644 index 00000000000000..a6f03ad9f41cbc --- /dev/null +++ b/test/parallel/test-debugger-probe-child-inspect-port-zero.js @@ -0,0 +1,38 @@ +// This tests child --inspect-port=0 pass-through in probe mode. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--json', + '--probe', `${probeScript}:12`, + '--expr', 'finalValue', + '--', + '--inspect-port=0', + probeScript, +], { + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [{ + expr: 'finalValue', + target: [probeScript, 12], + }], + results: [{ + probe: 0, + event: 'hit', + hit: 1, + result: { type: 'number', value: 81, description: '81' }, + }, { + event: 'completed', + }], + }); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-global-option-order.js b/test/parallel/test-debugger-probe-global-option-order.js new file mode 100644 index 00000000000000..9e247c3213178a --- /dev/null +++ b/test/parallel/test-debugger-probe-global-option-order.js @@ -0,0 +1,36 @@ +// This tests that global probe options can appear after the first --probe. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--probe', `${probeScript}:12`, + '--expr', 'finalValue', + '--json', + probeScript, +], { + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [{ + expr: 'finalValue', + target: [probeScript, 12], + }], + results: [{ + probe: 0, + event: 'hit', + hit: 1, + result: { type: 'number', value: 81, description: '81' }, + }, { + event: 'completed', + }], + }); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-json-preview.js b/test/parallel/test-debugger-probe-json-preview.js new file mode 100644 index 00000000000000..62b9edfe489c4a --- /dev/null +++ b/test/parallel/test-debugger-probe-json-preview.js @@ -0,0 +1,98 @@ +// This tests debugger probe JSON preview opt-in for object-like values. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeTypesScript } = require('../common/debugger-probe'); + +const location = `${probeTypesScript}:17`; + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--json', + '--preview', + '--probe', location, + '--expr', 'objectValue', + '--probe', location, + '--expr', 'arrayValue', + '--probe', location, + '--expr', 'errorValue', + probeTypesScript, +], { + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [ + { expr: 'objectValue', target: [probeTypesScript, 17] }, + { expr: 'arrayValue', target: [probeTypesScript, 17] }, + { expr: 'errorValue', target: [probeTypesScript, 17] }, + ], + results: [ + { + probe: 0, + event: 'hit', + hit: 1, + result: { + type: 'object', + description: 'Object', + preview: { + type: 'object', + description: 'Object', + overflow: false, + properties: [ + { name: 'alpha', type: 'number', value: '1' }, + { name: 'beta', type: 'string', value: 'two' }, + ], + }, + }, + }, + { + probe: 1, + event: 'hit', + hit: 1, + result: { + type: 'object', + subtype: 'array', + description: 'Array(3)', + preview: { + type: 'object', + subtype: 'array', + description: 'Array(3)', + overflow: false, + properties: [ + { name: '0', type: 'number', value: '1' }, + { name: '1', type: 'string', value: 'two' }, + { name: '2', type: 'number', value: '3' }, + ], + }, + }, + }, + { + probe: 2, + event: 'hit', + hit: 1, + result: { + type: 'object', + subtype: 'error', + description: 'Error: boom', + preview: { + type: 'object', + subtype: 'error', + description: 'Error: boom', + overflow: false, + properties: [ + { name: 'stack', type: 'string', value: 'Error: boom' }, + { name: 'message', type: 'string', value: 'boom' }, + ], + }, + }, + }, + { event: 'completed' }, + ], + }); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-json-special-values.js b/test/parallel/test-debugger-probe-json-special-values.js new file mode 100644 index 00000000000000..ea18a33f1e9ac7 --- /dev/null +++ b/test/parallel/test-debugger-probe-json-special-values.js @@ -0,0 +1,142 @@ +// This tests debugger probe JSON output for stable special-cased values. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeTypesScript } = require('../common/debugger-probe'); + +const location = `${probeTypesScript}:17`; + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--json', + '--probe', location, + '--expr', 'stringValue', + '--probe', location, + '--expr', 'booleanValue', + '--probe', location, + '--expr', 'undefinedValue', + '--probe', location, + '--expr', 'nullValue', + '--probe', location, + '--expr', 'nanValue', + '--probe', location, + '--expr', 'bigintValue', + '--probe', location, + '--expr', 'symbolValue', + '--probe', location, + '--expr', 'functionValue', + '--probe', location, + '--expr', 'objectValue', + '--probe', location, + '--expr', 'arrayValue', + '--probe', location, + '--expr', 'errorValue', + probeTypesScript, +], { + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [ + { expr: 'stringValue', target: [probeTypesScript, 17] }, + { expr: 'booleanValue', target: [probeTypesScript, 17] }, + { expr: 'undefinedValue', target: [probeTypesScript, 17] }, + { expr: 'nullValue', target: [probeTypesScript, 17] }, + { expr: 'nanValue', target: [probeTypesScript, 17] }, + { expr: 'bigintValue', target: [probeTypesScript, 17] }, + { expr: 'symbolValue', target: [probeTypesScript, 17] }, + { expr: 'functionValue', target: [probeTypesScript, 17] }, + { expr: 'objectValue', target: [probeTypesScript, 17] }, + { expr: 'arrayValue', target: [probeTypesScript, 17] }, + { expr: 'errorValue', target: [probeTypesScript, 17] }, + ], + results: [ + { + probe: 0, + event: 'hit', + hit: 1, + result: { type: 'string', value: 'hello' }, + }, + { + probe: 1, + event: 'hit', + hit: 1, + result: { type: 'boolean', value: true }, + }, + { + probe: 2, + event: 'hit', + hit: 1, + result: { type: 'undefined' }, + }, + { + probe: 3, + event: 'hit', + hit: 1, + result: { type: 'object', subtype: 'null', value: null }, + }, + { + probe: 4, + event: 'hit', + hit: 1, + result: { type: 'number', unserializableValue: 'NaN', description: 'NaN' }, + }, + { + probe: 5, + event: 'hit', + hit: 1, + result: { type: 'bigint', unserializableValue: '1n', description: '1n' }, + }, + { + probe: 6, + event: 'hit', + hit: 1, + result: { type: 'symbol', description: 'Symbol(tag)' }, + }, + { + probe: 7, + event: 'hit', + hit: 1, + result: { + type: 'function', + description: '() => 1', + }, + }, + { + probe: 8, + event: 'hit', + hit: 1, + result: { + type: 'object', + description: 'Object', + }, + }, + { + probe: 9, + event: 'hit', + hit: 1, + result: { + type: 'object', + subtype: 'array', + description: 'Array(3)', + }, + }, + { + probe: 10, + event: 'hit', + hit: 1, + result: { + type: 'object', + subtype: 'error', + description: 'Error: boom', + }, + }, + { event: 'completed' }, + ], + }); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-json.js b/test/parallel/test-debugger-probe-json.js new file mode 100644 index 00000000000000..e95b7ffbd72587 --- /dev/null +++ b/test/parallel/test-debugger-probe-json.js @@ -0,0 +1,66 @@ +// This tests debugger probe JSON output for duplicate and multi-probe hits. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--json', + '--probe', `${probeScript}:8`, + '--expr', 'index', + '--probe', `${probeScript}:8`, + '--expr', 'total', + '--probe', `${probeScript}:12`, + '--expr', 'finalValue', + probeScript, +], { + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [ + { expr: 'index', target: [probeScript, 8] }, + { expr: 'total', target: [probeScript, 8] }, + { expr: 'finalValue', target: [probeScript, 12] }, + ], + results: [ + { + probe: 0, + event: 'hit', + hit: 1, + result: { type: 'number', value: 0, description: '0' }, + }, + { + probe: 1, + event: 'hit', + hit: 1, + result: { type: 'number', value: 0, description: '0' }, + }, + { + probe: 0, + event: 'hit', + hit: 2, + result: { type: 'number', value: 1, description: '1' }, + }, + { + probe: 1, + event: 'hit', + hit: 2, + result: { type: 'number', value: 40, description: '40' }, + }, + { + probe: 2, + event: 'hit', + hit: 1, + result: { type: 'number', value: 81, description: '81' }, + }, + { event: 'completed' }, + ], + }); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-launch.js b/test/parallel/test-debugger-probe-launch.js new file mode 100644 index 00000000000000..09511fabf2caaf --- /dev/null +++ b/test/parallel/test-debugger-probe-launch.js @@ -0,0 +1,26 @@ +// This tests that probe launch failures fail fast instead of timing out. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndExit } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndExit(process.execPath, [ + 'inspect', + '--probe', `${probeScript}:12`, + '--expr', 'finalValue', + '--', + '--not-a-real-node-flag', + probeScript, +], { + signal: null, + status: 1, + stderr(output) { + assert.match(output, /bad option: --not-a-real-node-flag/); + assert.match(output, /Target exited before the inspector was ready \(code 9\)/); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-miss.js b/test/parallel/test-debugger-probe-miss.js new file mode 100644 index 00000000000000..7dc52e9a07b0c0 --- /dev/null +++ b/test/parallel/test-debugger-probe-miss.js @@ -0,0 +1,29 @@ +// This tests that probe sessions report unresolved breakpoints as misses. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { missScript } = require('../common/debugger-probe'); + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--json', + '--probe', `${missScript}:99`, + '--expr', '42', + missScript, +], { + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [{ expr: '42', target: [missScript, 99] }], + results: [{ + event: 'miss', + pending: [0], + }], + }); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-missing-expr.js b/test/parallel/test-debugger-probe-missing-expr.js new file mode 100644 index 00000000000000..e6d4f13ac6f26d --- /dev/null +++ b/test/parallel/test-debugger-probe-missing-expr.js @@ -0,0 +1,19 @@ +// This tests that probe mode rejects a --probe without a matching --expr. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const { spawnSyncAndExit } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndExit(process.execPath, [ + 'inspect', + '--probe', `${probeScript}:12`, + probeScript, +], { + signal: null, + status: 1, + stderr: /Each --probe must be followed immediately by --expr/, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-requires-separator.js b/test/parallel/test-debugger-probe-requires-separator.js new file mode 100644 index 00000000000000..8aae48052c1a29 --- /dev/null +++ b/test/parallel/test-debugger-probe-requires-separator.js @@ -0,0 +1,21 @@ +// This tests that child Node.js flags require -- in probe mode. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const { spawnSyncAndExit } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndExit(process.execPath, [ + 'inspect', + '--probe', `${probeScript}:12`, + '--expr', 'finalValue', + '--inspect-port=0', + probeScript, +], { + signal: null, + status: 1, + stderr: /Use -- before child Node\.js flags in probe mode/, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-text-special-values.js b/test/parallel/test-debugger-probe-text-special-values.js new file mode 100644 index 00000000000000..17ff62ef4a086d --- /dev/null +++ b/test/parallel/test-debugger-probe-text-special-values.js @@ -0,0 +1,67 @@ +// This tests debugger probe text output for stable special-cased values. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeTypesScript } = require('../common/debugger-probe'); + +const location = `${probeTypesScript}:17`; + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--probe', location, + '--expr', 'stringValue', + '--probe', location, + '--expr', 'booleanValue', + '--probe', location, + '--expr', 'undefinedValue', + '--probe', location, + '--expr', 'nullValue', + '--probe', location, + '--expr', 'nanValue', + '--probe', location, + '--expr', 'bigintValue', + '--probe', location, + '--expr', 'symbolValue', + '--probe', location, + '--expr', 'functionValue', + '--probe', location, + '--expr', 'objectValue', + '--probe', location, + '--expr', 'arrayValue', + '--probe', location, + '--expr', 'errorValue', + probeTypesScript, +], { + stdout(output) { + assert.strictEqual(output, [ + `Hit 1 at ${location}`, + ' stringValue = "hello"', + `Hit 1 at ${location}`, + ' booleanValue = true', + `Hit 1 at ${location}`, + ' undefinedValue = undefined', + `Hit 1 at ${location}`, + ' nullValue = null', + `Hit 1 at ${location}`, + ' nanValue = NaN', + `Hit 1 at ${location}`, + ' bigintValue = 1n', + `Hit 1 at ${location}`, + ' symbolValue = Symbol(tag)', + `Hit 1 at ${location}`, + ' functionValue = () => 1', + `Hit 1 at ${location}`, + ' objectValue = {alpha: 1, beta: "two"}', + `Hit 1 at ${location}`, + ' arrayValue = [1, "two", 3]', + `Hit 1 at ${location}`, + ' errorValue = Error: boom', + 'Completed', + ].join('\n')); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-text.js b/test/parallel/test-debugger-probe-text.js new file mode 100644 index 00000000000000..75be98611d378b --- /dev/null +++ b/test/parallel/test-debugger-probe-text.js @@ -0,0 +1,24 @@ +// This tests debugger probe text output for a single hit. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--probe', `${probeScript}:12`, + '--expr', 'finalValue', + probeScript, +], { + stdout(output) { + assert.strictEqual(output, + `Hit 1 at ${probeScript}:12\n` + + ' finalValue = 81\n' + + 'Completed'); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-timeout.js b/test/parallel/test-debugger-probe-timeout.js new file mode 100644 index 00000000000000..d4728407bc5924 --- /dev/null +++ b/test/parallel/test-debugger-probe-timeout.js @@ -0,0 +1,36 @@ +// This tests probe session timeout behavior and teardown. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndExit } = require('../common/child_process'); +const { timeoutScript } = require('../common/debugger-probe'); + +spawnSyncAndExit(process.execPath, [ + 'inspect', + '--json', + '--timeout=200', + '--probe', `${timeoutScript}:99`, + '--expr', '1', + timeoutScript, +], { + signal: null, + status: 1, + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [{ expr: '1', target: [timeoutScript, 99] }], + results: [{ + event: 'timeout', + pending: [0], + error: { + code: 'probe_timeout', + message: `Timed out after 200ms waiting for probes: ${timeoutScript}:99`, + }, + }], + }); + }, + trim: true, +});