Skip to content

Sync (registerHooks) + async (register) loaders co-residence: hook silently never fires #274

Description

@bareketsarusi

Summary

When an app has two import-in-the-middle loaders active at once - one registered synchronously via module.registerHooks (import-in-the-middle/register-hooks.mjs) and one registered asynchronously via module.register (import-in-the-middle/hook.mjs) - a Hook([...]) for a top-level package silently never fires, even though the module is resolved, tagged ?iitm, and wrapped successfully. Each loader works perfectly on its own.

This is the real-world "two APMs" scenario (e.g. OpenTelemetry uses module.register, while dd-trace and others now use module.registerHooks on Node ≥ 26). It is the silent successor to the crash reported in nodejs/node#57327 ("Using two instrumentation libraries, one using module.register … and the other using module.registerHooks"), which was fixed for the ERR_INVALID_RETURN_PROPERTY_VALUE crash by nodejs/node#59929 (22.22.3 / 24.11.1 / 25.1.0 / 26.0.0) - but on those same fixed versions the co-residence now fails silently instead.

Environment

  • import-in-the-middle: 3.3.1 (also present on main)
  • Node.js: 26.5.0 (reproduces on any version where supportsSyncHooks() is true: ≥ 22.22.3 / 24.11.1 / 25.1.0 / 26.0.0)
  • Platform: macOS arm64

Reproduction

mkdir iitm-coresidence-repro && cd iitm-coresidence-repro
npm init -y
npm install import-in-the-middle@3.3.1 ms@2.1.3

sync.mjs (a registerHooks-based consumer):

import { register } from 'import-in-the-middle/register-hooks.mjs'
register({ include: ['ms'] })

async.mjs (a module.register-based consumer):

import { register } from 'node:module'
register('import-in-the-middle/hook.mjs', import.meta.url, { data: { include: ['ms'] } })

probe.mjs (the instrumentation callback):

import { Hook } from 'import-in-the-middle'
new Hook(['ms'], (exports, name) => {
  console.log('>> HOOK FIRED for', name)
  return exports
})

app.mjs:

import ms from 'ms'
console.log('app loaded ms, type=', typeof ms)

Run each combination:

node --import ./sync.mjs                    --import ./probe.mjs ./app.mjs   # A
node --import ./async.mjs                   --import ./probe.mjs ./app.mjs   # B
node --import ./async.mjs --import ./sync.mjs --import ./probe.mjs ./app.mjs # C
node --import ./sync.mjs --import ./async.mjs --import ./probe.mjs ./app.mjs # D

Expected

>> HOOK FIRED for ms in all four runs.

Actual

A) sync only     -> >> HOOK FIRED for ms      ✅
B) async only    -> >> HOOK FIRED for ms      ✅
C) async + sync  -> (no output)               ❌ hook never fires
D) sync + async  -> (no output)               ❌ hook never fires

No error, no warning - the module loads fine, it's just not instrumented.

Root cause

The two loaders create separate createHook instances (sync one on the app thread via register-hooks.mjs; async one on the hooks thread via hook.mjs), and each has its own module-scoped specifiers Map (create-hook.mjs). Node chains them sync (outer) → async (inner) → default.

  1. The async (inner) resolve resolves ms to the clean URL, appends ?iitm, and stores specifiers.set('file://…/ms/index.js', 'ms') in its map.
  2. The sync (outer) resolve then receives a result.url that is already ?iitm-tagged, so finishResolve stores specifiers.set('file://…/ms/index.js?iitm=true', 'ms') in its map - keyed on the tagged URL.
  3. The sync loadSync handles the tagged URL (it wins, being in-thread). In getSourceSync, realUrl = deleteIitm(url) is the clean URL, so specifiers.get(realUrl) misses → originalSpecifier = undefined.
  4. buildWrapperSource emits register(realUrl, …, undefined).
  5. In index.js, matching a top-level import requires baseDir.endsWith(specifiers.get(loadUrl)); with the specifier undefined this is baseDir.endsWith(undefined)endsWith('undefined')false. The Hook wasn't created with { internals: true }, so there's no fallback, and the callback never runs.

So the specifier is lost purely because the outer (sync) resolve keys specifiers on the ?iitm-tagged URL while the load path looks it up by the clean URL. Instrumenting ms with { internals: true } makes the callback fire but with the wrong name (ms/index.js), and doesn't help the co-resident library that didn't opt in.

Suggested fixes (either)

  • Key create-hook.mjs's specifiers map on the clean URL - e.g. specifiers.set(deleteIitm(result.url), specifier) in finishResolve - so the tagged-vs-clean mismatch can't happen; or
  • In getSourceSync / getSource, if specifiers.get(realUrl) is missing, fall back to specifiers.get(addIitm(realUrl)) before defaulting to undefined.

This is likely related to the baseDir.endsWith(specifiers.get(...)) fragility discussed in #185 / #238 / #239, but the trigger here is specifically sync+async loader co-residence, not sub-path/internals matching.

Metadata

Metadata

Assignees

No one assigned

    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