Skip to content

feat(extension): native extension loader, ESM support and callback attribution - #5732

Open
chemzqm wants to merge 11 commits into
masterfrom
vscode-loader
Open

feat(extension): native extension loader, ESM support and callback attribution#5732
chemzqm wants to merge 11 commits into
masterfrom
vscode-loader

Conversation

@chemzqm

@chemzqm chemzqm commented Aug 14, 2026

Copy link
Copy Markdown
Member

Summary

Migrate coc.nvim extension loading from the VM sandbox to the VS Code Extension Host model, then add native ESM support and per-extension callback error attribution.

Changes

Native CommonJS loader

  • Extension entries now execute through Node's native require().
  • Removed vm.runInContext() execution, Module.prototype._compile patching, Module.wrap() and the custom sandbox.
  • require("coc.nvim") resolves by importing-module ownership:
    • ExtensionPathIndex maps module filenames to owning extensions (longest-root match, symlink-safe real/logical domains).
    • ExtensionApiFactory provides one stable top-level API object per extension while keeping shared core services underneath.
    • CocModuleInterceptor wraps Module._load once per manager and fails unknown importers with a diagnostic error.
    • ExtensionModuleLoader normalizes exports with the legacy semantics and preserves original errors as cause.
    • ExtensionModuleCache clears only extension-owned require.cache entries, with root safety guards.

Native ESM support

  • .mjs entries (or "type": "module" packages) load through import().
  • import ... from "coc.nvim" resolves per importer through node:module hooks.
  • ESM reload re-activates without re-executing module code (restart required to apply code changes), with a warning logged.

Callback error attribution

  • The per-extension API object wraps registration surfaces and tags callbacks with the owning extension id.
  • Command errors are prefixed with [extension: <id>]; event/provider errors and completion timeouts include the extension id in logs.
  • Provider __extensionName is derived from the registration owner instead of parsing stack traces.

Behavior changes

  • Extensions now share the JavaScript Realm with coc.nvim core (globalThis, process, constructors). The loader is intentionally not a security sandbox.
  • require("coc.nvim") returns a per-extension top-level object; shared services such as workspace remain shared underneath (now wrapped per extension for attribution).
  • CommonJS extension reload semantics are preserved.

Validation

  • Full native test suite: 124 files / 3617 tests.
  • tsc --noEmit, oxlint and the production build all pass.
  • Real extensions smoke-tested through the loader: coc-json, coc-pairs, coc-snippets, coc-yaml, coc-eslint, coc-tsserver (activate + reload).

Notes

  • Native ESM hot reload and deeper per-extension API facades are tracked as follow-ups.
  • See .codex/migration.md for the full migration plan and acceptance checklist.

Replace the VM sandbox extension loader with native Node.js require():
- ExtensionPathIndex maps module filenames to owning extensions with
  longest-root match and symlink-safe real/logical path domains
- ExtensionApiFactory provides one stable top-level API object per
  extension while keeping shared core services underneath
- CocModuleInterceptor maps require("coc.nvim") by importer ownership
  through a process-wide Module._load wrapper, failing unknown callers
- ExtensionModuleLoader executes entries with native require() and
  normalizes exports with the legacy semantics
- ExtensionModuleCache clears only extension-owned require.cache entries
  with root safety guards

Manager now loads extensions natively by default; COC_EXTENSION_LOADER
=legacy remains as a temporary comparison switch until validation
finishes. Adds characterization and unit tests plus reload ownership
integration coverage.
Delete the vm.runInContext()/Module._compile based extension loader now
that the native CommonJS loader is validated:
- delete src/util/factory.ts and its sandbox tests
- drop the COC_EXTENSION_LOADER comparison switch, native loading is the
  only production path
- remove sandbox-specific coverage from modules-util tests and add
  load-retry and symlink-entry cases to the native loader tests

Full suite passes with one production loader: native Node.js CommonJS
semantics, no Module.prototype._compile patching, no vm-based extension
execution.
Add a changelog entry for the native CommonJS loader and its behavior
changes (shared realm, per-extension API identity, scoped reload cache).
Load ESM extension entries (".mjs" or package "type": "module")
through native import() and resolve import ... from "coc.nvim" by
importer ownership using process-wide node:module hooks:
- module hooks map "coc.nvim" to a per-owner virtual module; all ESM
  files of one extension share one module instance and API object while
  different extensions receive different objects, unknown importers fail
  with the same diagnostic as the CommonJS interceptor
- normalizeExtensionExports accepts ESM namespaces: named activate,
  default function, or default object with activate/deactivate
- extension module type is derived from the entry extension and the
  package "type" field at registration time
- ESM reload re-activates the extension without re-executing module
  code (restart required for code changes), with a warning logged

Adds hook unit tests, loader ESM cases, and a manager integration test
covering activation and cached reload behavior.
Close the remaining extension-loader migration plan items:
- log extension id, entry, module type and load result from the native
  loader, plus activation success from the manager (§9.4)
- register extension ownership before installing the interceptor (§8.1)
- add reload failure-path tests: deactivate order before cache cleanup,
  cleanup continues when deactivate throws, failed reload activation
  leaves the extension inactive (§13.5)
- add JSON require coverage (§13.1), async activate rejection (§5.2)
  and cross-realm error identity (§12.3)
- validate coc-eslint (ESLint-style) and a real extension installed
  through a symlink, both activate and reload (§14)
The per-extension API object now wraps registration surfaces
(commands.registerCommand/register, events.on, languages.register*)
and tags the registered callback with the extension id. Diagnostics can
therefore name the plugin without parsing stack traces:
- command errors shown by Vim are prefixed with [extension: <id>]
- event handler errors and slow-handler warnings include the extension id
- provider errors logged through Manager.handleResults and completion
  timeouts/errors include the extension id
- provider __extensionName is derived from the registration owner instead
  of the captured stack

Registration methods keep their existing signatures; the extension id is
attached to the callback object by the wrapper, and manager registration
surfaces consume it from there.
Address review findings on the attribution work:
- keep g:coc_timeout_sources as plain source names; the extension-attributed
  form only appears in the timeout log
- widen attribution to events.once, the Command-object form of
  commands.register, workspace.onDid*/onWill* listeners, workspace keymap
  registration and registerBufferSync
- look up path ownership per entry (real path then logical path) so deepest
  root still wins when symlink and logical roots interleave
- fail with a clear owner diagnostic when an ESM importer has a non-file URL
- correct the Module._load interceptor comment to describe per-manager
  installation and chaining

Adds unit coverage for the newly wrapped surfaces.
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.57627% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.97%. Comparing base (2b3f04c) to head (5289234).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
src/util/extensionId.ts 77.19% 13 Missing ⚠️
src/extension/apiFactory.ts 92.70% 6 Missing and 1 partial ⚠️
src/extension/moduleLoader.ts 95.91% 3 Missing and 1 partial ⚠️
src/completion/complete.ts 71.42% 1 Missing and 1 partial ⚠️
src/events.ts 83.33% 2 Missing ⚠️
src/extension/moduleHook.ts 98.60% 2 Missing ⚠️
src/extension/pathIndex.ts 98.24% 1 Missing ⚠️
src/provider/manager.ts 93.75% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##           master    #5732    +/-   ##
========================================
  Coverage   97.96%   97.97%            
========================================
  Files         297      302     +5     
  Lines       50174    50555   +381     
  Branches     8668     8768   +100     
========================================
+ Hits        49155    49530   +375     
- Misses        900      906     +6     
  Partials      119      119            

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@chemzqm

chemzqm commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

@codex review

Replace the Module._load interceptor and ESM loader hooks with a single
process-wide node:module resolve hook that routes both require("coc.nvim")
and import ... from "coc.nvim" by importer ownership.

- Resolve module type from the nearest package.json for nested entries
- Prefer ESM named activate over a default export and preserve deactivate
- Retain ESM API identity across reload; only CJS extensions get a fresh API
- Keep prefixExtensionError working on frozen Error instances via cause
Add coverage for parentless requires, unknown importers, non-file
importers and ESM named export filtering in moduleHook.ts.
- remove orphaned src/__tests__/helper.ts and unused vm export
- merge duplicate waitImmediate, adjustRange, loadJson, escapeRegExp,
  wasm init and MCP tool schema fragments
- drop export on helpers only used within their own file
- add unit tests for extension id tagging and error prefixing edge cases
- cover apiFactory proxy wrap, method binding and onWill registration
- cover moduleLoader ExtensionLoadError rethrow and ESM no-op activate
- cover completion timeout attribution and synchronous provider throw
- extract shared noop activate and workspace registration set so the
  previously unrecorded lines become reportable
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.

1 participant