Skip to content

Commit 033acc5

Browse files
committed
feat(instrumentor): apply function hooks to ESM via MessagePort
Establish a MessagePort channel between the main thread and the ESM loader thread. After bug detectors register their hooks and finalizeHooks() completes, sendHooksToLoader() serializes the hook metadata and posts it to the loader. The loader uses receiveMessageOnPort() — a synchronous, non-blocking read — to drain the message queue at the start of each module load. Received hooks are registered as stubs (with no-op functions) in the loader's own hookManager, which the functionHooks Babel plugin reads from. At runtime, the instrumented code calls HookManager.callHook() on the main-thread global, where the real hook functions live. Explicit hook IDs in the serialization format guard against index mismatches between threads. The MessagePort requires Node >= 20.11 (transferList support in module.register); older 20.x builds degrade to ESM instrumentation without function hooks.
1 parent 6ee344e commit 033acc5

4 files changed

Lines changed: 366 additions & 13 deletions

File tree

packages/core/core.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ export async function initFuzzing(
126126
getJazzerJsGlobal<vm.Context>("vmContext") ?? globalThis,
127127
);
128128

129+
// Send the finalized hook definitions to the ESM loader thread
130+
// so it can apply function-hook transforms to user modules.
131+
// This must happen after finalizeHooks (hooks are complete) and
132+
// before loadFuzzFunction (user modules are imported).
133+
instrumentor.sendHooksToLoader();
134+
129135
return instrumentor;
130136
}
131137

packages/instrumentor/esm-loader.mts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import type { PluginItem } from "@babel/core";
2828
import { createRequire } from "node:module";
2929
import { fileURLToPath } from "node:url";
30+
import { receiveMessageOnPort, type MessagePort } from "node:worker_threads";
3031

3132
// Load CJS-compiled Babel plugins via createRequire so we don't
3233
// depend on Node.js CJS-named-export detection (varies by version).
@@ -39,6 +40,14 @@ const { compareHooks } =
3940
require("./plugins/compareHooks.js") as typeof import("./plugins/compareHooks.js");
4041
const { sourceCodeCoverage } =
4142
require("./plugins/sourceCodeCoverage.js") as typeof import("./plugins/sourceCodeCoverage.js");
43+
const { functionHooks } =
44+
require("./plugins/functionHooks.js") as typeof import("./plugins/functionHooks.js");
45+
46+
// The loader thread has its own CJS module cache, so this is a
47+
// separate HookManager instance from the main thread's. We populate
48+
// it with stub hooks from the serialized data we receive via the port.
49+
const { hookManager: loaderHookManager } =
50+
require("@jazzer.js/hooking") as typeof import("@jazzer.js/hooking");
4251

4352
// Already-instrumented code contains this marker.
4453
const INSTRUMENTATION_MARKER = "Fuzzer.coverageTracker.incrementCounter";
@@ -50,12 +59,17 @@ interface LoaderConfig {
5059
includes: string[];
5160
excludes: string[];
5261
coverage: boolean;
62+
port?: MessagePort;
5363
}
5464

5565
let config: LoaderConfig;
66+
let loaderPort: MessagePort | null = null;
5667

5768
export function initialize(data: LoaderConfig): void {
5869
config = data;
70+
if (data.port) {
71+
loaderPort = data.port;
72+
}
5973
}
6074

6175
interface LoadResult {
@@ -119,6 +133,8 @@ export const load: LoadFn = async function load(url, context, nextLoad) {
119133
// ── Instrumentation ──────────────────────────────────────────────
120134

121135
function instrumentModule(code: string, filename: string): string | null {
136+
drainHookUpdates();
137+
122138
const fuzzerCoverage = esmCodeCoverage();
123139

124140
const plugins: PluginItem[] = [fuzzerCoverage.plugin, compareHooks];
@@ -131,6 +147,14 @@ function instrumentModule(code: string, filename: string): string | null {
131147
plugins.push(sourceCodeCoverage(filename));
132148
}
133149

150+
// Apply function hooks if the main thread has sent hook definitions
151+
// and any of them target functions in this file. The instrumented
152+
// code calls HookManager.callHook(id, ...) at runtime, which
153+
// resolves to the real hook function on the main thread.
154+
if (loaderHookManager.hasFunctionsToHook(filename)) {
155+
plugins.push(functionHooks(filename));
156+
}
157+
134158
let transformed: ReturnType<typeof transformSync>;
135159
try {
136160
transformed = transformSync(code, {
@@ -178,6 +202,55 @@ function instrumentModule(code: string, filename: string): string | null {
178202
return preambleLines.join("\n") + "\n" + transformed.code;
179203
}
180204

205+
// ── Function hooks from the main thread ──────────────────────────
206+
207+
interface SerializedHook {
208+
id: number;
209+
type: number;
210+
target: string;
211+
pkg: string;
212+
async: boolean;
213+
}
214+
215+
const noop = () => {};
216+
217+
/**
218+
* Synchronously drain any hook-definition messages from the main
219+
* thread. Uses receiveMessageOnPort — a non-blocking, synchronous
220+
* read — so we never have to await or restructure the load() flow.
221+
*
222+
* The main thread sends hook data after finalizeHooks() and before
223+
* user modules are loaded, so the message is always available by the
224+
* time we process user code.
225+
*/
226+
function drainHookUpdates(): void {
227+
if (!loaderPort) return;
228+
229+
let msg;
230+
while ((msg = receiveMessageOnPort(loaderPort))) {
231+
const hooks = msg.message.hooks as SerializedHook[];
232+
for (const h of hooks) {
233+
const stub = loaderHookManager.registerHook(
234+
h.type,
235+
h.target,
236+
h.pkg,
237+
h.async,
238+
noop,
239+
);
240+
// Sanity check: the stub's index in the loader must match the
241+
// main thread's index so that runtime HookManager.callHook(id)
242+
// invokes the correct hook function.
243+
const actualId = loaderHookManager.hookIndex(stub);
244+
if (actualId !== h.id) {
245+
throw new Error(
246+
`ESM hook ID mismatch: expected ${h.id}, got ${actualId} ` +
247+
`for ${h.target} in ${h.pkg}`,
248+
);
249+
}
250+
}
251+
}
252+
}
253+
181254
// ── Include / exclude filtering ──────────────────────────────────
182255

183256
function shouldInstrument(filepath: string): boolean {
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/*
2+
* Copyright 2026 Code Intelligence GmbH
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { transformSync } from "@babel/core";
18+
19+
import { hookManager, HookType } from "@jazzer.js/hooking";
20+
21+
import { Instrumentor, SerializedHook } from "./instrument";
22+
import { functionHooks } from "./plugins/functionHooks";
23+
24+
/**
25+
* These tests verify the ESM function-hook wiring: serialization of
26+
* hooks on the main thread, registration of stubs in a simulated
27+
* loader hookManager, and correct Babel output with matching IDs.
28+
*
29+
* We cannot spawn a real loader thread in a unit test, so we exercise
30+
* the same logic inline: register stub hooks in the global hookManager
31+
* (which the functionHooks plugin reads from) and verify the output.
32+
*/
33+
34+
afterEach(() => {
35+
hookManager.clearHooks();
36+
});
37+
38+
describe("ESM function hook serialization", () => {
39+
it("should serialize hooks with explicit IDs", () => {
40+
hookManager.registerHook(
41+
HookType.Before,
42+
"execSync",
43+
"child_process",
44+
false,
45+
() => {},
46+
);
47+
hookManager.registerHook(
48+
HookType.Replace,
49+
"fetch",
50+
"node-fetch",
51+
true,
52+
() => {},
53+
);
54+
55+
const serialized: SerializedHook[] = hookManager.hooks.map(
56+
(hook, index) => ({
57+
id: index,
58+
type: hook.type,
59+
target: hook.target,
60+
pkg: hook.pkg,
61+
async: hook.async,
62+
}),
63+
);
64+
65+
expect(serialized).toEqual([
66+
{
67+
id: 0,
68+
type: HookType.Before,
69+
target: "execSync",
70+
pkg: "child_process",
71+
async: false,
72+
},
73+
{
74+
id: 1,
75+
type: HookType.Replace,
76+
target: "fetch",
77+
pkg: "node-fetch",
78+
async: true,
79+
},
80+
]);
81+
});
82+
83+
it("should round-trip through JSON (MessagePort serialization)", () => {
84+
hookManager.registerHook(HookType.After, "readFile", "fs", false, () => {});
85+
86+
const serialized: SerializedHook[] = hookManager.hooks.map(
87+
(hook, index) => ({
88+
id: index,
89+
type: hook.type,
90+
target: hook.target,
91+
pkg: hook.pkg,
92+
async: hook.async,
93+
}),
94+
);
95+
96+
// structuredClone simulates what MessagePort does
97+
const received = structuredClone(serialized);
98+
expect(received).toEqual(serialized);
99+
expect(received[0].type).toBe(HookType.After);
100+
});
101+
});
102+
103+
describe("ESM function hook stub registration", () => {
104+
it("should produce matching IDs when stubs are registered in order", () => {
105+
// Simulate the main thread registering real hooks
106+
const realHook1 = hookManager.registerHook(
107+
HookType.Before,
108+
"execSync",
109+
"child_process",
110+
false,
111+
() => {},
112+
);
113+
const realHook2 = hookManager.registerHook(
114+
HookType.Replace,
115+
"connect",
116+
"net",
117+
false,
118+
() => {},
119+
);
120+
121+
const mainId1 = hookManager.hookIndex(realHook1);
122+
const mainId2 = hookManager.hookIndex(realHook2);
123+
124+
// Serialize
125+
const serialized: SerializedHook[] = hookManager.hooks.map(
126+
(hook, index) => ({
127+
id: index,
128+
type: hook.type,
129+
target: hook.target,
130+
pkg: hook.pkg,
131+
async: hook.async,
132+
}),
133+
);
134+
135+
// Clear and re-register as the loader thread would
136+
hookManager.clearHooks();
137+
for (const h of serialized) {
138+
const stub = hookManager.registerHook(
139+
h.type,
140+
h.target,
141+
h.pkg,
142+
h.async,
143+
() => {},
144+
);
145+
expect(hookManager.hookIndex(stub)).toBe(h.id);
146+
}
147+
148+
// IDs in the loader match the original main-thread IDs
149+
expect(hookManager.hookIndex(hookManager.hooks[0])).toBe(mainId1);
150+
expect(hookManager.hookIndex(hookManager.hooks[1])).toBe(mainId2);
151+
});
152+
});
153+
154+
describe("ESM function hook Babel output", () => {
155+
it("should insert HookManager.callHook with the correct hook ID", () => {
156+
hookManager.registerHook(
157+
HookType.Before,
158+
"processInput",
159+
"target-pkg",
160+
false,
161+
() => {},
162+
);
163+
164+
const result = transformSync(
165+
"function processInput(data) { return data.trim(); }",
166+
{
167+
filename: "/app/node_modules/target-pkg/index.js",
168+
plugins: [functionHooks("/app/node_modules/target-pkg/index.js")],
169+
},
170+
);
171+
172+
expect(result?.code).toContain("HookManager.callHook(0,");
173+
expect(result?.code).toContain("this, [data]");
174+
});
175+
176+
it("should not hook functions in non-matching files", () => {
177+
hookManager.registerHook(
178+
HookType.Before,
179+
"dangerous",
180+
"target-pkg",
181+
false,
182+
() => {},
183+
);
184+
185+
const result = transformSync("function dangerous(x) { return x; }", {
186+
filename: "/app/node_modules/other-pkg/lib.js",
187+
plugins: [functionHooks("/app/node_modules/other-pkg/lib.js")],
188+
});
189+
190+
expect(result?.code).not.toContain("HookManager.callHook");
191+
});
192+
193+
it("should use sendHooksToLoader to serialize from Instrumentor", () => {
194+
hookManager.registerHook(
195+
HookType.Before,
196+
"exec",
197+
"child_process",
198+
false,
199+
() => {},
200+
);
201+
202+
const instrumentor = new Instrumentor();
203+
204+
// Without a port, sendHooksToLoader is a no-op (no crash)
205+
expect(() => instrumentor.sendHooksToLoader()).not.toThrow();
206+
});
207+
});

0 commit comments

Comments
 (0)