Skip to content

WIP [SPIKE] - test(NODE-7345): custom vm.Context runner for mocha#4844

Draft
tadjik1 wants to merge 13 commits intomainfrom
NODE-7345
Draft

WIP [SPIKE] - test(NODE-7345): custom vm.Context runner for mocha#4844
tadjik1 wants to merge 13 commits intomainfrom
NODE-7345

Conversation

@tadjik1
Copy link
Member

@tadjik1 tadjik1 commented Jan 20, 2026

Description

Summary of Changes

This POC implements a custom test runner using Node.js's vm module to execute driver intergation tests in an isolated environment. The goal is to enforce the architectural deprecation of Buffer and other globals in source files while permitting it in tests.

Notes for Reviewers

To run the tests:

MONGODB_URI=mongodb://localhost:27017,localhost:27018,localhost:27019 npm run check:runtime-independency

Key features implemented / tested:

  • A custom CommonJS-style loader (loadInSandbox) that transpiles TypeScript on the fly and manages a dedicated moduleCache for the sandbox.
  • Injects a Proxy into the global scope of source files that throws a Forbidden error if Buffer methods (like .from, .alloc) or constructors are accessed.
  • Implements Symbol.hasInstance on exported BSON and Error classes.
  • Uses Object.prototype.isPrototypeOf to bridge the gap between Host-context assertions and Sandbox-context instances, ensuring instanceof checks pass without infinite recursion.

Known problems, future tasks:

  • All necessary globals (Date, Error, etc.) needs to be mapped in the vm.createContext to keep basic prototype chains aligned with the host.
  • Refining the Proxy for Buffer (it ends up with Uncaught RangeError: Maximum call stack size exceeded sometimes).
  • Chai doesn't work well with errors (seems like prototype chain is broken).

What is the motivation for this change?

Release Highlight

Release notes highlight

Double check the following

  • Lint is passing (npm run check:lint)
  • Self-review completed using the steps outlined here
  • PR title follows the correct format: type(NODE-xxxx)[!]: description
    • Example: feat(NODE-1234)!: rewriting everything in coffeescript
  • Changes are covered by tests
  • New TODOs have a related JIRA ticket


function createSandboxContext(filename) {
const exportsContainer = {};
return {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
return {
return {
__proto__: null,

Just so none of the property lookups for built-ins fall back to the global one unintentionally

Copy link
Member Author

@tadjik1 tadjik1 Jan 23, 2026

Choose a reason for hiding this comment

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

thanks, included!

exports: exportsContainer,
module: { exports: exportsContainer },
__filename: filename,
__dirname: path.dirname(filename),
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd try to emulate the behavior of the built-in CommonJS evaluator as closely as possible.

None of these are typically globals, and modules do share a single global – all of require, module, exports, __filenameand__dirname` are injected as parameters to an implicit function that is used to wrap around user code

e.g. https://github.com/mongodb-js/mongosh/blob/5ea0a3617c9692cbb5c1e632a859d6688b69a6f1/packages/shell-api/src/runtime-independence.spec.ts#L41-L46

(you should generally be able to use that file as a reference for a lot of this)

Copy link
Member Author

Choose a reason for hiding this comment

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

That is awesome, thanks! I haven't seen this file before, this is huge help indeed.

Copy link
Member Author

Choose a reason for hiding this comment

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

Added the same style wrapper around the script and define "globals" (context) just once so it's shared.

return function sandboxRequire(moduleIdentifier) {
if (!moduleIdentifier.startsWith('.')) {
return require(moduleIdentifier);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this intentional? I figured we'd want to require almost everything inside the sandbox except for Node.js built-ins and maybe a list of modules that are explicit exceptions to this rule

Copy link
Member Author

Choose a reason for hiding this comment

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

This is temporary solution to be able to import all dev dependencies and to highlight the more general idea (this ticket and PR is spike), so I was focusing mostly on "running existing integration tests inside vm.Context sandbox". But you are absolutely right, ideally all prod dependencies should be required from the inside, otherwise it's pointless (if driver is browser-compatible but, say bson not - we can't run application).

@PavelSafronov
Copy link
Contributor

Re: testing - there's a command for running tests against 3 local servers, but what is the command we should use to start those 3 servers?

I tried starting a replica set (TOPOLOGY=replica_set AUTH=noauth bash ./.evergreen/run-orchestration.sh) and executed the test command, but encountered 25 test failures.

@tadjik1 tadjik1 changed the title test(NODE-7345): custom vm.Context runner for mocha WIP - test(NODE-7345): custom vm.Context runner for mocha Jan 21, 2026
@tadjik1
Copy link
Member Author

tadjik1 commented Jan 21, 2026

Re: testing - there's a command for running tests against 3 local servers, but what is the command we should use to start those 3 servers?

I tried starting a replica set (TOPOLOGY=replica_set AUTH=noauth bash ./.evergreen/run-orchestration.sh) and executed the test command, but encountered 25 test failures.

I actually don't know the answer yet. I mean you did everything right, I personally use mlaunch but it doesn't matter how to run the server.
Tests are failing and I would need to dig deeper to understand if this can be easily fixed (or if this setup is not appropriate for some of our integration tests).

@tadjik1 tadjik1 changed the title WIP - test(NODE-7345): custom vm.Context runner for mocha WIP [SPIKE] - test(NODE-7345): custom vm.Context runner for mocha Jan 21, 2026
Comment on lines 156 to 163
// force instanceof to work across contexts by defining custom `instanceof` function
Object.defineProperty(value, Symbol.hasInstance, {
value: (instance: any) => {
return (
instance && (instance.constructor.name === value.name || instance instanceof value)
);
}
});
Copy link
Member Author

Choose a reason for hiding this comment

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

I don't really like this part, but I found this problem a little bit tricky. The problem is that host environment (and consequently chai) has its' own set of Error classes, so instanceof from inside the sandbox is broken. @addaleax, maybe there is more convenient way to make it work rather than re-defining instanceof?

Comment on lines +92 to +98
const sandboxedDependencies = ['bson'];
const isSandboxedDep = sandboxedDependencies.some(
dep => moduleIdentifier === dep || moduleIdentifier.startsWith(`${dep}/`)
);
if (!moduleIdentifier.startsWith('.') && !isSandboxedDep) {
return require(moduleIdentifier);
}
Copy link
Member Author

Choose a reason for hiding this comment

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

@addaleax this seems to work kinda of fine, we can list dependencies that should be loaded from within the sandbox. i decided to go with this approach because list of dev and prod dependencies we need to import in host is large. but maybe i'm missing something and it should be the other way around (everything is inside except certain builtin modules)?

}

// clientmetadata requires package.json to fetch driver's version
if (realPath.endsWith('.json')) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want to do this for any *.json file, or only for package.json?

Copy link
Member Author

Choose a reason for hiding this comment

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

changed to package.json only

return result;
} catch (err: any) {
moduleCache.delete(realPath);
console.error(`Error running ${realPath} in sandbox:`, err.stack);
Copy link
Contributor

Choose a reason for hiding this comment

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

err.stack doesn't include code, message, and a bunch of other important info. Suggest we log the entire err object.

Copy link
Member Author

Choose a reason for hiding this comment

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

thanks!

const isTestFile = realPath.includes('.test.ts') || realPath.includes('.test.js');

let localBuffer = Buffer;
if (isSourceFile && !isTestFile) {
Copy link
Member Author

Choose a reason for hiding this comment

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

I guess this or similar way we can differentiate between source and test files, and allow 3rd party libraries (like bson) to use globals like Buffer. cc @PavelSafronov

const isBSON = realPath.includes('node_modules/bson');
const isError = realPath.includes('src/error.ts');

if (isBSON || isError) {
Copy link
Member Author

@tadjik1 tadjik1 Jan 26, 2026

Choose a reason for hiding this comment

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

In order instanceof to work we have to propagate certain classes between contexts, most notable all Mongo*Errors as well as bson.Long.

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