Skip to content

node:vm compatibility - #19703

Merged
Jarred-Sumner merged 85 commits into
mainfrom
kai/vm-compat-modules
May 24, 2025
Merged

Jarred-Sumner merged 85 commits into
mainfrom
kai/vm-compat-modules

Conversation

@heimskr

@heimskr heimskr commented May 16, 2025

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds support for vm.SourceTextModule and some other features in node:vm.

  • Documentation or TypeScript types
  • Code changes

How did you verify your code works?

Added Node.js tests


static RefPtr<JSC::CachedBytecode> getBytecode(JSGlobalObject* globalObject, JSC::ProgramExecutable* executable, JSC::SourceCode source);
static JSC::EncodedJSValue createCachedData(JSGlobalObject* globalObject, JSC::SourceCode source);
bool extractCachedData(JSValue cachedDataValue, WTF::Vector<uint8_t>& outCachedData)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a TODO to investigate if we can avoid this clone?

auto _ = ERR::INVALID_ARG_TYPE(scope, globalObject, "options"_s, "object"_s, optionsArg);
return false;
}
SourceCode sourceCode(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
SourceCode sourceCode(
RETURN_IF_EXCEPTION(scope, nullptr);
SourceCode sourceCode(

vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "isModuleNamespaceObject"_s)),
JSC::JSFunction::create(vm, globalObject, 0, "isModuleNamespaceObject"_s, vmIsModuleNamespaceObject, ImplementationVisibility::Public), 1);
obj->putDirect(
vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "kUnlinked"_s)),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use a single const enum in the file for these values instead and then it'll participate in constant folding and avoid the dynamic property lookup?

lexicallyScopedFeatures, JSParserScriptMode::Classic, SourceParseMode::ProgramMode,
FunctionMode::None, SuperBinding::NotNeeded, ConstructorKind::None, DerivedContextType::None,
isEvalNode, EvalContextType::None, nullptr);
if (JSValue lineOffsetOpt = options->getIfPropertyExists(globalObject, Identifier::fromString(vm, "lineOffset"_s))) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (JSValue lineOffsetOpt = options->getIfPropertyExists(globalObject, Identifier::fromString(vm, "lineOffset"_s))) {
RETURN_IF_EXCEPTION(scope, false);
if (JSValue lineOffsetOpt = options->getIfPropertyExists(globalObject, Identifier::fromString(vm, "lineOffset"_s))) {


program = parser.parse<ProgramNode>(error, name, ParsingContext::Normal);
}
if (JSValue columnOffsetOpt = options->getIfPropertyExists(globalObject, Identifier::fromString(vm, "columnOffset"_s))) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (JSValue columnOffsetOpt = options->getIfPropertyExists(globalObject, Identifier::fromString(vm, "columnOffset"_s))) {
RETURN_IF_EXCEPTION(scope, false);
if (JSValue columnOffsetOpt = options->getIfPropertyExists(globalObject, Identifier::fromString(vm, "columnOffset"_s))) {

return NodeVMSourceTextModule::create(vm, globalObject, args);
}

if (disambiguator.inherits(JSArray::info())) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (disambiguator.inherits(JSArray::info())) {
if (JSC::isJSArray(disambiguator)) {


JSArray* array = constructEmptyArray(globalObject, nullptr, requests.size());

for (unsigned i = 0; const NodeVMModuleRequest& request : requests) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest constructing the array at the end of this, appending to a MarkedArgumentsBuffer, checking for exceptions and then passing the MarkedArgumentsBuffer to constructEmptyArray.

Suggested change
for (unsigned i = 0; const NodeVMModuleRequest& request : requests) {
MarkedArgumentsBuffer array;
for ( const NodeVMModuleRequest& request : requests) {
array.append(request.toJS(globalObject));
RETURN_IF_EXCEPTION(scope, {});
}
return JSValue::encode(constructEmptyArray(globalObject, array));


JSC::LexicallyScopedFeatures lexicallyScopedFeatures = globalObject->globalScopeExtension() ? JSC::TaintedByWithScopeLexicallyScopedFeature : JSC::NoLexicallyScopedFeatures;
JSC::SourceCodeKey key(source, {}, JSC::SourceCodeType::ProgramType, lexicallyScopedFeatures, JSC::JSParserScriptMode::Classic, JSC::DerivedContextType::None, JSC::EvalContextType::None, false, {}, std::nullopt);
Ref<JSC::CachedBytecode> cachedBytecode = JSC::CachedBytecode::create(std::span(cachedData), nullptr, {});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Ref<JSC::CachedBytecode> cachedBytecode = JSC::CachedBytecode::create(std::span(cachedData), nullptr, {});
// TODO: check if this clones the cached data an extra time
Ref<JSC::CachedBytecode> cachedBytecode = JSC::CachedBytecode::create(std::span(cachedData), nullptr, {});

ASSERT(m_cachedBytecode);

std::span<const uint8_t> bytes = m_cachedBytecode->span();
m_cachedBytecodeBuffer.set(vm(), this, WebCore::createBuffer(globalObject(), bytes));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs an exception scope technically

Suggested change
m_cachedBytecodeBuffer.set(vm(), this, WebCore::createBuffer(globalObject(), bytes));
m_cachedBytecodeBuffer.set(vm(), this, WebCore::createBuffer(globalObject(), bytes));

uint32_t lineOffset = lineOffsetValue.toUInt32(globalObject);
uint32_t columnOffset = columnOffsetValue.toUInt32(globalObject);

Ref<StringSourceProvider> sourceProvider = StringSourceProvider::create(sourceTextValue.toWTFString(globalObject), SourceOrigin {}, String {}, SourceTaintedOrigin::Untainted,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the SourceOrigin need to be set here too?

Suggested change
Ref<StringSourceProvider> sourceProvider = StringSourceProvider::create(sourceTextValue.toWTFString(globalObject), SourceOrigin {}, String {}, SourceTaintedOrigin::Untainted,
auto&& sourceCodeString = sourceTextValue.toWTFString(globalObject);
RETURN_IF_EXCEPTION(scope, nullptr);
Ref<StringSourceProvider> sourceProvider = StringSourceProvider::create(sourceCodeString, SourceOrigin {}, String {}, SourceTaintedOrigin::Untainted,

return constructEmptyArray(globalObject, nullptr, 0);
}

JSArray* requestsArray = constructEmptyArray(globalObject, nullptr, requests.size());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
JSArray* requestsArray = constructEmptyArray(globalObject, nullptr, requests.size());
MarkedArgumentsBuffer requestsArray;


requestObject->putDirect(vm, attributesIdentifier, attributesObject);
addModuleRequest({ WTF::String(*request.m_specifier), WTFMove(attributeMap) });
requestsArray->putDirectIndex(globalObject, i, requestObject);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
requestsArray->putDirectIndex(globalObject, i, requestObject);
requestsArray.append(requestObject);

requestsArray->putDirectIndex(globalObject, i, requestObject);
}

m_moduleRequestsArray.set(vm, this, requestsArray);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
m_moduleRequestsArray.set(vm, this, requestsArray);
auto* jsArray = JSC::constructArray(globalObject, requestsArray))
m_moduleRequestsArray.set(vm, this, jsArray);
return jsArray;

if (!m_cachedBytecodeBuffer) {
RefPtr<CachedBytecode> cachedBytecode = bytecode(globalObject);
std::span<const uint8_t> bytes = cachedBytecode->span();
m_cachedBytecodeBuffer.set(vm(), this, WebCore::createBuffer(globalObject, bytes));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically needs an exception check if bytes > 4.7 GB

WriteBarrier<ModuleProgramExecutable> m_cachedExecutable;
WriteBarrier<JSUint8Array> m_cachedBytecodeBuffer;
WriteBarrier<Exception> m_evaluationException;
RefPtr<CachedBytecode> m_bytecode;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
RefPtr<CachedBytecode> m_bytecode;
// TODO: investigate if we're cloning the source code and the bytecode multiple times and figure out how to not do that
RefPtr<CachedBytecode> m_bytecode;

Comment thread src/vm/SigintWatcher.h Outdated
Comment thread src/vm/SigintWatcher.h Outdated
std::atomic_bool m_installed = false;
std::atomic_flag m_waiting {};
Semaphore m_semaphore;
std::mutex m_globalObjectsMutex;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
std::mutex m_globalObjectsMutex;
WTF::Lock m_globalObjectsMutex;

Comment thread src/vm/SigintWatcher.h Outdated
Comment thread src/js/node/vm.ts

// Iterates the module requests and links with the linker.
// Specifiers should be aligned with the moduleRequests array in order.
const specifiers = Array(moduleRequests.length);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const specifiers = Array(moduleRequests.length);
const moduleRequestsCount = moduleRequests.length;
const specifiers = Array(moduleRequestsCount);

Comment thread src/js/node/vm.ts
const specifiers = Array(moduleRequests.length);
const modulePromises = Array(moduleRequests.length);
// Iterates with index to avoid calling into userspace with `Symbol.iterator`.
for (let idx = 0; idx < moduleRequests.length; idx++) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for (let idx = 0; idx < moduleRequests.length; idx++) {
for (let idx = 0; idx < moduleRequestsCount; idx++) {

Comment thread src/js/node/vm.ts
// Iterates the module requests and links with the linker.
// Specifiers should be aligned with the moduleRequests array in order.
const specifiers = Array(moduleRequests.length);
const modulePromises = Array(moduleRequests.length);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const modulePromises = Array(moduleRequests.length);
const modulePromises = Array(moduleRequestsCount);

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few small nitpicky comments but very close!

@Jarred-Sumner
Jarred-Sumner merged commit 392212b into main May 24, 2025
@Jarred-Sumner
Jarred-Sumner deleted the kai/vm-compat-modules branch May 24, 2025 06:00
Jarred-Sumner pushed a commit that referenced this pull request Aug 18, 2026
…itions (#38228)

### Problem
- `new vm.Script('1', { lineOffset: 2147483647 })` and
`vm.compileFunction('return 1', [], { lineOffset: 2147483647 })` abort
on assertion-enabled builds with `ASSERTION FAILED: line >= 0` in
`JSC::JSTextPosition::checkConsistency()` (ParserTokens.h:231). Release
builds survive and print wrapped line numbers (`big.js:-2147483648`).
- `lineOffset: 2147483646` plus a two-line source, and `new
vm.SourceTextModule('1;\n2;', { lineOffset: 2147483647 })`, abort the
same way.
- Node's validator accepts any int32 for `lineOffset` / `columnOffset`,
and the bindings hand the value to JSC unchanged (`NodeVMScript.cpp`
`makeSource(...)`, `NodeVM.cpp` `constructAnonymousFunction`,
`NodeVMSourceTextModule.cpp` `create`). JSC stores positions as `int`:
`OrdinalNumber::oneBasedInt()` is `value + 1`, and the lexer does
`++m_lineNumber` per line terminator starting from that (`Lexer.cpp`
`setCode` / `shiftLineTerminator`), so any offset within (source line
count + 1) of `INT32_MAX` overflows. The `+ 1` overflow is undefined
behaviour, which is why a debug build and the optimized asan build fail
on different inputs.

### Fix
- Adds `clampOffsetForSource(offset, sourceLength)` (`NodeVM.h` /
`NodeVM.cpp`) and applies it to both offsets in `vm.Script`,
`vm.compileFunction` and `SourceTextModule` before the `SourceCode` is
built: the offset is capped at `INT32_MAX - 1 - sourceLength`.
- Correct because a source cannot hold more line terminators, or a
longer first line, than it has code units, so every position JSC derives
(`+1` for one-based, per-line increments, first-line column additions,
and `decorateParseErrorStack`'s own `line + offset`) stays within `int`.
For any realistic offset the clamp is a no-op; an absurd one now reports
positions just below `INT32_MAX` instead of undefined behaviour (e.g.
`big.js:2147483627` for the 20-character repro).
- `compileFunction` now builds its wrapper program before the standalone
parse of the body and clamps against the wrapper's length, since that is
the longest text it parses (the body is re-parsed as line 2 of it). The
early `RETURN_IF_EXCEPTION` after building the wrapper replaces
continuing with a null string.
- The options struct itself is clamped, so `decorateParseErrorStack` and
the provider's start position see the same value as the parser.
`SourceTextModule` still passes its (zero-based) values to `SourceCode`
exactly as before; the off-by-one there is fixed separately in #38235,
which touches the same lines. Whichever of the two lands second only has
to wrap that PR's `TextPosition` operands in `clampOffsetForSource`; the
module test case here uses `INT32_MAX - 1` with a three-line source so
it fails without the clamp under either line-numbering.
- Removes the native `runInNewContext` / `runInThisContext` host
functions from `NodeVM.cpp` (second commit), plus the `BaseVMOptions`
constructor only they used. `vm.ts` has built both APIs on `Script`
since #19703, so they were unreachable, and they were the only remaining
places that built a `SourceCode` from unclamped offsets.
- Verified with `test/js/node/vm/vm.test.ts` (`describe("node:vm
lineOffset/columnOffset at the edge of int32")`): 11 subprocess cases
covering construction of all three APIs and the reported line/column of
runtime and compile-time errors. Without the `src/` change 9 of the 11
fail on the debug build (7 abort with the assertion above; the
line/column cases otherwise report `1` / `16`, i.e. the offset silently
lost); of the remaining 2, the one-line `Script` case (the reported
repro) only fails on optimized assertion builds, and the `columnOffset`
construction case never aborted (the column value itself is checked
separately and did fail). With the change all 11 pass.
- Also ran the rest of `vm.test.ts` (225 pass), `vm-sourceUrl.test.ts`,
and all 95 `test/js/node/test/parallel/test-vm-*.js` files (covers
`runInNewContext` / `runInThisContext` after the removal); normal
offsets (`lineOffset: 5`, negative offsets, `columnOffset: 10`) report
the same lines and columns as before.

### Background
- `lineOffset` / `columnOffset` tell `node:vm` where a snippet sits
inside a larger file so error positions line up with that file; they are
zero-based and may be negative. Node validates them as int32 and
otherwise passes them through, so `2147483647` is a legal input.
- `OrdinalNumber` (WTF) wraps an `int` that can be read as zero- or
one-based; `TextPosition` is a line/column pair of them. A JSC
`SourceCode` carries the one-based first line and column to start
counting from, and the lexer counts lines upward from there in a plain
`int`.
- `JSTextPosition::checkConsistency()` is a debug-only assertion that
every token position the lexer produces has a non-negative line; it is
what turns the overflow into an abort on assertion builds.
- `vm.compileFunction` is implemented by wrapping the body in `(function
(params) {\n<body>\n})` and compiling that as a program; the user's body
is therefore line 2 of what JSC actually parses.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 2 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/node/vm/vm.test.ts

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants