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.
- The async (inner)
resolve resolves ms to the clean URL, appends ?iitm, and stores specifiers.set('file://…/ms/index.js', 'ms') in its map.
- 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.
- 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.
buildWrapperSource emits register(realUrl, …, undefined).
- 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.
Summary
When an app has two
import-in-the-middleloaders active at once - one registered synchronously viamodule.registerHooks(import-in-the-middle/register-hooks.mjs) and one registered asynchronously viamodule.register(import-in-the-middle/hook.mjs) - aHook([...])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 usemodule.registerHookson Node ≥ 26). It is the silent successor to the crash reported in nodejs/node#57327 ("Using two instrumentation libraries, one usingmodule.register… and the other usingmodule.registerHooks"), which was fixed for theERR_INVALID_RETURN_PROPERTY_VALUEcrash 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
main)supportsSyncHooks()is true: ≥ 22.22.3 / 24.11.1 / 25.1.0 / 26.0.0)Reproduction
sync.mjs(aregisterHooks-based consumer):async.mjs(amodule.register-based consumer):probe.mjs(the instrumentation callback):app.mjs:Run each combination:
Expected
>> HOOK FIRED for msin all four runs.Actual
No error, no warning - the module loads fine, it's just not instrumented.
Root cause
The two loaders create separate
createHookinstances (sync one on the app thread viaregister-hooks.mjs; async one on the hooks thread viahook.mjs), and each has its own module-scopedspecifiersMap (create-hook.mjs). Node chains them sync (outer) → async (inner) → default.resolveresolvesmsto the clean URL, appends?iitm, and storesspecifiers.set('file://…/ms/index.js', 'ms')in its map.resolvethen receives aresult.urlthat is already?iitm-tagged, sofinishResolvestoresspecifiers.set('file://…/ms/index.js?iitm=true', 'ms')in its map - keyed on the tagged URL.loadSynchandles the tagged URL (it wins, being in-thread). IngetSourceSync,realUrl = deleteIitm(url)is the clean URL, sospecifiers.get(realUrl)misses →originalSpecifier = undefined.buildWrapperSourceemitsregister(realUrl, …, undefined).index.js, matching a top-level import requiresbaseDir.endsWith(specifiers.get(loadUrl)); with the specifierundefinedthis isbaseDir.endsWith(undefined)→endsWith('undefined')→false. TheHookwasn'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
specifierson the?iitm-tagged URL while the load path looks it up by the clean URL. Instrumentingmswith{ internals: true }makes the callback fire but with the wrongname(ms/index.js), and doesn't help the co-resident library that didn't opt in.Suggested fixes (either)
create-hook.mjs'sspecifiersmap on the clean URL - e.g.specifiers.set(deleteIitm(result.url), specifier)infinishResolve- so the tagged-vs-clean mismatch can't happen; orgetSourceSync/getSource, ifspecifiers.get(realUrl)is missing, fall back tospecifiers.get(addIitm(realUrl))before defaulting toundefined.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/internalsmatching.