fix: Node.js 24 compatibility — four test failures resolved#191
Merged
dividedmind merged 7 commits intomainfrom Mar 16, 2026
Merged
fix: Node.js 24 compatibility — four test failures resolved#191dividedmind merged 7 commits intomainfrom
dividedmind merged 7 commits intomainfrom
Conversation
Node 24's util.inspect changed Symbol property format from `[Symbol(foo)]` to `Symbol(foo)`. The `fixValue()` helper in tests was already stripping these to keep snapshots stable across Node versions, but the regex only matched the old bracketed format. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses Node.js 24 compatibility regressions by stabilizing test snapshot normalization and fixing async-context-sensitive instrumentation so recorded AppMap event ordering remains correct across Node versions.
Changes:
- Stabilize test outputs across Node versions (ObjectId / symbol formatting, deterministic HTTP AppMap filename ordering).
- Fix sqlite/mysql hook event ordering by restoring captured
AsyncLocalStoragecontext when emitting completion events; update affected snapshots. - Remove the racy ESM “force-require” side-channel by pre-patching configured modules at startup; update CI to include Node 22/24.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
test/httpServer.test.ts |
Adds small inter-request delays to make AppMap filename ordering deterministic under Node 24 timing. |
test/helpers.ts |
Updates snapshot normalizers for Node 24 util.inspect changes and MongoDB ObjectId formatting. |
test/__snapshots__/sqlite.test.ts.snap |
Updates sqlite snapshot to reflect corrected event interleaving/ordering. |
test/__snapshots__/mongo.test.ts.snap |
Updates Mongo snapshot to match the new ObjectId normalization. |
src/register.ts |
Pre-patches configured modules and Prisma module IDs at startup; removes BroadcastChannel-based ESM side-channel. |
src/loader.ts |
Removes now-unneeded resolve() hook and forceRequire() call sites. |
src/hooks/sqlite.ts |
Captures/restores recording async context so sqlite completion emits return events in causal order. |
src/hooks/mysql.ts |
Captures/restores recording async context so mysql completion emits return/exception events in causal order. |
.github/workflows/publish.yml |
Bumps action majors and pins publish job to Node 24. |
.github/workflows/ci.yml |
Expands Node matrix to include 22/24; bumps action majors; updates artifact upload action major. |
Comments suppressed due to low confidence (1)
src/hooks/sqlite.ts:108
- The captured async context is restored only for emitting the SQL
functionReturnevents, butoriginalCompletionCallbackis invoked outsideRecording.run(...). If the user callback performs additional instrumented work, those events will still be recorded in the wrong async buffer/order. Consider invokingoriginalCompletionCallbackinside theRecording.run(asyncContext, ...)block (and ensure it still runs even on error).
),
);
}
originalCompletionCallback?.apply(this, args);
};
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…t ordering Native DB callbacks (sqlite3, mysql) fire outside any AsyncLocalStorage context in Node 24+, causing return events to be appended to the root buffer after all call events instead of inline at the correct position. Fix by capturing the context at call time and restoring it when emitting the return event. Update sqlite snapshot to reflect the now-correct ordering. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…estamps Node 24 can complete consecutive requests within the same millisecond, giving them identical filename prefixes and non-deterministic glob ordering. 10ms gaps guarantee each request gets a unique timestamp. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t startup
## Background
To instrument library modules (e.g. json5, node:console) under ESM, appmap-node
used a CJS-cache trick: requiring a module via CommonJS pre-populates the cache
that ESM also consults for CJS-format packages and built-ins. The challenge is
doing this in the main thread before any ESM module body evaluates the import.
The previous mechanism:
1. The ESM loader's resolve() hook (running in a separate worker thread in
Node 22+) called forceRequire(specifier), which:
a. Called cjsRequire(specifier) in the loader thread (ineffective in Node 22+
since the loader thread has its own separate CJS cache)
b. Posted a BroadcastChannel message to the main thread
2. The main thread's esmRequireChannel.onmessage handler received the message
and called cjsRequire(specifier) to patch the module in the main thread's cache.
This was always racy: BroadcastChannel messages are delivered asynchronously
through the event loop. In Node 24, the main thread consistently evaluates ESM
module bodies before the broadcast message is processed, so the patched version
of the module was never seen by the importing code.
The existing http/https pre-patching in register.ts (added earlier to address
the same race for those specific modules) was the correct pattern — it just
wasn't generalised to all configured library modules.
## Fix
Move all library module pre-patching into register.ts, which runs via --require
in the main thread before any ESM evaluation takes place. This makes the patching
unconditionally race-free regardless of Node.js version or scheduling behaviour.
- Extend the pre-patch loop to cover all packages with a `module` field in the
user's appmap.yml config (previously only node:http and node:https were covered)
- Also pre-patch Prisma client module IDs, which were previously patched via
forceRequire() in the loader's load() hook
- Extract a prePatch() helper that silently ignores missing modules (a module
listed in config may not be installed)
## Cleanup
With all patching now handled at startup, the cross-thread side-channel is
no longer needed:
- Remove the BroadcastChannel (esmRequireChannel) and its onmessage handler
from register.ts
- Remove the forceRequire() export from register.ts
- Remove the forceRequire() call sites from loader.ts (resolve() hook for
library packages, load() hook for Prisma)
- Remove the now-empty resolve() hook from loader.ts (Node only invokes
exported hooks, so a passthrough adds overhead for no benefit)
- Remove the node:worker_threads and config imports that are no longer needed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Node 24 changed util.inspect format for Symbol properties from
[Symbol(foo)] to Symbol(foo), breaking the MongoDB snapshot which
contained ObjectId { [Symbol(id)]: ... }.
Extend the ObjectId normalizer in fixValue() to also collapse the
inspect form (ObjectId { ... }) to ObjectId('...'), matching the
existing handling of the string form ObjectId('hex'). Both now
normalize to ObjectId('...') for stable cross-version snapshots.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
05e96c5 to
0d31528
Compare
commit: |
hleb-rubanau
approved these changes
Mar 13, 2026
|
🎉 This PR is included in version 2.26.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Node.js 24 introduced several behavioural changes that broke the test suite. This PR fixes all four failures. Two are functional bugs that affected correctness on all Node versions once properly diagnosed; the other two are test infrastructure issues.
1. Symbol properties in
util.inspectoutput (test/helpers.ts)Node 24 changed the serialisation format of enumerable symbol properties in
util.inspectfrom[Symbol(foo)]: valuetoSymbol(foo): value(no brackets). ThefixValue()normaliser in test helpers already stripped these to keep snapshots stable across Node versions, but its regex required the leading bracket.Fix:
\[Symbol→\[?Symbol(one character).2. SQL return events out of order (
src/hooks/sqlite.ts,src/hooks/mysql.ts)Native sqlite3/mysql callbacks fire outside any
AsyncLocalStoragecontext in Node 24, causing SQL return events to be appended to the root event buffer instead of their correct nested position. This made return events appear after all subsequent call events in the AppMap — inaccurate regardless of Node version.Fix: capture
Recording.getContext()at the time of the DB call and restore it inside the completion callback viaRecording.run(). The sqlite snapshot is updated to reflect the now-correct interleaved event ordering.3. Non-deterministic HTTP AppMap filename ordering (
test/httpServer.test.ts)Node 24 processes consecutive HTTP requests fast enough that two requests share the same millisecond timestamp prefix in their AppMap filenames, making glob ordering non-deterministic.
Fix: add 10 ms delays between requests in the test helper
makeRequests(). Production code is unchanged.4. ESM library instrumentation race condition (
src/register.ts,src/loader.ts)The mechanism for patching library modules under ESM (e.g.
json5,node:console) relied on the loader thread sending aBroadcastChannelmessage to the main thread, which thenrequire()d the module to pre-populate the shared CJS cache before the ESM import evaluated. In Node 24 the main thread consistently evaluates ESM module bodies before the event loop delivers the broadcast message, so the patched module was never seen.This was always racy — Node 24 just made it consistently reproducible. The same race had already been acknowledged and worked around for
node:httpandnode:httpsby pre-patching them eagerly inregister.tsat startup. That pattern is now generalised:module:entries in the user'sappmap.ymlare pre-patched at startupBroadcastChannelside-channel (esmRequireChannel) and itsonmessagehandler are removedforceRequire()export is removed fromregister.tsforceRequire()call sites inloader.ts(resolve()andload()hooks) are removedresolve()hook is removed fromloader.ts(Node only invokes exported hooks, so a passthrough adds overhead for no benefit)register.tsruns via--requirebefore any ESM evaluation, so theserequire()calls are unconditionally race-free on all Node versions.Fixes #190, supersedes #189.