Skip to content

Commit 21f7968

Browse files
authored
feat: replace Node.js REPL with plain vm context for script usage MONGOSH-1720 (#1849)
- Allow running `MongoshNodeRepl` instances either based on: - The existing mechanism for running code, which is spinning up a Node.js REPL and using it to evaluate code; or - A more lightweight mechanism that only creates a `vm` context and then run code using that context directly. - Introduce a new command-line switch, `--jsContext`, that can be used to explicitly select the desired behavior, with possible values of `repl`, `plain-vm` and `auto`. The default behavior is to switch depending on whether the CLI will enter interactive mode or not. Running in `plain-vm` mode will significantly improve runtime performance (a 6× reduction of script run time in local testing), coming from the removal of async context tracking that the REPL uses to identify async operations which were originally spawned from the REPL instance in question. This lack of async context tracking comes with some implications, in particular asynchronously thrown errors (in the Node.js sense, e.g. `setImmediate(() => { throw ... })`) will lead to slightly different behavior (e.g. different error code and error output). That is probably acceptable breakage in this context.
1 parent 20df659 commit 21f7968

File tree

12 files changed

+606
-299
lines changed

12 files changed

+606
-299
lines changed

packages/arg-parser/src/cli-options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface CliOptions {
2626
help?: boolean;
2727
host?: string;
2828
ipv6?: boolean;
29+
jsContext?: 'repl' | 'plain-vm' | 'auto';
2930
json?: boolean | 'canonical' | 'relaxed';
3031
keyVaultNamespace?: string;
3132
kmsURL?: string;

packages/cli-repl/src/arg-parser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const OPTIONS = {
3131
'gssapiServiceName',
3232
'sspiHostnameCanonicalization',
3333
'sspiRealmOverride',
34+
'jsContext',
3435
'host',
3536
'keyVaultNamespace',
3637
'kmsURL',

packages/cli-repl/src/cli-repl.spec.ts

Lines changed: 160 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -590,135 +590,174 @@ describe('CliRepl', function () {
590590
});
591591
});
592592

593-
context('files loaded from command line', function () {
594-
it('load a file if it has been specified on the command line', async function () {
595-
const filename1 = path.resolve(
596-
__dirname,
597-
'..',
598-
'test',
599-
'fixtures',
600-
'load',
601-
'hello1.js'
602-
);
603-
cliReplOptions.shellCliOptions.fileNames = [filename1];
604-
cliRepl = new CliRepl(cliReplOptions);
605-
await startWithExpectedImmediateExit(cliRepl, '');
606-
expect(output).to.include(`Loading file: ${filename1}`);
607-
expect(output).to.include('hello one');
608-
expect(exitCode).to.equal(0);
609-
});
593+
for (const jsContext of ['repl', 'plain-vm', undefined] as const) {
594+
context(
595+
`files loaded from command line (jsContext: ${
596+
jsContext ?? 'default'
597+
})`,
598+
function () {
599+
beforeEach(function () {
600+
cliReplOptions.shellCliOptions.jsContext = jsContext;
601+
});
602+
it('load a file if it has been specified on the command line', async function () {
603+
const filename1 = path.resolve(
604+
__dirname,
605+
'..',
606+
'test',
607+
'fixtures',
608+
'load',
609+
'hello1.js'
610+
);
611+
cliReplOptions.shellCliOptions.fileNames = [filename1];
612+
cliRepl = new CliRepl(cliReplOptions);
613+
await startWithExpectedImmediateExit(cliRepl, '');
614+
expect(output).to.include(`Loading file: ${filename1}`);
615+
expect(output).to.include('hello one');
616+
expect(exitCode).to.equal(0);
617+
});
610618

611-
it('load two files if it has been specified on the command line', async function () {
612-
const filename1 = path.resolve(
613-
__dirname,
614-
'..',
615-
'test',
616-
'fixtures',
617-
'load',
618-
'hello1.js'
619-
);
620-
const filename2 = path.resolve(
621-
__dirname,
622-
'..',
623-
'test',
624-
'fixtures',
625-
'load',
626-
'hello2.js'
627-
);
628-
cliReplOptions.shellCliOptions.fileNames = [filename1, filename2];
629-
cliRepl = new CliRepl(cliReplOptions);
630-
await startWithExpectedImmediateExit(cliRepl, '');
631-
expect(output).to.include(`Loading file: ${filename1}`);
632-
expect(output).to.include('hello one');
633-
expect(output).to.include(`Loading file: ${filename2}`);
634-
expect(output).to.include('hello two');
635-
expect(exitCode).to.equal(0);
636-
});
619+
it('load two files if it has been specified on the command line', async function () {
620+
const filename1 = path.resolve(
621+
__dirname,
622+
'..',
623+
'test',
624+
'fixtures',
625+
'load',
626+
'hello1.js'
627+
);
628+
const filename2 = path.resolve(
629+
__dirname,
630+
'..',
631+
'test',
632+
'fixtures',
633+
'load',
634+
'hello2.js'
635+
);
636+
cliReplOptions.shellCliOptions.fileNames = [filename1, filename2];
637+
cliRepl = new CliRepl(cliReplOptions);
638+
await startWithExpectedImmediateExit(cliRepl, '');
639+
expect(output).to.include(`Loading file: ${filename1}`);
640+
expect(output).to.include('hello one');
641+
expect(output).to.include(`Loading file: ${filename2}`);
642+
expect(output).to.include('hello two');
643+
expect(exitCode).to.equal(0);
644+
});
637645

638-
it('does not print filenames if --quiet is passed', async function () {
639-
const filename1 = path.resolve(
640-
__dirname,
641-
'..',
642-
'test',
643-
'fixtures',
644-
'load',
645-
'hello1.js'
646-
);
647-
cliReplOptions.shellCliOptions.fileNames = [filename1];
648-
cliReplOptions.shellCliOptions.quiet = true;
649-
cliRepl = new CliRepl(cliReplOptions);
650-
await startWithExpectedImmediateExit(cliRepl, '');
651-
expect(output).not.to.include('Loading file');
652-
expect(output).to.include('hello one');
653-
expect(exitCode).to.equal(0);
654-
});
646+
it('does not print filenames if --quiet is passed', async function () {
647+
const filename1 = path.resolve(
648+
__dirname,
649+
'..',
650+
'test',
651+
'fixtures',
652+
'load',
653+
'hello1.js'
654+
);
655+
cliReplOptions.shellCliOptions.fileNames = [filename1];
656+
cliReplOptions.shellCliOptions.quiet = true;
657+
cliRepl = new CliRepl(cliReplOptions);
658+
await startWithExpectedImmediateExit(cliRepl, '');
659+
expect(output).not.to.include('Loading file');
660+
expect(output).to.include('hello one');
661+
expect(exitCode).to.equal(0);
662+
});
655663

656-
it('forwards the error it if loading the file throws', async function () {
657-
const filename1 = path.resolve(
658-
__dirname,
659-
'..',
660-
'test',
661-
'fixtures',
662-
'load',
663-
'throw.js'
664-
);
665-
cliReplOptions.shellCliOptions.fileNames = [filename1];
666-
cliRepl = new CliRepl(cliReplOptions);
667-
try {
668-
await cliRepl.start('', {});
669-
} catch (err: any) {
670-
expect(err.message).to.include('uh oh');
671-
}
672-
expect(output).to.include('Loading file');
673-
expect(output).not.to.include('uh oh');
674-
});
664+
it('forwards the error it if loading the file throws', async function () {
665+
const filename1 = path.resolve(
666+
__dirname,
667+
'..',
668+
'test',
669+
'fixtures',
670+
'load',
671+
'throw.js'
672+
);
673+
cliReplOptions.shellCliOptions.fileNames = [filename1];
674+
cliRepl = new CliRepl(cliReplOptions);
675+
try {
676+
await cliRepl.start('', {});
677+
} catch (err: any) {
678+
expect(err.message).to.include('uh oh');
679+
}
680+
expect(output).to.include('Loading file');
681+
expect(output).not.to.include('uh oh');
682+
});
675683

676-
it('evaluates code passed through --eval (single argument)', async function () {
677-
cliReplOptions.shellCliOptions.eval = ['"i am" + " being evaluated"'];
678-
cliRepl = new CliRepl(cliReplOptions);
679-
await startWithExpectedImmediateExit(cliRepl, '');
680-
expect(output).to.include('i am being evaluated');
681-
expect(exitCode).to.equal(0);
682-
});
684+
it('evaluates code passed through --eval (single argument)', async function () {
685+
cliReplOptions.shellCliOptions.eval = [
686+
'"i am" + " being evaluated"',
687+
];
688+
cliRepl = new CliRepl(cliReplOptions);
689+
await startWithExpectedImmediateExit(cliRepl, '');
690+
expect(output).to.include('i am being evaluated');
691+
expect(exitCode).to.equal(0);
692+
});
683693

684-
it('forwards the error if the script passed to --eval throws (single argument)', async function () {
685-
cliReplOptions.shellCliOptions.eval = ['throw new Error("oh no")'];
686-
cliRepl = new CliRepl(cliReplOptions);
687-
try {
688-
await cliRepl.start('', {});
689-
} catch (err: any) {
690-
expect(err.message).to.include('oh no');
691-
}
692-
expect(output).not.to.include('oh no');
693-
});
694+
it('forwards the error if the script passed to --eval throws (single argument)', async function () {
695+
cliReplOptions.shellCliOptions.eval = [
696+
'throw new Error("oh no")',
697+
];
698+
cliRepl = new CliRepl(cliReplOptions);
699+
try {
700+
await cliRepl.start('', {});
701+
} catch (err: any) {
702+
expect(err.message).to.include('oh no');
703+
}
704+
expect(output).not.to.include('oh no');
705+
});
694706

695-
it('evaluates code passed through --eval (multiple arguments)', async function () {
696-
cliReplOptions.shellCliOptions.eval = [
697-
'X = "i am"; "asdfghjkl"',
698-
'X + " being evaluated"',
699-
];
700-
cliRepl = new CliRepl(cliReplOptions);
701-
await startWithExpectedImmediateExit(cliRepl, '');
702-
expect(output).to.not.include('asdfghjkl');
703-
expect(output).to.include('i am being evaluated');
704-
expect(exitCode).to.equal(0);
705-
});
707+
it('evaluates code passed through --eval (multiple arguments)', async function () {
708+
cliReplOptions.shellCliOptions.eval = [
709+
'X = "i am"; "asdfghjkl"',
710+
'X + " being evaluated"',
711+
];
712+
cliRepl = new CliRepl(cliReplOptions);
713+
await startWithExpectedImmediateExit(cliRepl, '');
714+
expect(output).to.not.include('asdfghjkl');
715+
expect(output).to.include('i am being evaluated');
716+
expect(exitCode).to.equal(0);
717+
});
706718

707-
it('forwards the error if the script passed to --eval throws (multiple arguments)', async function () {
708-
cliReplOptions.shellCliOptions.eval = [
709-
'throw new Error("oh no")',
710-
'asdfghjkl',
711-
];
712-
cliRepl = new CliRepl(cliReplOptions);
713-
try {
714-
await cliRepl.start('', {});
715-
} catch (err: any) {
716-
expect(err.message).to.include('oh no');
719+
it('forwards the error if the script passed to --eval throws (multiple arguments)', async function () {
720+
cliReplOptions.shellCliOptions.eval = [
721+
'throw new Error("oh no")',
722+
'asdfghjkl',
723+
];
724+
cliRepl = new CliRepl(cliReplOptions);
725+
try {
726+
await cliRepl.start('', {});
727+
} catch (err: any) {
728+
expect(err.message).to.include('oh no');
729+
}
730+
expect(output).to.not.include('asdfghjkl');
731+
expect(output).not.to.include('oh no');
732+
});
733+
734+
it('evaluates code in the expected environment (non-interactive)', async function () {
735+
cliReplOptions.shellCliOptions.eval = [
736+
'print(":::" + (globalThis[Symbol.for("@@mongosh.usingPlainVMContext")] ? "plain-vm" : "repl"))',
737+
];
738+
cliRepl = new CliRepl(cliReplOptions);
739+
await startWithExpectedImmediateExit(cliRepl, '');
740+
expect(output).to.include(`:::${jsContext ?? 'plain-vm'}`);
741+
expect(exitCode).to.equal(0);
742+
});
743+
744+
if (jsContext !== 'plain-vm') {
745+
it('evaluates code in the expected environment (interactive)', async function () {
746+
cliReplOptions.shellCliOptions.eval = [
747+
'print(":::" + (globalThis[Symbol.for("@@mongosh.usingPlainVMContext")] ? "plain-vm" : "repl"))',
748+
];
749+
cliReplOptions.shellCliOptions.shell = true;
750+
cliRepl = new CliRepl(cliReplOptions);
751+
await cliRepl.start('', {});
752+
input.write('exit\n');
753+
await waitBus(cliRepl.bus, 'mongosh:closed');
754+
expect(output).to.include(`:::${jsContext ?? 'repl'}`);
755+
expect(exitCode).to.equal(0);
756+
});
757+
}
717758
}
718-
expect(output).to.not.include('asdfghjkl');
719-
expect(output).not.to.include('oh no');
720-
});
721-
});
759+
);
760+
}
722761

723762
context('in --json mode', function () {
724763
beforeEach(function () {

0 commit comments

Comments
 (0)