Skip to content

Using require(esm) breaks the mechanism for intercepting the vscode module #285297

@mizdra

Description

@mizdra

Summary: Using require(esm) breaks the mechanism for intercepting the vscode module. This appears to be a bug in Node.js module loader. I believe a workaround should be implemented in vscode until this Node.js bug is resolved.

Does this issue occur when all extensions are disabled?: Yes

  • VS Code Version: 1.107.1
  • OS Version: macOS 26.1

Steps to Reproduce:

  1. Clone https://github.com/mizdra/repro-vscode-test-hang
  2. cd repro-vscode-test-hang && npm i & npm start

Description

Suppose we have the following extension test file. This test file is an ESM and is imported via require(esm) from test/runner.cjs.

// test/index.test.js
import assert from 'node:assert/strict';
import * as vscode from 'vscode';

assert.equal(1, 2);
// test/runner.cjs
exports.run = async function run() {
  require('./index.test.js');
}

Executing test/runner.cjs by @vscode/test-electron causes the application to hang.

Expected behavior

The test will run until completion.

$ npm start
> [email protected] start
> node test/runTest.js

✔ Validated version: 1.107.1
✔ Found existing install in /Users/mizdra/src/github.com/mizdra/repro-vscode-test-hang/.vscode-test/vscode-darwin-arm64-1.107.1
[main 2025-12-28T16:08:16.301Z] update#setState disabled
[main 2025-12-28T16:08:16.302Z] update#ctor - updates are disabled by the environment
ChatSessionStore: Migrating 0 chat sessions from storage service to file system
Started local extension host with pid 33448.
MCP Registry configured: https://api.mcp.github.com
Settings Sync: Account status changed from uninitialized to unavailable
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

1 !== 2

	at file:///Users/mizdra/src/github.com/mizdra/repro-vscode-test-hang/test/index.test.js:6:8
	at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
	at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:665:26)
	at async Object.run (/Users/mizdra/src/github.com/mizdra/repro-vscode-test-hang/test/runner.cjs:6:3)
[main 2025-12-28T16:08:17.527Z] Extension host with pid 33448 exited with code: 0, signal: unknown.
Exit code:   1
TestRunFailedError: Test run failed with code 1
    at ChildProcess.onProcessClosed (/Users/mizdra/src/github.com/mizdra/repro-vscode-test-hang/node_modules/@vscode/test-electron/out/runTest.js:110:24)
    at ChildProcess.emit (node:events:508:28)
    at ChildProcess._handle.onexit (node:internal/child_process:293:12) {
  code: 1,
  signal: undefined
}
Failed to run tests

Actual behavior

The test hangs during execution and does not complete.

$ npm start
> [email protected] start
> node test/runTest.js

✔ Validated version: 1.107.1
✔ Found existing install in /Users/mizdra/src/github.com/mizdra/repro-vscode-test-hang/.vscode-test/vscode-darwin-arm64-1.107.1
[main 2025-12-28T16:09:16.678Z] update#setState disabled
[main 2025-12-28T16:09:16.680Z] update#ctor - updates are disabled by the environment
ChatSessionStore: Migrating 0 chat sessions from storage service to file system
Started local extension host with pid 36009.
MCP Registry configured: https://api.mcp.github.com
Settings Sync: Account status changed from uninitialized to unavailable
(...hangs here...)

Description

VS Code has a mechanism to intercept the vscode module imported from an extension. This provides a dedicated Extension API for each extension.

Below, module._load is used to intercept require('vscode'):

node_module._load = function load(request: string, parent: { filename: string }, isMain: boolean) {
request = applyAlternatives(request);
if (!that._factories.has(request)) {
return originalLoad.apply(this, arguments);
}
return that._factories.get(request)!.load(
request,
URI.file(realpathSync(parent.filename)),
request => originalLoad.apply(this, [request, parent, isMain])
);
};

Here, a hook registered with module.register is used to intercept import * as vscode from 'vscode':

export const resolve = async (specifier, context, nextResolve) => {
if (specifier !== 'vscode' || !context.parentURL) {
return nextResolve(specifier, context);
}
const otherUrl = await lookup(context.parentURL);
return {
url: otherUrl,
shortCircuit: true,
};
};`;

The hook operates in the following steps:

  1. Send parentURL (the URL of the module attempting to import the vscode module) to the main thread
  2. On the main thread, generate the appropriate vscode module code for each parentURL, convert it to a Data URI, and send it to the hook thread
    • port1LayerCheckerWorkaround.onmessage = (e: { data: Message }) => {
      // Get the vscode-module factory - which is the same logic that's also used by
      // the CommonJS require interceptor
      if (!apiModuleFactory) {
      apiModuleFactory = this._factories.get('vscode');
      assertType(apiModuleFactory);
      }
      const { id, url } = e.data;
      const uri = URI.parse(url);
      // Get or create the API instance. The interface is per extension and extensions are
      // looked up by the uri (e.data.url) and path containment.
      const apiInstance = apiModuleFactory.load('_not_used', uri, () => { throw new Error('CANNOT LOAD MODULE from here.'); });
      let key = apiInstances.get(apiInstance);
      if (!key) {
      key = generateUuid();
      apiInstances.set(apiInstance, key);
      }
      // Create and cache a data-url which is the import script for the API instance
      let scriptDataUrlSrc = apiImportDataUrl.get(key);
      if (!scriptDataUrlSrc) {
      const jsCode = `const _vscodeInstance = globalThis.${NodeModuleESMInterceptor._vscodeImportFnName}('${key}');\n\n${Object.keys(apiInstance).map((name => `export const ${name} = _vscodeInstance['${name}'];`)).join('\n')}`;
      scriptDataUrlSrc = NodeModuleESMInterceptor._createDataUri(jsCode);
      apiImportDataUrl.set(key, scriptDataUrlSrc);
      }
      port1.postMessage({
      id,
      url: scriptDataUrlSrc
      });
      };
  3. The hook receives the Data URI and returns it as the result of the vscode module's resolve.

Data transfer between threads is performed using MessageChannel. Since data receiving is asynchronous, the resolve hook is implemented as an asynchronous function.

Incidentally, the asynchronous resolve hook appears to cause race conditions. This results in the following symptoms:

  • console.log executed from the hook may not output to stdout
    • In Node.js 22.21.1, the issue only reproduces when used with require(esm).
    • In Node.js 25.2.1, the issue reproduces with both require(esm) and import(esm).
  • Messages sent from the loader hook thread may not be received on the main thread
    • In Node.js 22.21.1, the issue only reproduces when used with require(esm).
    • In Node.js 25.2.1, the issue reproduces with both require(esm) and import(esm).

This resembles nodejs/node#60380 fixed in Node.js 24.12.0 and 25.2.0. However, this issue still reproduces in Node.js 25.2.1, suggesting it may be a different bug. The reproduction code is below.

Due to this bug, importing the vscode module in VS Code never completes, causing the application to hang.

VS Code is affected by this Node.js bug. When you run import * as vscode from 'vscode', execution stops at step 2. As a result, the application hangs.

How to Fix

There are several ways to address this issue.

1. Fix the bug in Node.js

One approach is to report the bug reproduced in https://github.com/mizdra/repro-vscode-test-hang-simple to the Node.js team and have them fix it. I plan to report this to the Node.js team soon. However, it may take some time before the bug is fixed.

Even after a fixed version of Node.js is released, it may take additional time for that version to be bundled with Electron and VS Code. Therefore, I believe a temporary workaround should be implemented on the VS Code side.

2. Implement a workaround in VS Code

It appears that using module.registerHooks can avoid the problem. With this approach, you can avoid using MessageChannel and implement the hook synchronously.

I also tried using Atomics.wait to convert asynchronous code into synchronous code. However, this did not resolve the issue. For some reason, the onmessage event on the main thread did not fire. This suggests that executing postMessage from the resolve hook is unreliable.

Workaround

Use import(esm) instead of require(esm)

The bug in https://github.com/mizdra/repro-vscode-test-hang-simple can be avoided in Node.js 22.21.1 by using import(esm). Since VS Code 1.107.1 uses Node.js 22.21.1, you can work around the issue as follows:

 // test/runner.cjs
 exports.run = async function run() {
-  require('./index.test.js');
+  await import('./index.test.js');
 }

However, in Node.js 25.2.1, this workaround does not work even if you use import(esm). If a future version of VS Code uses Node.js 25.2.1, this workaround may no longer be effective.

Use require('vscode') instead of import * as vscode from 'vscode'

Using require(‘vscode’) will be intercepted by module._load. This avoids the issue because it is not affected by the Node.js bug.

 // test/index.test.js
 import assert from 'node:assert/strict';
-import * as vscode from 'vscode';
+import { createRequire } from 'node:module';
+const require = createRequire(import.meta.url);
+const vscode = require('vscode');

 assert.equal(1, 2);

Additional Information

@vscode/test-cli uses mocha to run test files. Mocha imports test files via require(esm) (ref: mochajs/mocha#5366). Therefore, users of @vscode/test-cli are affected by this issue.

It appears other users have also encountered this problem.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions